"
+ let port = url.port.map { ":\($0)" } ?? ""
+ let path = url.path.isEmpty ? "/" : url.path
+ return "\(host)\(port)\(path)"
+ }
+
static func parseUsageSnapshot(from data: Data) throws -> ZaiUsageSnapshot {
+ guard !data.isEmpty else {
+ throw ZaiUsageError.parseFailed("Empty response body")
+ }
+
let decoder = JSONDecoder()
let apiResponse = try decoder.decode(ZaiQuotaLimitResponse.self, from: data)
diff --git a/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift b/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift
index b2bc65d41..2a1d0f1d4 100644
--- a/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift
+++ b/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift
@@ -51,5 +51,12 @@ extension TokenAccountSupportCatalog {
injection: .cookieHeader,
requiresManualCookieSource: true,
cookieName: nil),
+ .ollama: TokenAccountSupport(
+ title: "Session tokens",
+ subtitle: "Store multiple Ollama Cookie headers.",
+ placeholder: "Cookie: …",
+ injection: .cookieHeader,
+ requiresManualCookieSource: true,
+ cookieName: nil),
]
}
diff --git a/Sources/CodexBarCore/UsageFetcher.swift b/Sources/CodexBarCore/UsageFetcher.swift
index ca300ea9f..f2370e9ca 100644
--- a/Sources/CodexBarCore/UsageFetcher.swift
+++ b/Sources/CodexBarCore/UsageFetcher.swift
@@ -54,6 +54,7 @@ public struct UsageSnapshot: Codable, Sendable {
public let providerCost: ProviderCostSnapshot?
public let zaiUsage: ZaiUsageSnapshot?
public let minimaxUsage: MiniMaxUsageSnapshot?
+ public let openRouterUsage: OpenRouterUsageSnapshot?
public let cursorRequests: CursorRequestUsage?
public let updatedAt: Date
public let identity: ProviderIdentitySnapshot?
@@ -63,6 +64,7 @@ public struct UsageSnapshot: Codable, Sendable {
case secondary
case tertiary
case providerCost
+ case openRouterUsage
case updatedAt
case identity
case accountEmail
@@ -77,6 +79,7 @@ public struct UsageSnapshot: Codable, Sendable {
providerCost: ProviderCostSnapshot? = nil,
zaiUsage: ZaiUsageSnapshot? = nil,
minimaxUsage: MiniMaxUsageSnapshot? = nil,
+ openRouterUsage: OpenRouterUsageSnapshot? = nil,
cursorRequests: CursorRequestUsage? = nil,
updatedAt: Date,
identity: ProviderIdentitySnapshot? = nil)
@@ -87,6 +90,7 @@ public struct UsageSnapshot: Codable, Sendable {
self.providerCost = providerCost
self.zaiUsage = zaiUsage
self.minimaxUsage = minimaxUsage
+ self.openRouterUsage = openRouterUsage
self.cursorRequests = cursorRequests
self.updatedAt = updatedAt
self.identity = identity
@@ -100,6 +104,7 @@ public struct UsageSnapshot: Codable, Sendable {
self.providerCost = try container.decodeIfPresent(ProviderCostSnapshot.self, forKey: .providerCost)
self.zaiUsage = nil // Not persisted, fetched fresh each time
self.minimaxUsage = nil // Not persisted, fetched fresh each time
+ self.openRouterUsage = try container.decodeIfPresent(OpenRouterUsageSnapshot.self, forKey: .openRouterUsage)
self.cursorRequests = nil // Not persisted, fetched fresh each time
self.updatedAt = try container.decode(Date.self, forKey: .updatedAt)
if let identity = try container.decodeIfPresent(ProviderIdentitySnapshot.self, forKey: .identity) {
@@ -127,6 +132,7 @@ public struct UsageSnapshot: Codable, Sendable {
try container.encode(self.secondary, forKey: .secondary)
try container.encode(self.tertiary, forKey: .tertiary)
try container.encodeIfPresent(self.providerCost, forKey: .providerCost)
+ try container.encodeIfPresent(self.openRouterUsage, forKey: .openRouterUsage)
try container.encode(self.updatedAt, forKey: .updatedAt)
try container.encodeIfPresent(self.identity, forKey: .identity)
try container.encodeIfPresent(self.identity?.accountEmail, forKey: .accountEmail)
@@ -172,20 +178,26 @@ public struct UsageSnapshot: Codable, Sendable {
self.identity(for: provider)?.loginMethod
}
- public func scoped(to provider: UsageProvider) -> UsageSnapshot {
- guard let identity else { return self }
- let scopedIdentity = identity.scoped(to: provider)
- if scopedIdentity.providerID == identity.providerID { return self }
- return UsageSnapshot(
+ /// Keep this initializer-style copy in sync with UsageSnapshot fields so relabeling/scoping never drops data.
+ public func withIdentity(_ identity: ProviderIdentitySnapshot?) -> UsageSnapshot {
+ UsageSnapshot(
primary: self.primary,
secondary: self.secondary,
tertiary: self.tertiary,
providerCost: self.providerCost,
zaiUsage: self.zaiUsage,
minimaxUsage: self.minimaxUsage,
+ openRouterUsage: self.openRouterUsage,
cursorRequests: self.cursorRequests,
updatedAt: self.updatedAt,
- identity: scopedIdentity)
+ identity: identity)
+ }
+
+ public func scoped(to provider: UsageProvider) -> UsageSnapshot {
+ guard let identity else { return self }
+ let scopedIdentity = identity.scoped(to: provider)
+ if scopedIdentity.providerID == identity.providerID { return self }
+ return self.withIdentity(scopedIdentity)
}
}
diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift
index 37f87cf57..0e6557a66 100644
--- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift
+++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift
@@ -54,6 +54,16 @@ enum CostUsagePricing {
outputCostPerTokenAboveThreshold: nil,
cacheCreationInputCostPerTokenAboveThreshold: nil,
cacheReadInputCostPerTokenAboveThreshold: nil),
+ "claude-haiku-4-5": ClaudePricing(
+ inputCostPerToken: 1e-6,
+ outputCostPerToken: 5e-6,
+ cacheCreationInputCostPerToken: 1.25e-6,
+ cacheReadInputCostPerToken: 1e-7,
+ thresholdTokens: nil,
+ inputCostPerTokenAboveThreshold: nil,
+ outputCostPerTokenAboveThreshold: nil,
+ cacheCreationInputCostPerTokenAboveThreshold: nil,
+ cacheReadInputCostPerTokenAboveThreshold: nil),
"claude-opus-4-5-20251101": ClaudePricing(
inputCostPerToken: 5e-6,
outputCostPerToken: 2.5e-5,
@@ -64,6 +74,36 @@ enum CostUsagePricing {
outputCostPerTokenAboveThreshold: nil,
cacheCreationInputCostPerTokenAboveThreshold: nil,
cacheReadInputCostPerTokenAboveThreshold: nil),
+ "claude-opus-4-5": ClaudePricing(
+ inputCostPerToken: 5e-6,
+ outputCostPerToken: 2.5e-5,
+ cacheCreationInputCostPerToken: 6.25e-6,
+ cacheReadInputCostPerToken: 5e-7,
+ thresholdTokens: nil,
+ inputCostPerTokenAboveThreshold: nil,
+ outputCostPerTokenAboveThreshold: nil,
+ cacheCreationInputCostPerTokenAboveThreshold: nil,
+ cacheReadInputCostPerTokenAboveThreshold: nil),
+ "claude-opus-4-6-20260205": ClaudePricing(
+ inputCostPerToken: 5e-6,
+ outputCostPerToken: 2.5e-5,
+ cacheCreationInputCostPerToken: 6.25e-6,
+ cacheReadInputCostPerToken: 5e-7,
+ thresholdTokens: nil,
+ inputCostPerTokenAboveThreshold: nil,
+ outputCostPerTokenAboveThreshold: nil,
+ cacheCreationInputCostPerTokenAboveThreshold: nil,
+ cacheReadInputCostPerTokenAboveThreshold: nil),
+ "claude-opus-4-6": ClaudePricing(
+ inputCostPerToken: 5e-6,
+ outputCostPerToken: 2.5e-5,
+ cacheCreationInputCostPerToken: 6.25e-6,
+ cacheReadInputCostPerToken: 5e-7,
+ thresholdTokens: nil,
+ inputCostPerTokenAboveThreshold: nil,
+ outputCostPerTokenAboveThreshold: nil,
+ cacheCreationInputCostPerTokenAboveThreshold: nil,
+ cacheReadInputCostPerTokenAboveThreshold: nil),
"claude-sonnet-4-5": ClaudePricing(
inputCostPerToken: 3e-6,
outputCostPerToken: 1.5e-5,
diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift
index d47d7d557..f5cb75f5e 100644
--- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift
+++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift
@@ -57,48 +57,22 @@ enum CostUsageScanner {
options: Options = Options()) -> CostUsageDailyReport
{
let range = CostUsageDayRange(since: since, until: until)
+ let emptyReport = CostUsageDailyReport(data: [], summary: nil)
switch provider {
case .codex:
return self.loadCodexDaily(range: range, now: now, options: options)
case .claude:
return self.loadClaudeDaily(provider: .claude, range: range, now: now, options: options)
- case .zai:
- return CostUsageDailyReport(data: [], summary: nil)
- case .gemini:
- return CostUsageDailyReport(data: [], summary: nil)
- case .antigravity:
- return CostUsageDailyReport(data: [], summary: nil)
- case .cursor:
- return CostUsageDailyReport(data: [], summary: nil)
- case .opencode:
- return CostUsageDailyReport(data: [], summary: nil)
- case .factory:
- return CostUsageDailyReport(data: [], summary: nil)
- case .copilot:
- return CostUsageDailyReport(data: [], summary: nil)
- case .minimax:
- return CostUsageDailyReport(data: [], summary: nil)
case .vertexai:
var filtered = options
if filtered.claudeLogProviderFilter == .all {
filtered.claudeLogProviderFilter = .vertexAIOnly
}
return self.loadClaudeDaily(provider: .vertexai, range: range, now: now, options: filtered)
- case .kiro:
- return CostUsageDailyReport(data: [], summary: nil)
- case .kimi:
- return CostUsageDailyReport(data: [], summary: nil)
- case .kimik2:
- return CostUsageDailyReport(data: [], summary: nil)
- case .augment:
- return CostUsageDailyReport(data: [], summary: nil)
- case .jetbrains:
- return CostUsageDailyReport(data: [], summary: nil)
- case .amp:
- return CostUsageDailyReport(data: [], summary: nil)
- case .synthetic:
- return CostUsageDailyReport(data: [], summary: nil)
+ case .zai, .gemini, .antigravity, .cursor, .opencode, .factory, .copilot, .minimax, .kiro, .kimi, .kimik2,
+ .augment, .jetbrains, .amp, .ollama, .synthetic, .openrouter, .warp:
+ return emptyReport
}
}
diff --git a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift
index 1634611ee..5b88abbdd 100644
--- a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift
+++ b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift
@@ -39,6 +39,7 @@ enum ProviderChoice: String, AppEnum {
}
}
+ // swiftlint:disable:next cyclomatic_complexity
init?(provider: UsageProvider) {
switch provider {
case .codex: self = .codex
@@ -58,7 +59,10 @@ enum ProviderChoice: String, AppEnum {
case .kimi: return nil // Kimi not yet supported in widgets
case .kimik2: return nil // Kimi K2 not yet supported in widgets
case .amp: return nil // Amp not yet supported in widgets
+ case .ollama: return nil // Ollama not yet supported in widgets
case .synthetic: return nil // Synthetic not yet supported in widgets
+ case .openrouter: return nil // OpenRouter not yet supported in widgets
+ case .warp: return nil // Warp not yet supported in widgets
}
}
}
@@ -212,7 +216,8 @@ struct CodexBarSwitcherTimelineProvider: TimelineProvider {
private func availableProviders(from snapshot: WidgetSnapshot) -> [UsageProvider] {
let enabled = snapshot.enabledProviders
let providers = enabled.isEmpty ? snapshot.entries.map(\.provider) : enabled
- return providers.isEmpty ? [.codex] : providers
+ let supported = providers.filter { ProviderChoice(provider: $0) != nil }
+ return supported.isEmpty ? [.codex] : supported
}
}
diff --git a/Sources/CodexBarWidget/CodexBarWidgetViews.swift b/Sources/CodexBarWidget/CodexBarWidgetViews.swift
index ed39b4506..7ad1064e5 100644
--- a/Sources/CodexBarWidget/CodexBarWidgetViews.swift
+++ b/Sources/CodexBarWidget/CodexBarWidgetViews.swift
@@ -274,7 +274,10 @@ private struct ProviderSwitchChip: View {
case .kimi: "Kimi"
case .kimik2: "Kimi K2"
case .amp: "Amp"
+ case .ollama: "Ollama"
case .synthetic: "Synthetic"
+ case .openrouter: "OpenRouter"
+ case .warp: "Warp"
}
}
}
@@ -567,6 +570,7 @@ private struct UsageHistoryChart: View {
}
enum WidgetColors {
+ // swiftlint:disable:next cyclomatic_complexity
static func color(for provider: UsageProvider) -> Color {
switch provider {
case .codex:
@@ -603,8 +607,14 @@ enum WidgetColors {
Color(red: 76 / 255, green: 0 / 255, blue: 255 / 255) // Kimi K2 purple
case .amp:
Color(red: 220 / 255, green: 38 / 255, blue: 38 / 255) // Amp red
+ case .ollama:
+ Color(red: 32 / 255, green: 32 / 255, blue: 32 / 255) // Ollama charcoal
case .synthetic:
Color(red: 20 / 255, green: 20 / 255, blue: 20 / 255) // Synthetic charcoal
+ case .openrouter:
+ Color(red: 111 / 255, green: 66 / 255, blue: 193 / 255) // OpenRouter purple
+ case .warp:
+ Color(red: 147 / 255, green: 139 / 255, blue: 180 / 255)
}
}
}
diff --git a/Tests/CodexBarTests/AmpUsageFetcherTests.swift b/Tests/CodexBarTests/AmpUsageFetcherTests.swift
index afbf81c9c..8a4e8506c 100644
--- a/Tests/CodexBarTests/AmpUsageFetcherTests.swift
+++ b/Tests/CodexBarTests/AmpUsageFetcherTests.swift
@@ -17,4 +17,31 @@ struct AmpUsageFetcherTests {
#expect(!AmpUsageFetcher.shouldAttachCookie(to: URL(string: "https://ampcode.com.evil.com")))
#expect(!AmpUsageFetcher.shouldAttachCookie(to: nil))
}
+
+ @Test
+ func detectsLoginRedirects() throws {
+ let signIn = try #require(URL(string: "https://ampcode.com/auth/sign-in?returnTo=%2Fsettings"))
+ #expect(AmpUsageFetcher.isLoginRedirect(signIn))
+
+ let sso = try #require(URL(string: "https://ampcode.com/auth/sso?returnTo=%2Fsettings"))
+ #expect(AmpUsageFetcher.isLoginRedirect(sso))
+
+ let login = try #require(URL(string: "https://ampcode.com/login"))
+ #expect(AmpUsageFetcher.isLoginRedirect(login))
+
+ let signin = try #require(URL(string: "https://www.ampcode.com/signin"))
+ #expect(AmpUsageFetcher.isLoginRedirect(signin))
+ }
+
+ @Test
+ func ignoresNonLoginURLs() throws {
+ let settings = try #require(URL(string: "https://ampcode.com/settings"))
+ #expect(!AmpUsageFetcher.isLoginRedirect(settings))
+
+ let signOut = try #require(URL(string: "https://ampcode.com/auth/sign-out"))
+ #expect(!AmpUsageFetcher.isLoginRedirect(signOut))
+
+ let evil = try #require(URL(string: "https://ampcode.com.evil.com/auth/sign-in"))
+ #expect(!AmpUsageFetcher.isLoginRedirect(evil))
+ }
}
diff --git a/Tests/CodexBarTests/CLIProviderSelectionTests.swift b/Tests/CodexBarTests/CLIProviderSelectionTests.swift
index ec8551802..fe74c2860 100644
--- a/Tests/CodexBarTests/CLIProviderSelectionTests.swift
+++ b/Tests/CodexBarTests/CLIProviderSelectionTests.swift
@@ -20,6 +20,8 @@ struct CLIProviderSelectionTests {
"|copilot|",
"|synthetic|",
"|kiro|",
+ "|warp|",
+ "|ollama|",
"|both|",
"|all]",
]
diff --git a/Tests/CodexBarTests/CLISnapshotTests.swift b/Tests/CodexBarTests/CLISnapshotTests.swift
index aaf237092..18cde0c98 100644
--- a/Tests/CodexBarTests/CLISnapshotTests.swift
+++ b/Tests/CodexBarTests/CLISnapshotTests.swift
@@ -65,6 +65,70 @@ struct CLISnapshotTests {
#expect(!output.contains("Weekly:"))
}
+ @Test
+ func rendersWarpUnlimitedAsDetailNotReset() {
+ let meta = ProviderDescriptorRegistry.descriptor(for: .warp).metadata
+ let snap = UsageSnapshot(
+ primary: .init(usedPercent: 0, windowMinutes: nil, resetsAt: nil, resetDescription: "Unlimited"),
+ secondary: nil,
+ tertiary: nil,
+ updatedAt: Date(timeIntervalSince1970: 0),
+ identity: ProviderIdentitySnapshot(
+ providerID: .warp,
+ accountEmail: nil,
+ accountOrganization: nil,
+ loginMethod: nil))
+
+ let output = CLIRenderer.renderText(
+ provider: .warp,
+ snapshot: snap,
+ credits: nil,
+ context: RenderContext(
+ header: "Warp 0.0.0 (warp)",
+ status: nil,
+ useColor: false,
+ resetStyle: .absolute))
+
+ #expect(output.contains("\(meta.sessionLabel): 100% left"))
+ #expect(!output.contains("Resets Unlimited"))
+ #expect(output.contains("Unlimited"))
+ }
+
+ @Test
+ func rendersWarpCreditsAsDetailAndResetAsDate() {
+ let meta = ProviderDescriptorRegistry.descriptor(for: .warp).metadata
+ let now = Date(timeIntervalSince1970: 0)
+ let snap = UsageSnapshot(
+ primary: .init(
+ usedPercent: 10,
+ windowMinutes: nil,
+ resetsAt: now.addingTimeInterval(3600),
+ resetDescription: "10/100 credits"),
+ secondary: nil,
+ tertiary: nil,
+ updatedAt: now,
+ identity: ProviderIdentitySnapshot(
+ providerID: .warp,
+ accountEmail: nil,
+ accountOrganization: nil,
+ loginMethod: nil))
+
+ let output = CLIRenderer.renderText(
+ provider: .warp,
+ snapshot: snap,
+ credits: nil,
+ context: RenderContext(
+ header: "Warp 0.0.0 (warp)",
+ status: nil,
+ useColor: false,
+ resetStyle: .absolute))
+
+ #expect(output.contains("\(meta.sessionLabel): 90% left"))
+ #expect(output.contains("Resets"))
+ #expect(output.contains("10/100 credits"))
+ #expect(!output.contains("Resets 10/100 credits"))
+ }
+
@Test
func rendersPaceLineWhenWeeklyHasReset() {
let now = Date()
diff --git a/Tests/CodexBarTests/CLIWebFallbackTests.swift b/Tests/CodexBarTests/CLIWebFallbackTests.swift
index b3efe7cba..8ebc1cbb1 100644
--- a/Tests/CodexBarTests/CLIWebFallbackTests.swift
+++ b/Tests/CodexBarTests/CLIWebFallbackTests.swift
@@ -4,22 +4,35 @@ import Testing
@Suite
struct CLIWebFallbackTests {
- private func makeContext(sourceMode: ProviderSourceMode = .auto) -> ProviderFetchContext {
+ private func makeContext(
+ runtime: ProviderRuntime = .cli,
+ sourceMode: ProviderSourceMode = .auto,
+ settings: ProviderSettingsSnapshot? = nil) -> ProviderFetchContext
+ {
let browserDetection = BrowserDetection(cacheTTL: 0)
return ProviderFetchContext(
- runtime: .cli,
+ runtime: runtime,
sourceMode: sourceMode,
includeCredits: true,
webTimeout: 60,
webDebugDumpHTML: false,
verbose: false,
env: [:],
- settings: nil,
+ settings: settings,
fetcher: UsageFetcher(),
claudeFetcher: ClaudeUsageFetcher(browserDetection: browserDetection),
browserDetection: browserDetection)
}
+ private func makeClaudeSettingsSnapshot(cookieHeader: String?) -> ProviderSettingsSnapshot {
+ ProviderSettingsSnapshot.make(
+ claude: .init(
+ usageDataSource: .auto,
+ webExtrasEnabled: false,
+ cookieSource: .manual,
+ manualCookieHeader: cookieHeader))
+ }
+
@Test
func codexFallsBackWhenCookiesMissing() {
let context = self.makeContext()
@@ -49,4 +62,42 @@ struct CLIWebFallbackTests {
on: OpenAIDashboardFetcher.FetchError.noDashboardData(body: "missing"),
context: context))
}
+
+ @Test
+ func claudeFallsBackWhenNoSessionKey() {
+ let context = self.makeContext()
+ let strategy = ClaudeWebFetchStrategy(browserDetection: BrowserDetection(cacheTTL: 0))
+ #expect(strategy.shouldFallback(on: ClaudeWebAPIFetcher.FetchError.noSessionKeyFound, context: context))
+ #expect(strategy.shouldFallback(on: ClaudeWebAPIFetcher.FetchError.unauthorized, context: context))
+ }
+
+ @Test
+ func claudeCLIFallbackIsEnabledOnlyForAppAuto() {
+ let strategy = ClaudeCLIFetchStrategy(
+ useWebExtras: false,
+ manualCookieHeader: nil,
+ browserDetection: BrowserDetection(cacheTTL: 0))
+ let error = ClaudeUsageError.parseFailed("cli failed")
+ let webAvailableSettings = self.makeClaudeSettingsSnapshot(cookieHeader: "sessionKey=sk-ant-test")
+ let webUnavailableSettings = self.makeClaudeSettingsSnapshot(cookieHeader: "foo=bar")
+
+ #expect(strategy.shouldFallback(
+ on: error,
+ context: self.makeContext(runtime: .app, sourceMode: .auto, settings: webAvailableSettings)))
+ #expect(!strategy.shouldFallback(
+ on: error,
+ context: self.makeContext(runtime: .app, sourceMode: .auto, settings: webUnavailableSettings)))
+ #expect(!strategy.shouldFallback(on: error, context: self.makeContext(runtime: .app, sourceMode: .cli)))
+ #expect(!strategy.shouldFallback(on: error, context: self.makeContext(runtime: .app, sourceMode: .web)))
+ #expect(!strategy.shouldFallback(on: error, context: self.makeContext(runtime: .app, sourceMode: .oauth)))
+ #expect(!strategy.shouldFallback(on: error, context: self.makeContext(runtime: .cli, sourceMode: .auto)))
+ }
+
+ @Test
+ func claudeWebFallbackIsDisabledForAppAuto() {
+ let strategy = ClaudeWebFetchStrategy(browserDetection: BrowserDetection(cacheTTL: 0))
+ let error = ClaudeWebAPIFetcher.FetchError.unauthorized
+ #expect(strategy.shouldFallback(on: error, context: self.makeContext(runtime: .cli, sourceMode: .auto)))
+ #expect(!strategy.shouldFallback(on: error, context: self.makeContext(runtime: .app, sourceMode: .auto)))
+ }
}
diff --git a/Tests/CodexBarTests/ClaudeOAuthCredentialsStorePromptPolicyTests.swift b/Tests/CodexBarTests/ClaudeOAuthCredentialsStorePromptPolicyTests.swift
new file mode 100644
index 000000000..41ea049f7
--- /dev/null
+++ b/Tests/CodexBarTests/ClaudeOAuthCredentialsStorePromptPolicyTests.swift
@@ -0,0 +1,732 @@
+import Foundation
+import Testing
+@testable import CodexBarCore
+
+@Suite(.serialized)
+struct ClaudeOAuthCredentialsStorePromptPolicyTests {
+ private func makeCredentialsData(accessToken: String, expiresAt: Date, refreshToken: String? = nil) -> Data {
+ let millis = Int(expiresAt.timeIntervalSince1970 * 1000)
+ let refreshField: String = {
+ guard let refreshToken else { return "" }
+ return ",\n \"refreshToken\": \"\(refreshToken)\""
+ }()
+ let json = """
+ {
+ "claudeAiOauth": {
+ "accessToken": "\(accessToken)",
+ "expiresAt": \(millis),
+ "scopes": ["user:profile"]\(refreshField)
+ }
+ }
+ """
+ return Data(json.utf8)
+ }
+
+ @Test
+ func doesNotReadClaudeKeychainInBackgroundWhenPromptModeOnlyOnUserAction() throws {
+ let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
+ try KeychainCacheStore.withServiceOverrideForTesting(service) {
+ try KeychainAccessGate.withTaskOverrideForTesting(false) {
+ KeychainCacheStore.setTestStoreForTesting(true)
+ defer { KeychainCacheStore.setTestStoreForTesting(false) }
+
+ ClaudeOAuthCredentialsStore.invalidateCache()
+ ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
+ defer {
+ ClaudeOAuthCredentialsStore.invalidateCache()
+ ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
+ ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeTrackingForTesting()
+ }
+
+ let tempDir = FileManager.default.temporaryDirectory
+ .appendingPathComponent(UUID().uuidString, isDirectory: true)
+ try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
+ let fileURL = tempDir.appendingPathComponent("credentials.json")
+
+ try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
+ ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeTrackingForTesting()
+
+ let fingerprint = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint(
+ modifiedAt: 1,
+ createdAt: 1,
+ persistentRefHash: "ref1")
+ let keychainData = self.makeCredentialsData(
+ accessToken: "keychain-token",
+ expiresAt: Date(timeIntervalSinceNow: 3600))
+
+ do {
+ _ = try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) {
+ try ProviderInteractionContext.$current.withValue(.background) {
+ try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting(
+ data: keychainData,
+ fingerprint: fingerprint)
+ {
+ try ClaudeOAuthCredentialsStore.load(environment: [:], allowKeychainPrompt: false)
+ }
+ }
+ }
+ Issue.record("Expected ClaudeOAuthCredentialsError.notFound")
+ } catch let error as ClaudeOAuthCredentialsError {
+ guard case .notFound = error else {
+ Issue.record("Expected .notFound, got \(error)")
+ return
+ }
+ }
+ }
+ }
+ }
+ }
+
+ @Test
+ func canReadClaudeKeychainOnUserActionWhenPromptModeOnlyOnUserAction() throws {
+ let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
+ try KeychainCacheStore.withServiceOverrideForTesting(service) {
+ try KeychainAccessGate.withTaskOverrideForTesting(false) {
+ KeychainCacheStore.setTestStoreForTesting(true)
+ defer { KeychainCacheStore.setTestStoreForTesting(false) }
+
+ ClaudeOAuthCredentialsStore.invalidateCache()
+ ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
+ defer {
+ ClaudeOAuthCredentialsStore.invalidateCache()
+ ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
+ ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeTrackingForTesting()
+ }
+
+ let tempDir = FileManager.default.temporaryDirectory
+ .appendingPathComponent(UUID().uuidString, isDirectory: true)
+ try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
+ let fileURL = tempDir.appendingPathComponent("credentials.json")
+
+ try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
+ ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeTrackingForTesting()
+
+ let fingerprint = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint(
+ modifiedAt: 1,
+ createdAt: 1,
+ persistentRefHash: "ref1")
+ let keychainData = self.makeCredentialsData(
+ accessToken: "keychain-token",
+ expiresAt: Date(timeIntervalSinceNow: 3600))
+
+ let creds = try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) {
+ try ProviderInteractionContext.$current.withValue(.userInitiated) {
+ try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting(
+ data: keychainData,
+ fingerprint: fingerprint)
+ {
+ try ClaudeOAuthCredentialsStore.load(environment: [:], allowKeychainPrompt: false)
+ }
+ }
+ }
+
+ #expect(creds.accessToken == "keychain-token")
+ }
+ }
+ }
+ }
+
+ @Test
+ func doesNotShowPreAlertWhenClaudeKeychainReadableWithoutInteraction() throws {
+ let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
+ try KeychainCacheStore.withServiceOverrideForTesting(service) {
+ try KeychainAccessGate.withTaskOverrideForTesting(false) {
+ KeychainCacheStore.setTestStoreForTesting(true)
+ defer { KeychainCacheStore.setTestStoreForTesting(false) }
+
+ try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {
+ ClaudeOAuthCredentialsStore.invalidateCache()
+ ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
+ defer {
+ ClaudeOAuthCredentialsStore.invalidateCache()
+ ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
+ }
+
+ let tempDir = FileManager.default.temporaryDirectory
+ .appendingPathComponent(UUID().uuidString, isDirectory: true)
+ try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
+ let fileURL = tempDir.appendingPathComponent("credentials.json")
+ try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
+ let keychainData = self.makeCredentialsData(
+ accessToken: "keychain-token",
+ expiresAt: Date(timeIntervalSinceNow: 3600))
+
+ var preAlertHits = 0
+ let preflightOverride: (String, String?) -> KeychainAccessPreflight.Outcome = { _, _ in
+ .allowed
+ }
+ let promptHandler: (KeychainPromptContext) -> Void = { _ in
+ preAlertHits += 1
+ }
+ let creds = try KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting(
+ preflightOverride,
+ operation: {
+ try KeychainPromptHandler.withHandlerForTesting(promptHandler, operation: {
+ try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(
+ .onlyOnUserAction)
+ {
+ try ProviderInteractionContext.$current.withValue(.userInitiated) {
+ try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting(
+ data: keychainData,
+ fingerprint: nil)
+ {
+ try ClaudeOAuthCredentialsStore.load(
+ environment: [:],
+ allowKeychainPrompt: true)
+ }
+ }
+ }
+ })
+ })
+
+ #expect(creds.accessToken == "keychain-token")
+ #expect(preAlertHits == 0)
+ }
+ }
+ }
+ }
+ }
+
+ @Test
+ func showsPreAlertWhenClaudeKeychainLikelyRequiresInteraction() throws {
+ let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
+ try KeychainCacheStore.withServiceOverrideForTesting(service) {
+ try KeychainAccessGate.withTaskOverrideForTesting(false) {
+ KeychainCacheStore.setTestStoreForTesting(true)
+ defer { KeychainCacheStore.setTestStoreForTesting(false) }
+
+ try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {
+ ClaudeOAuthCredentialsStore.invalidateCache()
+ ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
+ defer {
+ ClaudeOAuthCredentialsStore.invalidateCache()
+ ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
+ }
+
+ let tempDir = FileManager.default.temporaryDirectory
+ .appendingPathComponent(UUID().uuidString, isDirectory: true)
+ try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
+ let fileURL = tempDir.appendingPathComponent("credentials.json")
+ try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
+ let keychainData = self.makeCredentialsData(
+ accessToken: "keychain-token",
+ expiresAt: Date(timeIntervalSinceNow: 3600))
+
+ var preAlertHits = 0
+ let preflightOverride: (String, String?) -> KeychainAccessPreflight.Outcome = { _, _ in
+ .interactionRequired
+ }
+ let promptHandler: (KeychainPromptContext) -> Void = { _ in
+ preAlertHits += 1
+ }
+ let creds = try KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting(
+ preflightOverride,
+ operation: {
+ try KeychainPromptHandler.withHandlerForTesting(promptHandler, operation: {
+ try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(
+ .onlyOnUserAction)
+ {
+ try ProviderInteractionContext.$current.withValue(.userInitiated) {
+ try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting(
+ data: keychainData,
+ fingerprint: nil)
+ {
+ try ClaudeOAuthCredentialsStore.load(
+ environment: [:],
+ allowKeychainPrompt: true)
+ }
+ }
+ }
+ })
+ })
+
+ #expect(creds.accessToken == "keychain-token")
+ // TODO: tighten this to `== 1` once keychain pre-alert delivery is deduplicated/scoped.
+ // This path can currently emit more than one pre-alert during a single load attempt.
+ #expect(preAlertHits >= 1)
+ }
+ }
+ }
+ }
+ }
+
+ @Test
+ func showsPreAlertWhenClaudeKeychainPreflightFails() throws {
+ let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
+ try KeychainCacheStore.withServiceOverrideForTesting(service) {
+ try KeychainAccessGate.withTaskOverrideForTesting(false) {
+ KeychainCacheStore.setTestStoreForTesting(true)
+ defer { KeychainCacheStore.setTestStoreForTesting(false) }
+
+ try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {
+ ClaudeOAuthCredentialsStore.invalidateCache()
+ ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
+ defer {
+ ClaudeOAuthCredentialsStore.invalidateCache()
+ ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
+ }
+
+ let tempDir = FileManager.default.temporaryDirectory
+ .appendingPathComponent(UUID().uuidString, isDirectory: true)
+ try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
+ let fileURL = tempDir.appendingPathComponent("credentials.json")
+ try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
+ let keychainData = self.makeCredentialsData(
+ accessToken: "keychain-token",
+ expiresAt: Date(timeIntervalSinceNow: 3600))
+
+ var preAlertHits = 0
+ let preflightOverride: (String, String?) -> KeychainAccessPreflight.Outcome = { _, _ in
+ .failure(-1)
+ }
+ let promptHandler: (KeychainPromptContext) -> Void = { _ in
+ preAlertHits += 1
+ }
+ let creds = try KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting(
+ preflightOverride,
+ operation: {
+ try KeychainPromptHandler.withHandlerForTesting(promptHandler, operation: {
+ try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(
+ .onlyOnUserAction)
+ {
+ try ProviderInteractionContext.$current.withValue(.userInitiated) {
+ try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting(
+ data: keychainData,
+ fingerprint: nil)
+ {
+ try ClaudeOAuthCredentialsStore.load(
+ environment: [:],
+ allowKeychainPrompt: true)
+ }
+ }
+ }
+ })
+ })
+
+ #expect(creds.accessToken == "keychain-token")
+ // TODO: tighten this to `== 1` once keychain pre-alert delivery is deduplicated/scoped.
+ // This path can currently emit more than one pre-alert during a single load attempt.
+ #expect(preAlertHits >= 1)
+ }
+ }
+ }
+ }
+ }
+
+ @Test
+ func experimentalReader_skipsPreAlertWhenSecurityCLIReadSucceeds() throws {
+ let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
+ try KeychainCacheStore.withServiceOverrideForTesting(service) {
+ try KeychainAccessGate.withTaskOverrideForTesting(false) {
+ KeychainCacheStore.setTestStoreForTesting(true)
+ defer { KeychainCacheStore.setTestStoreForTesting(false) }
+
+ try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {
+ ClaudeOAuthCredentialsStore.invalidateCache()
+ ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
+ defer {
+ ClaudeOAuthCredentialsStore.invalidateCache()
+ ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
+ }
+
+ let tempDir = FileManager.default.temporaryDirectory
+ .appendingPathComponent(UUID().uuidString, isDirectory: true)
+ try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
+ let fileURL = tempDir.appendingPathComponent("credentials.json")
+ try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
+ let securityData = self.makeCredentialsData(
+ accessToken: "security-token",
+ expiresAt: Date(timeIntervalSinceNow: 3600))
+
+ var preAlertHits = 0
+ let preflightOverride: (String, String?) -> KeychainAccessPreflight.Outcome = { _, _ in
+ .interactionRequired
+ }
+ let promptHandler: (KeychainPromptContext) -> Void = { _ in
+ preAlertHits += 1
+ }
+ let creds = try KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting(
+ preflightOverride,
+ operation: {
+ try KeychainPromptHandler.withHandlerForTesting(promptHandler, operation: {
+ try ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
+ .securityCLIExperimental)
+ {
+ try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) {
+ try ProviderInteractionContext.$current.withValue(.userInitiated) {
+ try ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(
+ .data(securityData))
+ {
+ try ClaudeOAuthCredentialsStore.load(
+ environment: [:],
+ allowKeychainPrompt: true)
+ }
+ }
+ }
+ }
+ })
+ })
+
+ #expect(creds.accessToken == "security-token")
+ #expect(preAlertHits == 0)
+ }
+ }
+ }
+ }
+ }
+
+ @Test
+ func experimentalReader_showsPreAlertWhenSecurityCLIFailsAndFallbackNeedsInteraction() throws {
+ let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
+ try KeychainCacheStore.withServiceOverrideForTesting(service) {
+ try KeychainAccessGate.withTaskOverrideForTesting(false) {
+ KeychainCacheStore.setTestStoreForTesting(true)
+ defer { KeychainCacheStore.setTestStoreForTesting(false) }
+
+ try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {
+ ClaudeOAuthCredentialsStore.invalidateCache()
+ ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
+ defer {
+ ClaudeOAuthCredentialsStore.invalidateCache()
+ ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
+ }
+
+ let tempDir = FileManager.default.temporaryDirectory
+ .appendingPathComponent(UUID().uuidString, isDirectory: true)
+ try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
+ let fileURL = tempDir.appendingPathComponent("credentials.json")
+ try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
+ let fallbackData = self.makeCredentialsData(
+ accessToken: "fallback-token",
+ expiresAt: Date(timeIntervalSinceNow: 3600))
+
+ var preAlertHits = 0
+ let preflightOverride: (String, String?) -> KeychainAccessPreflight.Outcome = { _, _ in
+ .interactionRequired
+ }
+ let promptHandler: (KeychainPromptContext) -> Void = { _ in
+ preAlertHits += 1
+ }
+ let creds = try KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting(
+ preflightOverride,
+ operation: {
+ try KeychainPromptHandler.withHandlerForTesting(promptHandler, operation: {
+ try ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
+ .securityCLIExperimental)
+ {
+ try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) {
+ try ProviderInteractionContext.$current.withValue(.userInitiated) {
+ try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting(
+ data: fallbackData,
+ fingerprint: nil)
+ {
+ try ClaudeOAuthCredentialsStore
+ .withSecurityCLIReadOverrideForTesting(.timedOut) {
+ try ClaudeOAuthCredentialsStore.load(
+ environment: [:],
+ allowKeychainPrompt: true)
+ }
+ }
+ }
+ }
+ }
+ })
+ })
+
+ #expect(creds.accessToken == "fallback-token")
+ #expect(preAlertHits >= 1)
+ }
+ }
+ }
+ }
+ }
+
+ @Test
+ func experimentalReader_doesNotFallbackInBackgroundWhenStoredModeOnlyOnUserAction() throws {
+ let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
+ try KeychainCacheStore.withServiceOverrideForTesting(service) {
+ try KeychainAccessGate.withTaskOverrideForTesting(false) {
+ KeychainCacheStore.setTestStoreForTesting(true)
+ defer { KeychainCacheStore.setTestStoreForTesting(false) }
+
+ try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {
+ ClaudeOAuthCredentialsStore.invalidateCache()
+ ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
+ defer {
+ ClaudeOAuthCredentialsStore.invalidateCache()
+ ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
+ }
+
+ let tempDir = FileManager.default.temporaryDirectory
+ .appendingPathComponent(UUID().uuidString, isDirectory: true)
+ try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
+ let fileURL = tempDir.appendingPathComponent("credentials.json")
+ try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
+ let fallbackData = self.makeCredentialsData(
+ accessToken: "fallback-token",
+ expiresAt: Date(timeIntervalSinceNow: 3600))
+
+ var preAlertHits = 0
+ let preflightOverride: (String, String?) -> KeychainAccessPreflight.Outcome = { _, _ in
+ .interactionRequired
+ }
+ let promptHandler: (KeychainPromptContext) -> Void = { _ in
+ preAlertHits += 1
+ }
+
+ do {
+ _ = try KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting(
+ preflightOverride,
+ operation: {
+ try KeychainPromptHandler.withHandlerForTesting(promptHandler, operation: {
+ try ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
+ .securityCLIExperimental)
+ {
+ try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(
+ .onlyOnUserAction)
+ {
+ try ProviderInteractionContext.$current.withValue(.background) {
+ try ClaudeOAuthCredentialsStore
+ .withClaudeKeychainOverridesForTesting(
+ data: fallbackData,
+ fingerprint: nil)
+ {
+ try ClaudeOAuthCredentialsStore
+ .withSecurityCLIReadOverrideForTesting(.timedOut) {
+ try ClaudeOAuthCredentialsStore.load(
+ environment: [:],
+ allowKeychainPrompt: true,
+ respectKeychainPromptCooldown: true)
+ }
+ }
+ }
+ }
+ }
+ })
+ })
+ Issue.record("Expected ClaudeOAuthCredentialsError.notFound")
+ } catch let error as ClaudeOAuthCredentialsError {
+ guard case .notFound = error else {
+ Issue.record("Expected .notFound, got \(error)")
+ return
+ }
+ }
+
+ #expect(preAlertHits == 0)
+ }
+ }
+ }
+ }
+ }
+
+ @Test
+ func experimentalReader_doesNotFallbackWhenStoredModeNever() throws {
+ let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
+ try KeychainCacheStore.withServiceOverrideForTesting(service) {
+ try KeychainAccessGate.withTaskOverrideForTesting(false) {
+ KeychainCacheStore.setTestStoreForTesting(true)
+ defer { KeychainCacheStore.setTestStoreForTesting(false) }
+
+ try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {
+ ClaudeOAuthCredentialsStore.invalidateCache()
+ ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
+ defer {
+ ClaudeOAuthCredentialsStore.invalidateCache()
+ ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
+ }
+
+ let tempDir = FileManager.default.temporaryDirectory
+ .appendingPathComponent(UUID().uuidString, isDirectory: true)
+ try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
+ let fileURL = tempDir.appendingPathComponent("credentials.json")
+ try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
+ let fallbackData = self.makeCredentialsData(
+ accessToken: "fallback-token",
+ expiresAt: Date(timeIntervalSinceNow: 3600))
+
+ var preAlertHits = 0
+ let preflightOverride: (String, String?) -> KeychainAccessPreflight.Outcome = { _, _ in
+ .interactionRequired
+ }
+ let promptHandler: (KeychainPromptContext) -> Void = { _ in
+ preAlertHits += 1
+ }
+
+ do {
+ _ = try KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting(
+ preflightOverride,
+ operation: {
+ try KeychainPromptHandler.withHandlerForTesting(promptHandler, operation: {
+ try ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
+ .securityCLIExperimental)
+ {
+ try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.never) {
+ try ProviderInteractionContext.$current.withValue(.userInitiated) {
+ try ClaudeOAuthCredentialsStore
+ .withClaudeKeychainOverridesForTesting(
+ data: fallbackData,
+ fingerprint: nil)
+ {
+ try ClaudeOAuthCredentialsStore
+ .withSecurityCLIReadOverrideForTesting(.timedOut) {
+ try ClaudeOAuthCredentialsStore.load(
+ environment: [:],
+ allowKeychainPrompt: true)
+ }
+ }
+ }
+ }
+ }
+ })
+ })
+ Issue.record("Expected ClaudeOAuthCredentialsError.notFound")
+ } catch let error as ClaudeOAuthCredentialsError {
+ guard case .notFound = error else {
+ Issue.record("Expected .notFound, got \(error)")
+ return
+ }
+ }
+
+ #expect(preAlertHits == 0)
+ }
+ }
+ }
+ }
+ }
+
+ @Test
+ func experimentalReader_nonInteractiveFallbackBlockedInBackgroundWhenStoredModeOnlyOnUserAction() throws {
+ let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
+ try KeychainCacheStore.withServiceOverrideForTesting(service) {
+ try KeychainAccessGate.withTaskOverrideForTesting(false) {
+ KeychainCacheStore.setTestStoreForTesting(true)
+ defer { KeychainCacheStore.setTestStoreForTesting(false) }
+
+ try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {
+ ClaudeOAuthCredentialsStore.invalidateCache()
+ ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
+ defer {
+ ClaudeOAuthCredentialsStore.invalidateCache()
+ ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
+ }
+
+ let tempDir = FileManager.default.temporaryDirectory
+ .appendingPathComponent(UUID().uuidString, isDirectory: true)
+ try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
+ let fileURL = tempDir.appendingPathComponent("credentials.json")
+ try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
+ let fallbackData = self.makeCredentialsData(
+ accessToken: "fallback-token-only-on-user-action",
+ expiresAt: Date(timeIntervalSinceNow: 3600))
+ let preflightOverride: (String, String?) -> KeychainAccessPreflight.Outcome = { _, _ in
+ .allowed
+ }
+
+ do {
+ _ = try KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting(
+ preflightOverride,
+ operation: {
+ try ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
+ .securityCLIExperimental)
+ {
+ try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(
+ .onlyOnUserAction)
+ {
+ try ProviderInteractionContext.$current.withValue(.background) {
+ try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting(
+ data: fallbackData,
+ fingerprint: nil)
+ {
+ try ClaudeOAuthCredentialsStore
+ .withSecurityCLIReadOverrideForTesting(.timedOut) {
+ try ClaudeOAuthCredentialsStore.load(
+ environment: [:],
+ allowKeychainPrompt: false,
+ respectKeychainPromptCooldown: true)
+ }
+ }
+ }
+ }
+ }
+ })
+ Issue.record("Expected ClaudeOAuthCredentialsError.notFound")
+ } catch let error as ClaudeOAuthCredentialsError {
+ guard case .notFound = error else {
+ Issue.record("Expected .notFound, got \(error)")
+ return
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ @Test
+ func experimentalReader_allowsFallbackInBackgroundWhenStoredModeAlways() throws {
+ let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
+ try KeychainCacheStore.withServiceOverrideForTesting(service) {
+ try KeychainAccessGate.withTaskOverrideForTesting(false) {
+ KeychainCacheStore.setTestStoreForTesting(true)
+ defer { KeychainCacheStore.setTestStoreForTesting(false) }
+
+ try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {
+ ClaudeOAuthCredentialsStore.invalidateCache()
+ ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
+ defer {
+ ClaudeOAuthCredentialsStore.invalidateCache()
+ ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
+ }
+
+ let tempDir = FileManager.default.temporaryDirectory
+ .appendingPathComponent(UUID().uuidString, isDirectory: true)
+ try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
+ let fileURL = tempDir.appendingPathComponent("credentials.json")
+ try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
+ let fallbackData = self.makeCredentialsData(
+ accessToken: "fallback-token",
+ expiresAt: Date(timeIntervalSinceNow: 3600))
+
+ var preAlertHits = 0
+ let preflightOverride: (String, String?) -> KeychainAccessPreflight.Outcome = { _, _ in
+ .interactionRequired
+ }
+ let promptHandler: (KeychainPromptContext) -> Void = { _ in
+ preAlertHits += 1
+ }
+
+ let creds = try KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting(
+ preflightOverride,
+ operation: {
+ try KeychainPromptHandler.withHandlerForTesting(promptHandler, operation: {
+ try ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
+ .securityCLIExperimental)
+ {
+ try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) {
+ try ProviderInteractionContext.$current.withValue(.background) {
+ try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting(
+ data: fallbackData,
+ fingerprint: nil)
+ {
+ try ClaudeOAuthCredentialsStore
+ .withSecurityCLIReadOverrideForTesting(.timedOut) {
+ try ClaudeOAuthCredentialsStore.load(
+ environment: [:],
+ allowKeychainPrompt: true,
+ respectKeychainPromptCooldown: false)
+ }
+ }
+ }
+ }
+ }
+ })
+ })
+
+ #expect(creds.accessToken == "fallback-token")
+ #expect(preAlertHits >= 1)
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreSecurityCLIFallbackPolicyTests.swift b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreSecurityCLIFallbackPolicyTests.swift
new file mode 100644
index 000000000..4db21b4b4
--- /dev/null
+++ b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreSecurityCLIFallbackPolicyTests.swift
@@ -0,0 +1,109 @@
+import Foundation
+import Testing
+@testable import CodexBarCore
+
+@Suite(.serialized)
+struct ClaudeOAuthCredentialsStoreSecurityCLIFallbackPolicyTests {
+ private func makeCredentialsData(accessToken: String, expiresAt: Date) -> Data {
+ let millis = Int(expiresAt.timeIntervalSince1970 * 1000)
+ let json = """
+ {
+ "claudeAiOauth": {
+ "accessToken": "\(accessToken)",
+ "expiresAt": \(millis),
+ "scopes": ["user:profile"]
+ }
+ }
+ """
+ return Data(json.utf8)
+ }
+
+ @Test
+ func experimentalReader_hasClaudeKeychainCredentialsWithoutPrompt_backgroundFallbackBlockedByStoredPolicy() {
+ let fallbackData = self.makeCredentialsData(
+ accessToken: "fallback-should-be-blocked",
+ expiresAt: Date(timeIntervalSinceNow: 3600))
+
+ let hasCredentials = KeychainAccessGate.withTaskOverrideForTesting(false) {
+ ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
+ .securityCLIExperimental,
+ operation: {
+ ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(
+ .onlyOnUserAction,
+ operation: {
+ ProviderInteractionContext.$current.withValue(.background) {
+ ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting(
+ data: fallbackData,
+ fingerprint: nil)
+ {
+ ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(
+ .nonZeroExit)
+ {
+ ClaudeOAuthCredentialsStore.hasClaudeKeychainCredentialsWithoutPrompt()
+ }
+ }
+ }
+ })
+ })
+ }
+
+ #expect(hasCredentials == false)
+ }
+
+ @Test
+ func experimentalReader_syncFromClaudeKeychainWithoutPrompt_backgroundFallbackBlockedByStoredPolicy() {
+ let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
+ KeychainCacheStore.withServiceOverrideForTesting(service) {
+ KeychainAccessGate.withTaskOverrideForTesting(false) {
+ KeychainCacheStore.setTestStoreForTesting(true)
+ defer { KeychainCacheStore.setTestStoreForTesting(false) }
+
+ ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {
+ ClaudeOAuthCredentialsStore.invalidateCache()
+ defer { ClaudeOAuthCredentialsStore.invalidateCache() }
+
+ let fallbackData = self.makeCredentialsData(
+ accessToken: "sync-fallback-should-be-blocked",
+ expiresAt: Date(timeIntervalSinceNow: 3600))
+ final class Counter: @unchecked Sendable {
+ var value = 0
+ }
+ let preflightCalls = Counter()
+ let preflightOverride: (String, String?) -> KeychainAccessPreflight.Outcome = { _, _ in
+ preflightCalls.value += 1
+ return .allowed
+ }
+
+ let synced = KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting(
+ preflightOverride,
+ operation: {
+ ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
+ .securityCLIExperimental,
+ operation: {
+ ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(
+ .onlyOnUserAction,
+ operation: {
+ ProviderInteractionContext.$current.withValue(.background) {
+ ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting(
+ data: fallbackData,
+ fingerprint: nil)
+ {
+ ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(
+ .timedOut)
+ {
+ ClaudeOAuthCredentialsStore
+ .syncFromClaudeKeychainWithoutPrompt(now: Date())
+ }
+ }
+ }
+ })
+ })
+ })
+
+ #expect(synced == false)
+ #expect(preflightCalls.value == 0)
+ }
+ }
+ }
+ }
+}
diff --git a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreSecurityCLITests.swift b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreSecurityCLITests.swift
new file mode 100644
index 000000000..bfae65a0f
--- /dev/null
+++ b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreSecurityCLITests.swift
@@ -0,0 +1,864 @@
+import Foundation
+import Testing
+@testable import CodexBarCore
+
+@Suite(.serialized)
+struct ClaudeOAuthCredentialsStoreSecurityCLITests {
+ private func makeCredentialsData(accessToken: String, expiresAt: Date, refreshToken: String? = nil) -> Data {
+ let millis = Int(expiresAt.timeIntervalSince1970 * 1000)
+ let refreshField: String = {
+ guard let refreshToken else { return "" }
+ return ",\n \"refreshToken\": \"\(refreshToken)\""
+ }()
+ let json = """
+ {
+ "claudeAiOauth": {
+ "accessToken": "\(accessToken)",
+ "expiresAt": \(millis),
+ "scopes": ["user:profile"]\(refreshField)
+ }
+ }
+ """
+ return Data(json.utf8)
+ }
+
+ @Test
+ func experimentalReader_prefersSecurityCLIForNonInteractiveLoad() throws {
+ let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
+ try KeychainCacheStore.withServiceOverrideForTesting(service) {
+ try KeychainAccessGate.withTaskOverrideForTesting(false) {
+ KeychainCacheStore.setTestStoreForTesting(true)
+ defer { KeychainCacheStore.setTestStoreForTesting(false) }
+
+ ClaudeOAuthCredentialsStore.invalidateCache()
+ ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
+ defer {
+ ClaudeOAuthCredentialsStore.invalidateCache()
+ ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
+ ClaudeOAuthCredentialsStore.setClaudeKeychainDataOverrideForTesting(nil)
+ ClaudeOAuthCredentialsStore.setClaudeKeychainFingerprintOverrideForTesting(nil)
+ }
+
+ let tempDir = FileManager.default.temporaryDirectory
+ .appendingPathComponent(UUID().uuidString, isDirectory: true)
+ try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
+ let fileURL = tempDir.appendingPathComponent("credentials.json")
+
+ try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
+ let securityData = self.makeCredentialsData(
+ accessToken: "security-token",
+ expiresAt: Date(timeIntervalSinceNow: 3600),
+ refreshToken: "security-refresh")
+
+ let creds = try ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
+ .securityCLIExperimental,
+ operation: {
+ try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(
+ .onlyOnUserAction,
+ operation: {
+ try ProviderInteractionContext.$current.withValue(.userInitiated) {
+ try ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(
+ .data(securityData))
+ {
+ try ClaudeOAuthCredentialsStore.load(
+ environment: [:],
+ allowKeychainPrompt: false)
+ }
+ }
+ })
+ })
+
+ #expect(creds.accessToken == "security-token")
+ #expect(creds.refreshToken == "security-refresh")
+ #expect(creds.scopes.contains("user:profile"))
+ }
+ }
+ }
+ }
+
+ @Test
+ func experimentalReader_nonInteractiveBackgroundLoad_stillExecutesSecurityCLIRead() throws {
+ let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
+ try KeychainCacheStore.withServiceOverrideForTesting(service) {
+ try KeychainAccessGate.withTaskOverrideForTesting(false) {
+ KeychainCacheStore.setTestStoreForTesting(true)
+ defer { KeychainCacheStore.setTestStoreForTesting(false) }
+
+ ClaudeOAuthCredentialsStore.invalidateCache()
+ ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
+ defer {
+ ClaudeOAuthCredentialsStore.invalidateCache()
+ ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
+ ClaudeOAuthCredentialsStore.setClaudeKeychainDataOverrideForTesting(nil)
+ ClaudeOAuthCredentialsStore.setClaudeKeychainFingerprintOverrideForTesting(nil)
+ }
+
+ let tempDir = FileManager.default.temporaryDirectory
+ .appendingPathComponent(UUID().uuidString, isDirectory: true)
+ try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
+ let fileURL = tempDir.appendingPathComponent("credentials.json")
+
+ try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
+ let securityData = self.makeCredentialsData(
+ accessToken: "security-token-background",
+ expiresAt: Date(timeIntervalSinceNow: 3600),
+ refreshToken: "security-refresh-background")
+ final class ReadCounter: @unchecked Sendable {
+ var count = 0
+ }
+ let securityReadCalls = ReadCounter()
+
+ let creds = try ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
+ .securityCLIExperimental,
+ operation: {
+ try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(
+ .onlyOnUserAction,
+ operation: {
+ try ProviderInteractionContext.$current.withValue(.background) {
+ try ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(
+ .dynamic { _ in
+ securityReadCalls.count += 1
+ return securityData
+ }) {
+ try ClaudeOAuthCredentialsStore.load(
+ environment: [:],
+ allowKeychainPrompt: false)
+ }
+ }
+ })
+ })
+
+ #expect(creds.accessToken == "security-token-background")
+ #expect(securityReadCalls.count == 1)
+ }
+ }
+ }
+ }
+
+ @Test
+ func experimentalReader_fallsBackWhenSecurityCLIThrows() throws {
+ let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
+ try KeychainCacheStore.withServiceOverrideForTesting(service) {
+ try KeychainAccessGate.withTaskOverrideForTesting(false) {
+ KeychainCacheStore.setTestStoreForTesting(true)
+ defer { KeychainCacheStore.setTestStoreForTesting(false) }
+
+ ClaudeOAuthCredentialsStore.invalidateCache()
+ ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
+ defer {
+ ClaudeOAuthCredentialsStore.invalidateCache()
+ ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
+ ClaudeOAuthCredentialsStore.setClaudeKeychainDataOverrideForTesting(nil)
+ ClaudeOAuthCredentialsStore.setClaudeKeychainFingerprintOverrideForTesting(nil)
+ }
+
+ let tempDir = FileManager.default.temporaryDirectory
+ .appendingPathComponent(UUID().uuidString, isDirectory: true)
+ try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
+ let fileURL = tempDir.appendingPathComponent("credentials.json")
+
+ try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
+ let fallbackData = self.makeCredentialsData(
+ accessToken: "fallback-token",
+ expiresAt: Date(timeIntervalSinceNow: 3600),
+ refreshToken: "fallback-refresh")
+
+ let creds = try ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
+ .securityCLIExperimental,
+ operation: {
+ try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(
+ .onlyOnUserAction,
+ operation: {
+ try ProviderInteractionContext.$current.withValue(.userInitiated) {
+ try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting(
+ data: fallbackData,
+ fingerprint: nil)
+ {
+ try ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(
+ .timedOut)
+ {
+ try ClaudeOAuthCredentialsStore.load(
+ environment: [:],
+ allowKeychainPrompt: false)
+ }
+ }
+ }
+ })
+ })
+
+ #expect(creds.accessToken == "fallback-token")
+ }
+ }
+ }
+ }
+
+ @Test
+ func experimentalReader_fallsBackWhenSecurityCLIOutputMalformed() throws {
+ let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
+ try KeychainCacheStore.withServiceOverrideForTesting(service) {
+ try KeychainAccessGate.withTaskOverrideForTesting(false) {
+ KeychainCacheStore.setTestStoreForTesting(true)
+ defer { KeychainCacheStore.setTestStoreForTesting(false) }
+
+ ClaudeOAuthCredentialsStore.invalidateCache()
+ ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
+ defer {
+ ClaudeOAuthCredentialsStore.invalidateCache()
+ ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
+ ClaudeOAuthCredentialsStore.setClaudeKeychainDataOverrideForTesting(nil)
+ ClaudeOAuthCredentialsStore.setClaudeKeychainFingerprintOverrideForTesting(nil)
+ }
+
+ let tempDir = FileManager.default.temporaryDirectory
+ .appendingPathComponent(UUID().uuidString, isDirectory: true)
+ try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
+ let fileURL = tempDir.appendingPathComponent("credentials.json")
+
+ try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
+ let fallbackData = self.makeCredentialsData(
+ accessToken: "fallback-token",
+ expiresAt: Date(timeIntervalSinceNow: 3600))
+
+ let creds = try ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
+ .securityCLIExperimental,
+ operation: {
+ try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(
+ .onlyOnUserAction,
+ operation: {
+ try ProviderInteractionContext.$current.withValue(.userInitiated) {
+ try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting(
+ data: fallbackData,
+ fingerprint: nil)
+ {
+ try ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(
+ .data(Data("not-json".utf8)))
+ {
+ try ClaudeOAuthCredentialsStore.load(
+ environment: [:],
+ allowKeychainPrompt: false)
+ }
+ }
+ }
+ })
+ })
+
+ #expect(creds.accessToken == "fallback-token")
+ }
+ }
+ }
+ }
+
+ @Test
+ func experimentalReader_loadFromClaudeKeychainUsesSecurityCLI() throws {
+ let securityData = self.makeCredentialsData(
+ accessToken: "security-direct",
+ expiresAt: Date(timeIntervalSinceNow: 3600),
+ refreshToken: "security-refresh")
+ let fingerprintStore = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprintStore()
+ let sentinelFingerprint = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint(
+ modifiedAt: 200,
+ createdAt: 199,
+ persistentRefHash: "sentinel")
+
+ let loaded = try ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
+ .securityCLIExperimental,
+ operation: {
+ try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(
+ .always,
+ operation: {
+ try ProviderInteractionContext.$current.withValue(.userInitiated) {
+ try ClaudeOAuthCredentialsStore.withClaudeKeychainFingerprintStoreOverrideForTesting(
+ fingerprintStore)
+ {
+ try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting(
+ data: nil,
+ fingerprint: sentinelFingerprint)
+ {
+ try ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(
+ .data(securityData))
+ {
+ try ClaudeOAuthCredentialsStore.loadFromClaudeKeychain()
+ }
+ }
+ }
+ }
+ })
+ })
+
+ let creds = try ClaudeOAuthCredentials.parse(data: loaded)
+ #expect(creds.accessToken == "security-direct")
+ #expect(creds.refreshToken == "security-refresh")
+ #expect(fingerprintStore.fingerprint == nil)
+ }
+
+ @Test
+ func experimentalReader_hasClaudeKeychainCredentialsWithoutPrompt_usesSecurityCLI() {
+ let securityData = self.makeCredentialsData(
+ accessToken: "security-available",
+ expiresAt: Date(timeIntervalSinceNow: 3600))
+
+ let hasCredentials = ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
+ .securityCLIExperimental,
+ operation: {
+ ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(
+ .always,
+ operation: {
+ ProviderInteractionContext.$current.withValue(.userInitiated) {
+ ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(
+ .data(securityData))
+ {
+ ClaudeOAuthCredentialsStore.hasClaudeKeychainCredentialsWithoutPrompt()
+ }
+ }
+ })
+ })
+
+ #expect(hasCredentials == true)
+ }
+
+ @Test
+ func experimentalReader_hasClaudeKeychainCredentialsWithoutPrompt_fallsBackWhenSecurityCLIFails() {
+ let fallbackData = self.makeCredentialsData(
+ accessToken: "fallback-available",
+ expiresAt: Date(timeIntervalSinceNow: 3600))
+
+ let hasCredentials = ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
+ .securityCLIExperimental,
+ operation: {
+ ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(
+ .always,
+ operation: {
+ ProviderInteractionContext.$current.withValue(.userInitiated) {
+ ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting(
+ data: fallbackData,
+ fingerprint: nil)
+ {
+ ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(
+ .nonZeroExit)
+ {
+ ClaudeOAuthCredentialsStore.hasClaudeKeychainCredentialsWithoutPrompt()
+ }
+ }
+ }
+ })
+ })
+
+ #expect(hasCredentials == true)
+ }
+
+ @Test
+ func experimentalReader_ignoresPromptPolicyAndCooldownForBackgroundSilentCheck() {
+ let securityData = self.makeCredentialsData(
+ accessToken: "security-background",
+ expiresAt: Date(timeIntervalSinceNow: 3600))
+
+ let hasCredentials = KeychainAccessGate.withTaskOverrideForTesting(false) {
+ ClaudeOAuthKeychainAccessGate.withShouldAllowPromptOverrideForTesting(false) {
+ ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
+ .securityCLIExperimental,
+ operation: {
+ ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(
+ .never,
+ operation: {
+ ProviderInteractionContext.$current.withValue(.background) {
+ ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(
+ .data(securityData))
+ {
+ ClaudeOAuthCredentialsStore.hasClaudeKeychainCredentialsWithoutPrompt()
+ }
+ }
+ })
+ })
+ }
+ }
+
+ #expect(hasCredentials == true)
+ }
+
+ @Test
+ func experimentalReader_loadFromClaudeKeychainFallbackBlockedWhenStoredModeNever() throws {
+ var threwNotFound = false
+ do {
+ _ = try KeychainAccessGate.withTaskOverrideForTesting(false) {
+ try ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
+ .securityCLIExperimental,
+ operation: {
+ try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(
+ .never,
+ operation: {
+ try ProviderInteractionContext.$current.withValue(.background) {
+ try ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(
+ .nonZeroExit)
+ {
+ try ClaudeOAuthCredentialsStore.loadFromClaudeKeychain()
+ }
+ }
+ })
+ })
+ }
+ } catch let error as ClaudeOAuthCredentialsError {
+ if case .notFound = error {
+ threwNotFound = true
+ }
+ }
+
+ #expect(threwNotFound == true)
+ }
+
+ @Test
+ func experimentalReader_securityCLIRead_pinsPreferredAccountWhenAvailable() throws {
+ let securityData = self.makeCredentialsData(
+ accessToken: "security-account-pinned",
+ expiresAt: Date(timeIntervalSinceNow: 3600))
+ final class AccountBox: @unchecked Sendable {
+ var value: String?
+ }
+ let pinnedAccount = AccountBox()
+
+ let loaded = try ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
+ .securityCLIExperimental,
+ operation: {
+ try ClaudeOAuthCredentialsStore.withSecurityCLIReadAccountOverrideForTesting("new-account") {
+ try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(
+ .always,
+ operation: {
+ try ProviderInteractionContext.$current.withValue(.userInitiated) {
+ try ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(
+ .dynamic { request in
+ pinnedAccount.value = request.account
+ return securityData
+ }) {
+ try ClaudeOAuthCredentialsStore.loadFromClaudeKeychain()
+ }
+ }
+ })
+ }
+ })
+
+ let creds = try ClaudeOAuthCredentials.parse(data: loaded)
+ #expect(pinnedAccount.value == "new-account")
+ #expect(creds.accessToken == "security-account-pinned")
+ }
+
+ @Test
+ func experimentalReader_securityCLIRead_doesNotPinAccountInBackground() throws {
+ let securityData = self.makeCredentialsData(
+ accessToken: "security-account-not-pinned",
+ expiresAt: Date(timeIntervalSinceNow: 3600))
+ final class AccountBox: @unchecked Sendable {
+ var value: String?
+ }
+ let pinnedAccount = AccountBox()
+
+ let loaded = try ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
+ .securityCLIExperimental,
+ operation: {
+ try ClaudeOAuthCredentialsStore.withSecurityCLIReadAccountOverrideForTesting("new-account") {
+ try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(
+ .always,
+ operation: {
+ try ProviderInteractionContext.$current.withValue(.background) {
+ try ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(
+ .dynamic { request in
+ pinnedAccount.value = request.account
+ return securityData
+ }) {
+ try ClaudeOAuthCredentialsStore.loadFromClaudeKeychain()
+ }
+ }
+ })
+ }
+ })
+
+ let creds = try ClaudeOAuthCredentials.parse(data: loaded)
+ #expect(pinnedAccount.value == nil)
+ #expect(creds.accessToken == "security-account-not-pinned")
+ }
+
+ @Test
+ func experimentalReader_freshnessSync_skipsSecurityCLIWhenPreflightRequiresInteraction() throws {
+ let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
+ try KeychainCacheStore.withServiceOverrideForTesting(service) {
+ try KeychainAccessGate.withTaskOverrideForTesting(false) {
+ KeychainCacheStore.setTestStoreForTesting(true)
+ defer { KeychainCacheStore.setTestStoreForTesting(false) }
+
+ try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {
+ ClaudeOAuthCredentialsStore.invalidateCache()
+ ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
+ defer {
+ ClaudeOAuthCredentialsStore.invalidateCache()
+ ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
+ }
+
+ let tempDir = FileManager.default.temporaryDirectory
+ .appendingPathComponent(UUID().uuidString, isDirectory: true)
+ try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
+ let fileURL = tempDir.appendingPathComponent("credentials.json")
+ try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
+ let securityData = self.makeCredentialsData(
+ accessToken: "security-sync",
+ expiresAt: Date(timeIntervalSinceNow: 3600))
+ final class ReadCounter: @unchecked Sendable {
+ var count = 0
+ }
+ let securityReadCalls = ReadCounter()
+
+ func loadWithPreflight(
+ _ outcome: KeychainAccessPreflight.Outcome) throws -> ClaudeOAuthCredentials
+ {
+ let preflightOverride: (String, String?) -> KeychainAccessPreflight.Outcome = { _, _ in
+ outcome
+ }
+ return try KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting(
+ preflightOverride,
+ operation: {
+ try ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
+ .securityCLIExperimental)
+ {
+ try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) {
+ try ProviderInteractionContext.$current.withValue(.background) {
+ try ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(
+ .dynamic { _ in
+ securityReadCalls.count += 1
+ return securityData
+ }) {
+ try ClaudeOAuthCredentialsStore.load(
+ environment: [:],
+ allowKeychainPrompt: false,
+ respectKeychainPromptCooldown: true)
+ }
+ }
+ }
+ }
+ })
+ }
+
+ let first = try loadWithPreflight(.allowed)
+ #expect(first.accessToken == "security-sync")
+ #expect(securityReadCalls.count == 1)
+
+ let second = try loadWithPreflight(.interactionRequired)
+ #expect(second.accessToken == "security-sync")
+ #expect(securityReadCalls.count == 1)
+ }
+ }
+ }
+ }
+ }
+
+ @Test
+ func experimentalReader_freshnessSync_background_respectsStoredOnlyOnUserAction() throws {
+ let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
+ try KeychainCacheStore.withServiceOverrideForTesting(service) {
+ try KeychainAccessGate.withTaskOverrideForTesting(false) {
+ KeychainCacheStore.setTestStoreForTesting(true)
+ defer { KeychainCacheStore.setTestStoreForTesting(false) }
+
+ try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {
+ ClaudeOAuthCredentialsStore.invalidateCache()
+ ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
+ defer {
+ ClaudeOAuthCredentialsStore.invalidateCache()
+ ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
+ }
+
+ let tempDir = FileManager.default.temporaryDirectory
+ .appendingPathComponent(UUID().uuidString, isDirectory: true)
+ try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
+ let fileURL = tempDir.appendingPathComponent("credentials.json")
+ try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
+ let securityData = self.makeCredentialsData(
+ accessToken: "security-sync-only-on-user-action",
+ expiresAt: Date(timeIntervalSinceNow: 3600))
+ final class ReadCounter: @unchecked Sendable {
+ var count = 0
+ }
+ let securityReadCalls = ReadCounter()
+ let preflightOverride: (String, String?) -> KeychainAccessPreflight.Outcome = { _, _ in
+ .allowed
+ }
+
+ func load(_ interaction: ProviderInteraction) throws -> ClaudeOAuthCredentials {
+ try KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting(
+ preflightOverride,
+ operation: {
+ try ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
+ .securityCLIExperimental)
+ {
+ try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(
+ .onlyOnUserAction)
+ {
+ try ProviderInteractionContext.$current.withValue(interaction) {
+ try ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(
+ .dynamic { _ in
+ securityReadCalls.count += 1
+ return securityData
+ }) {
+ try ClaudeOAuthCredentialsStore.load(
+ environment: [:],
+ allowKeychainPrompt: false,
+ respectKeychainPromptCooldown: true)
+ }
+ }
+ }
+ }
+ })
+ }
+
+ let first = try load(.userInitiated)
+ #expect(first.accessToken == "security-sync-only-on-user-action")
+ #expect(securityReadCalls.count == 1)
+
+ let second = try load(.background)
+ #expect(second.accessToken == "security-sync-only-on-user-action")
+ #expect(securityReadCalls.count == 1)
+ }
+ }
+ }
+ }
+ }
+
+ @Test
+ func experimentalReader_syncFromClaudeKeychainWithoutPrompt_skipsFingerprintProbeAfterSecurityCLIRead() {
+ let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
+ KeychainCacheStore.withServiceOverrideForTesting(service) {
+ KeychainAccessGate.withTaskOverrideForTesting(false) {
+ KeychainCacheStore.setTestStoreForTesting(true)
+ defer { KeychainCacheStore.setTestStoreForTesting(false) }
+
+ ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {
+ ClaudeOAuthCredentialsStore.invalidateCache()
+ defer { ClaudeOAuthCredentialsStore.invalidateCache() }
+
+ let securityData = self.makeCredentialsData(
+ accessToken: "security-sync-no-fingerprint-probe",
+ expiresAt: Date(timeIntervalSinceNow: 3600))
+ let fingerprintStore = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprintStore()
+ let sentinelFingerprint = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint(
+ modifiedAt: 123,
+ createdAt: 122,
+ persistentRefHash: "sentinel")
+
+ let synced = ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
+ .securityCLIExperimental,
+ operation: {
+ ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) {
+ ProviderInteractionContext.$current.withValue(.background) {
+ ClaudeOAuthCredentialsStore.withClaudeKeychainFingerprintStoreOverrideForTesting(
+ fingerprintStore)
+ {
+ ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting(
+ data: nil,
+ fingerprint: sentinelFingerprint)
+ {
+ ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(
+ .data(securityData))
+ {
+ ClaudeOAuthCredentialsStore.syncFromClaudeKeychainWithoutPrompt(
+ now: Date())
+ }
+ }
+ }
+ }
+ }
+ })
+
+ #expect(synced == true)
+ #expect(fingerprintStore.fingerprint == nil)
+ }
+ }
+ }
+ }
+
+ @Test
+ func experimentalReader_noPromptRepair_skipsFingerprintProbeAfterSecurityCLISuccess() throws {
+ let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
+ try KeychainCacheStore.withServiceOverrideForTesting(service) {
+ try KeychainAccessGate.withTaskOverrideForTesting(false) {
+ KeychainCacheStore.setTestStoreForTesting(true)
+ defer { KeychainCacheStore.setTestStoreForTesting(false) }
+
+ ClaudeOAuthCredentialsStore.invalidateCache()
+ ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
+ defer {
+ ClaudeOAuthCredentialsStore.invalidateCache()
+ ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
+ }
+
+ let tempDir = FileManager.default.temporaryDirectory
+ .appendingPathComponent(UUID().uuidString, isDirectory: true)
+ try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
+ let fileURL = tempDir.appendingPathComponent("credentials.json")
+ try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
+ let securityData = self.makeCredentialsData(
+ accessToken: "security-repair-no-fingerprint-probe",
+ expiresAt: Date(timeIntervalSinceNow: 3600))
+ let fingerprintStore = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprintStore()
+ let sentinelFingerprint = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint(
+ modifiedAt: 456,
+ createdAt: 455,
+ persistentRefHash: "sentinel")
+
+ let record = try ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
+ .securityCLIExperimental,
+ operation: {
+ try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) {
+ try ProviderInteractionContext.$current.withValue(.background) {
+ try ClaudeOAuthCredentialsStore
+ .withClaudeKeychainFingerprintStoreOverrideForTesting(
+ fingerprintStore)
+ {
+ try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting(
+ data: nil,
+ fingerprint: sentinelFingerprint)
+ {
+ try ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(
+ .data(securityData))
+ {
+ try ClaudeOAuthCredentialsStore.loadRecord(
+ environment: [:],
+ allowKeychainPrompt: false,
+ respectKeychainPromptCooldown: true)
+ }
+ }
+ }
+ }
+ }
+ })
+
+ #expect(record.credentials.accessToken == "security-repair-no-fingerprint-probe")
+ #expect(record.source == .claudeKeychain)
+ #expect(fingerprintStore.fingerprint == nil)
+ }
+ }
+ }
+ }
+
+ @Test
+ func experimentalReader_loadWithPrompt_skipsFingerprintProbeAfterSecurityCLISuccess() throws {
+ let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
+ try KeychainCacheStore.withServiceOverrideForTesting(service) {
+ try KeychainAccessGate.withTaskOverrideForTesting(false) {
+ KeychainCacheStore.setTestStoreForTesting(true)
+ defer { KeychainCacheStore.setTestStoreForTesting(false) }
+
+ ClaudeOAuthCredentialsStore.invalidateCache()
+ ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
+ defer {
+ ClaudeOAuthCredentialsStore.invalidateCache()
+ ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
+ }
+
+ let tempDir = FileManager.default.temporaryDirectory
+ .appendingPathComponent(UUID().uuidString, isDirectory: true)
+ try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
+ let fileURL = tempDir.appendingPathComponent("credentials.json")
+ try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
+ let securityData = self.makeCredentialsData(
+ accessToken: "security-load-with-prompt",
+ expiresAt: Date(timeIntervalSinceNow: 3600))
+ let fingerprintStore = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprintStore()
+ let sentinelFingerprint = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint(
+ modifiedAt: 321,
+ createdAt: 320,
+ persistentRefHash: "sentinel")
+
+ let creds = try ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
+ .securityCLIExperimental,
+ operation: {
+ try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) {
+ try ProviderInteractionContext.$current.withValue(.userInitiated) {
+ try ClaudeOAuthCredentialsStore
+ .withClaudeKeychainFingerprintStoreOverrideForTesting(
+ fingerprintStore)
+ {
+ try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting(
+ data: nil,
+ fingerprint: sentinelFingerprint)
+ {
+ try ClaudeOAuthCredentialsStore
+ .withSecurityCLIReadOverrideForTesting(
+ .data(securityData))
+ {
+ try ClaudeOAuthCredentialsStore.load(
+ environment: [:],
+ allowKeychainPrompt: true,
+ respectKeychainPromptCooldown: false)
+ }
+ }
+ }
+ }
+ }
+ })
+
+ #expect(creds.accessToken == "security-load-with-prompt")
+ #expect(fingerprintStore.fingerprint == nil)
+ }
+ }
+ }
+ }
+
+ @Test
+ func experimentalReader_loadWithPrompt_doesNotReadWhenGlobalKeychainDisabled() throws {
+ let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
+ try KeychainCacheStore.withServiceOverrideForTesting(service) {
+ try KeychainAccessGate.withTaskOverrideForTesting(true) {
+ KeychainCacheStore.setTestStoreForTesting(true)
+ defer { KeychainCacheStore.setTestStoreForTesting(false) }
+
+ ClaudeOAuthCredentialsStore.invalidateCache()
+ ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
+ defer {
+ ClaudeOAuthCredentialsStore.invalidateCache()
+ ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
+ }
+
+ let tempDir = FileManager.default.temporaryDirectory
+ .appendingPathComponent(UUID().uuidString, isDirectory: true)
+ try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
+ let fileURL = tempDir.appendingPathComponent("credentials.json")
+ try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
+ let securityData = self.makeCredentialsData(
+ accessToken: "security-should-not-read",
+ expiresAt: Date(timeIntervalSinceNow: 3600))
+ var threwNotFound = false
+ final class ReadCounter: @unchecked Sendable {
+ var count = 0
+ }
+ let securityReadCalls = ReadCounter()
+
+ do {
+ _ = try ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
+ .securityCLIExperimental,
+ operation: {
+ try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) {
+ try ProviderInteractionContext.$current.withValue(.userInitiated) {
+ try ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(
+ .dynamic { _ in
+ securityReadCalls.count += 1
+ return securityData
+ }) {
+ try ClaudeOAuthCredentialsStore.load(
+ environment: [:],
+ allowKeychainPrompt: true,
+ respectKeychainPromptCooldown: false)
+ }
+ }
+ }
+ })
+ } catch let error as ClaudeOAuthCredentialsError {
+ if case .notFound = error {
+ threwNotFound = true
+ } else {
+ throw error
+ }
+ }
+
+ #expect(threwNotFound == true)
+ #expect(securityReadCalls.count < 1)
+ }
+ }
+ }
+ }
+}
diff --git a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTests.swift b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTests.swift
index a5df002e0..5fd258eb0 100644
--- a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTests.swift
+++ b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTests.swift
@@ -27,40 +27,63 @@ struct ClaudeOAuthCredentialsStoreTests {
let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
try ProviderInteractionContext.$current.withValue(.background) {
try KeychainCacheStore.withServiceOverrideForTesting(service) {
- try KeychainAccessGate.withTaskOverrideForTesting(true) {
+ try KeychainAccessGate.withTaskOverrideForTesting(false) {
KeychainCacheStore.setTestStoreForTesting(true)
defer { KeychainCacheStore.setTestStoreForTesting(false) }
- ClaudeOAuthCredentialsStore.setKeychainAccessOverrideForTesting(true)
- defer { ClaudeOAuthCredentialsStore.setKeychainAccessOverrideForTesting(nil) }
ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
defer { ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() }
+ try ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting {
+ try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {
+ let tempDir = FileManager.default.temporaryDirectory
+ .appendingPathComponent(UUID().uuidString, isDirectory: true)
+ try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
+ let fileURL = tempDir.appendingPathComponent("credentials.json")
+ try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
+ let expiredData = self.makeCredentialsData(
+ accessToken: "expired",
+ expiresAt: Date(timeIntervalSinceNow: -3600))
+ try expiredData.write(to: fileURL)
+
+ let cachedData = self.makeCredentialsData(
+ accessToken: "cached",
+ expiresAt: Date(timeIntervalSinceNow: 3600))
+ let cacheEntry = ClaudeOAuthCredentialsStore.CacheEntry(
+ data: cachedData,
+ storedAt: Date())
+ let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude)
+ ClaudeOAuthCredentialsStore.invalidateCache()
+ KeychainCacheStore.store(key: cacheKey, entry: cacheEntry)
+ defer { KeychainCacheStore.clear(key: cacheKey) }
+ _ = try ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
+ .securityFramework)
+ {
+ try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(
+ .onlyOnUserAction)
+ {
+ try ClaudeOAuthCredentialsStore.load(
+ environment: [:],
+ allowKeychainPrompt: false)
+ }
+ }
+ // Re-store to cache after file check has marked file as "seen"
+ KeychainCacheStore.store(key: cacheKey, entry: cacheEntry)
+ let creds = try ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
+ .securityFramework)
+ {
+ try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(
+ .onlyOnUserAction)
+ {
+ try ClaudeOAuthCredentialsStore.load(
+ environment: [:],
+ allowKeychainPrompt: false)
+ }
+ }
- let tempDir = FileManager.default.temporaryDirectory
- .appendingPathComponent(UUID().uuidString, isDirectory: true)
- try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
- let fileURL = tempDir.appendingPathComponent("credentials.json")
- try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
- let expiredData = self.makeCredentialsData(
- accessToken: "expired",
- expiresAt: Date(timeIntervalSinceNow: -3600))
- try expiredData.write(to: fileURL)
-
- let cachedData = self.makeCredentialsData(
- accessToken: "cached",
- expiresAt: Date(timeIntervalSinceNow: 3600))
- let cacheEntry = ClaudeOAuthCredentialsStore.CacheEntry(data: cachedData, storedAt: Date())
- let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude)
- ClaudeOAuthCredentialsStore.invalidateCache()
- KeychainCacheStore.store(key: cacheKey, entry: cacheEntry)
- defer { KeychainCacheStore.clear(key: cacheKey) }
- _ = try ClaudeOAuthCredentialsStore.load(environment: [:])
- // Re-store to cache after file check has marked file as "seen"
- KeychainCacheStore.store(key: cacheKey, entry: cacheEntry)
- let creds = try ClaudeOAuthCredentialsStore.load(environment: [:])
-
- #expect(creds.accessToken == "cached")
- #expect(creds.isExpired == false)
+ #expect(creds.accessToken == "cached")
+ #expect(creds.isExpired == false)
+ }
+ }
}
}
}
@@ -69,50 +92,61 @@ struct ClaudeOAuthCredentialsStoreTests {
@Test
func loadRecord_nonInteractiveRepairCanBeDisabled() throws {
- KeychainCacheStore.setTestStoreForTesting(true)
- defer { KeychainCacheStore.setTestStoreForTesting(false) }
+ let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
+ try KeychainCacheStore.withServiceOverrideForTesting(service) {
+ KeychainCacheStore.setTestStoreForTesting(true)
+ defer { KeychainCacheStore.setTestStoreForTesting(false) }
- ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
- defer { ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() }
+ ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
+ defer { ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() }
- // Ensure file-based lookup doesn't interfere (and avoid touching ~/.claude).
- let tempDir = FileManager.default.temporaryDirectory
- .appendingPathComponent(UUID().uuidString, isDirectory: true)
- try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
- let fileURL = tempDir.appendingPathComponent("credentials.json")
- try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
- ClaudeOAuthCredentialsStore.invalidateCache()
+ try ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting {
+ try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {
+ // Ensure file-based lookup doesn't interfere (and avoid touching ~/.claude).
+ let tempDir = FileManager.default.temporaryDirectory
+ .appendingPathComponent(UUID().uuidString, isDirectory: true)
+ try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
+ let fileURL = tempDir.appendingPathComponent("credentials.json")
+ try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
+ ClaudeOAuthCredentialsStore.invalidateCache()
- let keychainData = self.makeCredentialsData(
- accessToken: "claude-keychain",
- expiresAt: Date(timeIntervalSinceNow: 3600))
-
- // Simulate Claude Keychain containing creds, without querying the real Keychain.
- try ClaudeOAuthCredentialsStore
- .withClaudeKeychainOverridesForTesting(data: keychainData, fingerprint: nil) {
- // When repair is disabled, non-interactive loads should not consult Claude's keychain data.
- do {
- _ = try ClaudeOAuthCredentialsStore.loadRecord(
- environment: [:],
- allowKeychainPrompt: false,
- respectKeychainPromptCooldown: true,
- allowClaudeKeychainRepairWithoutPrompt: false)
- Issue.record("Expected ClaudeOAuthCredentialsError.notFound")
- } catch let error as ClaudeOAuthCredentialsError {
- guard case .notFound = error else {
- Issue.record("Expected .notFound, got \(error)")
- return
+ let keychainData = self.makeCredentialsData(
+ accessToken: "claude-keychain",
+ expiresAt: Date(timeIntervalSinceNow: 3600))
+
+ // Simulate Claude Keychain containing creds, without querying the real Keychain.
+ try ProviderInteractionContext.$current.withValue(.userInitiated) {
+ try ClaudeOAuthCredentialsStore
+ .withClaudeKeychainOverridesForTesting(data: keychainData, fingerprint: nil) {
+ // When repair is disabled, non-interactive loads should not consult Claude's
+ // keychain data.
+ do {
+ _ = try ClaudeOAuthCredentialsStore.loadRecord(
+ environment: [:],
+ allowKeychainPrompt: false,
+ respectKeychainPromptCooldown: true,
+ allowClaudeKeychainRepairWithoutPrompt: false)
+ Issue.record("Expected ClaudeOAuthCredentialsError.notFound")
+ } catch let error as ClaudeOAuthCredentialsError {
+ guard case .notFound = error else {
+ Issue.record("Expected .notFound, got \(error)")
+ return
+ }
+ }
+
+ // With repair enabled, we should be able to seed from the "Claude keychain"
+ // override.
+ let record = try ClaudeOAuthCredentialsStore.loadRecord(
+ environment: [:],
+ allowKeychainPrompt: false,
+ respectKeychainPromptCooldown: true,
+ allowClaudeKeychainRepairWithoutPrompt: true)
+ #expect(record.credentials.accessToken == "claude-keychain")
+ }
}
}
-
- // With repair enabled, we should be able to seed from the "Claude keychain" override.
- let record = try ClaudeOAuthCredentialsStore.loadRecord(
- environment: [:],
- allowKeychainPrompt: false,
- respectKeychainPromptCooldown: true,
- allowClaudeKeychainRepairWithoutPrompt: true)
- #expect(record.credentials.accessToken == "claude-keychain")
}
+ }
}
}
@@ -125,65 +159,68 @@ struct ClaudeOAuthCredentialsStoreTests {
defer { ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() }
// Avoid interacting with the real Keychain in unit tests.
- ClaudeOAuthCredentialsStore.setKeychainAccessOverrideForTesting(true)
- defer { ClaudeOAuthCredentialsStore.setKeychainAccessOverrideForTesting(nil) }
-
- let tempDir = FileManager.default.temporaryDirectory
- .appendingPathComponent(UUID().uuidString, isDirectory: true)
- try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
- let fileURL = tempDir.appendingPathComponent("credentials.json")
- try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
- let first = self.makeCredentialsData(
- accessToken: "first",
- expiresAt: Date(timeIntervalSinceNow: 3600))
- try first.write(to: fileURL)
+ try ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(true) {
+ let tempDir = FileManager.default.temporaryDirectory
+ .appendingPathComponent(UUID().uuidString, isDirectory: true)
+ try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
+ let fileURL = tempDir.appendingPathComponent("credentials.json")
+ try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
+ let first = self.makeCredentialsData(
+ accessToken: "first",
+ expiresAt: Date(timeIntervalSinceNow: 3600))
+ try first.write(to: fileURL)
- let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude)
- let cacheEntry = ClaudeOAuthCredentialsStore.CacheEntry(data: first, storedAt: Date())
- KeychainCacheStore.store(key: cacheKey, entry: cacheEntry)
+ let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude)
+ let cacheEntry = ClaudeOAuthCredentialsStore.CacheEntry(data: first, storedAt: Date())
+ KeychainCacheStore.store(key: cacheKey, entry: cacheEntry)
- _ = try ClaudeOAuthCredentialsStore.load(environment: [:])
+ _ = try ClaudeOAuthCredentialsStore.load(environment: [:])
- let updated = self.makeCredentialsData(
- accessToken: "second",
- expiresAt: Date(timeIntervalSinceNow: 3600))
- try updated.write(to: fileURL)
+ let updated = self.makeCredentialsData(
+ accessToken: "second",
+ expiresAt: Date(timeIntervalSinceNow: 3600))
+ try updated.write(to: fileURL)
- #expect(ClaudeOAuthCredentialsStore.invalidateCacheIfCredentialsFileChanged())
- KeychainCacheStore.clear(key: cacheKey)
+ #expect(ClaudeOAuthCredentialsStore.invalidateCacheIfCredentialsFileChanged())
+ KeychainCacheStore.clear(key: cacheKey)
- let creds = try ClaudeOAuthCredentialsStore.load(environment: [:])
- #expect(creds.accessToken == "second")
+ let creds = try ClaudeOAuthCredentialsStore.load(environment: [:])
+ #expect(creds.accessToken == "second")
+ }
}
}
@Test
func returnsExpiredFileWhenNoOtherSources() throws {
- try KeychainAccessGate.withTaskOverrideForTesting(true) {
- KeychainCacheStore.setTestStoreForTesting(true)
- defer { KeychainCacheStore.setTestStoreForTesting(false) }
-
- ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
- defer { ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() }
+ let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
+ try KeychainCacheStore.withServiceOverrideForTesting(service) {
+ try KeychainAccessGate.withTaskOverrideForTesting(true) {
+ KeychainCacheStore.setTestStoreForTesting(true)
+ defer { KeychainCacheStore.setTestStoreForTesting(false) }
- let tempDir = FileManager.default.temporaryDirectory
- .appendingPathComponent(UUID().uuidString, isDirectory: true)
- try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
- let fileURL = tempDir.appendingPathComponent("credentials.json")
- try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
- let expiredData = self.makeCredentialsData(
- accessToken: "expired-only",
- expiresAt: Date(timeIntervalSinceNow: -3600))
- try expiredData.write(to: fileURL)
+ try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {
+ ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
+ defer { ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() }
- ClaudeOAuthCredentialsStore.setKeychainAccessOverrideForTesting(true)
- defer { ClaudeOAuthCredentialsStore.setKeychainAccessOverrideForTesting(nil) }
+ let tempDir = FileManager.default.temporaryDirectory
+ .appendingPathComponent(UUID().uuidString, isDirectory: true)
+ try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
+ let fileURL = tempDir.appendingPathComponent("credentials.json")
+ try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
+ let expiredData = self.makeCredentialsData(
+ accessToken: "expired-only",
+ expiresAt: Date(timeIntervalSinceNow: -3600))
+ try expiredData.write(to: fileURL)
- ClaudeOAuthCredentialsStore.invalidateCache()
- let creds = try ClaudeOAuthCredentialsStore.load(environment: [:])
+ try ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(true) {
+ ClaudeOAuthCredentialsStore.invalidateCache()
+ let creds = try ClaudeOAuthCredentialsStore.load(environment: [:])
- #expect(creds.accessToken == "expired-only")
- #expect(creds.isExpired == true)
+ #expect(creds.accessToken == "expired-only")
+ #expect(creds.isExpired == true)
+ }
+ }
+ }
}
}
}
@@ -201,37 +238,36 @@ struct ClaudeOAuthCredentialsStoreTests {
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
let fileURL = tempDir.appendingPathComponent("credentials.json")
await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
- ClaudeOAuthCredentialsStore.setKeychainAccessOverrideForTesting(true)
- defer { ClaudeOAuthCredentialsStore.setKeychainAccessOverrideForTesting(nil) }
+ await ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(true) {
+ ClaudeOAuthCredentialsStore.invalidateCache()
+ let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude)
+ defer { KeychainCacheStore.clear(key: cacheKey) }
- ClaudeOAuthCredentialsStore.invalidateCache()
- let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude)
- defer { KeychainCacheStore.clear(key: cacheKey) }
+ let expiredData = self.makeCredentialsData(
+ accessToken: "expired-claude-cli-owner",
+ expiresAt: Date(timeIntervalSinceNow: -3600),
+ refreshToken: "refresh-token")
+ KeychainCacheStore.store(
+ key: cacheKey,
+ entry: ClaudeOAuthCredentialsStore.CacheEntry(
+ data: expiredData,
+ storedAt: Date(),
+ owner: .claudeCLI))
- let expiredData = self.makeCredentialsData(
- accessToken: "expired-claude-cli-owner",
- expiresAt: Date(timeIntervalSinceNow: -3600),
- refreshToken: "refresh-token")
- KeychainCacheStore.store(
- key: cacheKey,
- entry: ClaudeOAuthCredentialsStore.CacheEntry(
- data: expiredData,
- storedAt: Date(),
- owner: .claudeCLI))
-
- do {
- _ = try await ClaudeOAuthCredentialsStore.loadWithAutoRefresh(
- environment: [:],
- allowKeychainPrompt: false,
- respectKeychainPromptCooldown: true)
- Issue.record("Expected delegated refresh error for Claude CLI-owned credentials")
- } catch let error as ClaudeOAuthCredentialsError {
- guard case .refreshDelegatedToClaudeCLI = error else {
- Issue.record("Expected .refreshDelegatedToClaudeCLI, got \(error)")
- return
+ do {
+ _ = try await ClaudeOAuthCredentialsStore.loadWithAutoRefresh(
+ environment: [:],
+ allowKeychainPrompt: false,
+ respectKeychainPromptCooldown: true)
+ Issue.record("Expected delegated refresh error for Claude CLI-owned credentials")
+ } catch let error as ClaudeOAuthCredentialsError {
+ guard case .refreshDelegatedToClaudeCLI = error else {
+ Issue.record("Expected .refreshDelegatedToClaudeCLI, got \(error)")
+ return
+ }
+ } catch {
+ Issue.record("Expected ClaudeOAuthCredentialsError, got \(error)")
}
- } catch {
- Issue.record("Expected ClaudeOAuthCredentialsError, got \(error)")
}
}
}
@@ -251,38 +287,37 @@ struct ClaudeOAuthCredentialsStoreTests {
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
let fileURL = tempDir.appendingPathComponent("credentials.json")
await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
- ClaudeOAuthCredentialsStore.setKeychainAccessOverrideForTesting(true)
- defer { ClaudeOAuthCredentialsStore.setKeychainAccessOverrideForTesting(nil) }
-
- ClaudeOAuthCredentialsStore.invalidateCache()
- let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude)
- defer { KeychainCacheStore.clear(key: cacheKey) }
+ await ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(true) {
+ ClaudeOAuthCredentialsStore.invalidateCache()
+ let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude)
+ defer { KeychainCacheStore.clear(key: cacheKey) }
- let expiredData = self.makeCredentialsData(
- accessToken: "expired-codexbar-owner",
- expiresAt: Date(timeIntervalSinceNow: -3600),
- refreshToken: "refresh-token")
- KeychainCacheStore.store(
- key: cacheKey,
- entry: ClaudeOAuthCredentialsStore.CacheEntry(
- data: expiredData,
- storedAt: Date(),
- owner: .codexbar))
-
- await ClaudeOAuthRefreshFailureGate.$shouldAttemptOverride.withValue(false) {
- do {
- _ = try await ClaudeOAuthCredentialsStore.loadWithAutoRefresh(
- environment: [:],
- allowKeychainPrompt: false,
- respectKeychainPromptCooldown: true)
- Issue.record("Expected refresh failure for CodexBar-owned direct refresh path")
- } catch let error as ClaudeOAuthCredentialsError {
- guard case .refreshFailed = error else {
- Issue.record("Expected .refreshFailed, got \(error)")
- return
+ let expiredData = self.makeCredentialsData(
+ accessToken: "expired-codexbar-owner",
+ expiresAt: Date(timeIntervalSinceNow: -3600),
+ refreshToken: "refresh-token")
+ KeychainCacheStore.store(
+ key: cacheKey,
+ entry: ClaudeOAuthCredentialsStore.CacheEntry(
+ data: expiredData,
+ storedAt: Date(),
+ owner: .codexbar))
+
+ await ClaudeOAuthRefreshFailureGate.$shouldAttemptOverride.withValue(false) {
+ do {
+ _ = try await ClaudeOAuthCredentialsStore.loadWithAutoRefresh(
+ environment: [:],
+ allowKeychainPrompt: false,
+ respectKeychainPromptCooldown: true)
+ Issue.record("Expected refresh failure for CodexBar-owned direct refresh path")
+ } catch let error as ClaudeOAuthCredentialsError {
+ guard case .refreshFailed = error else {
+ Issue.record("Expected .refreshFailed, got \(error)")
+ return
+ }
+ } catch {
+ Issue.record("Expected ClaudeOAuthCredentialsError, got \(error)")
}
- } catch {
- Issue.record("Expected ClaudeOAuthCredentialsError, got \(error)")
}
}
}
@@ -302,29 +337,28 @@ struct ClaudeOAuthCredentialsStoreTests {
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
let fileURL = tempDir.appendingPathComponent("credentials.json")
try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
- ClaudeOAuthCredentialsStore.setKeychainAccessOverrideForTesting(true)
- defer { ClaudeOAuthCredentialsStore.setKeychainAccessOverrideForTesting(nil) }
+ try ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(true) {
+ ClaudeOAuthCredentialsStore.invalidateCache()
+ let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude)
+ defer { KeychainCacheStore.clear(key: cacheKey) }
- ClaudeOAuthCredentialsStore.invalidateCache()
- let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude)
- defer { KeychainCacheStore.clear(key: cacheKey) }
-
- let validData = self.makeCredentialsData(
- accessToken: "legacy-owner",
- expiresAt: Date(timeIntervalSinceNow: 3600),
- refreshToken: "refresh-token")
- KeychainCacheStore.store(
- key: cacheKey,
- entry: ClaudeOAuthCredentialsStore.CacheEntry(
- data: validData,
- storedAt: Date()))
-
- let record = try ClaudeOAuthCredentialsStore.loadRecord(
- environment: [:],
- allowKeychainPrompt: false,
- respectKeychainPromptCooldown: true)
- #expect(record.owner == .claudeCLI)
- #expect(record.source == .cacheKeychain)
+ let validData = self.makeCredentialsData(
+ accessToken: "legacy-owner",
+ expiresAt: Date(timeIntervalSinceNow: 3600),
+ refreshToken: "refresh-token")
+ KeychainCacheStore.store(
+ key: cacheKey,
+ entry: ClaudeOAuthCredentialsStore.CacheEntry(
+ data: validData,
+ storedAt: Date()))
+
+ let record = try ClaudeOAuthCredentialsStore.loadRecord(
+ environment: [:],
+ allowKeychainPrompt: false,
+ respectKeychainPromptCooldown: true)
+ #expect(record.owner == .claudeCLI)
+ #expect(record.source == .cacheKeychain)
+ }
}
}
@@ -785,148 +819,28 @@ struct ClaudeOAuthCredentialsStoreTests {
}
}
- @Test
- func doesNotShowPreAlertWhenClaudeKeychainReadableWithoutInteraction() throws {
- KeychainCacheStore.setTestStoreForTesting(true)
- defer { KeychainCacheStore.setTestStoreForTesting(false) }
-
- ClaudeOAuthCredentialsStore.invalidateCache()
- ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
- defer {
- ClaudeOAuthCredentialsStore.invalidateCache()
- ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
- ClaudeOAuthCredentialsStore.setClaudeKeychainDataOverrideForTesting(nil)
- KeychainPromptHandler.handler = nil
- KeychainAccessPreflight.setCheckGenericPasswordOverrideForTesting(nil)
- }
-
- let tempDir = FileManager.default.temporaryDirectory
- .appendingPathComponent(UUID().uuidString, isDirectory: true)
- try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
- let fileURL = tempDir.appendingPathComponent("credentials.json")
- try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
- let keychainData = self.makeCredentialsData(
- accessToken: "keychain-token",
- expiresAt: Date(timeIntervalSinceNow: 3600))
- ClaudeOAuthCredentialsStore.setClaudeKeychainDataOverrideForTesting(keychainData)
-
- KeychainAccessPreflight.setCheckGenericPasswordOverrideForTesting { _, _ in
- .allowed
- }
-
- var preAlertHits = 0
- KeychainPromptHandler.handler = { _ in
- preAlertHits += 1
- }
-
- let creds = try ClaudeOAuthCredentialsStore.load(environment: [:], allowKeychainPrompt: true)
- #expect(creds.accessToken == "keychain-token")
- #expect(preAlertHits == 0)
- }
- }
-
- @Test
- func showsPreAlertWhenClaudeKeychainLikelyRequiresInteraction() throws {
- KeychainCacheStore.setTestStoreForTesting(true)
- defer { KeychainCacheStore.setTestStoreForTesting(false) }
-
- ClaudeOAuthCredentialsStore.invalidateCache()
- ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
- defer {
- ClaudeOAuthCredentialsStore.invalidateCache()
- ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
- ClaudeOAuthCredentialsStore.setClaudeKeychainDataOverrideForTesting(nil)
- KeychainPromptHandler.handler = nil
- KeychainAccessPreflight.setCheckGenericPasswordOverrideForTesting(nil)
- }
-
- let tempDir = FileManager.default.temporaryDirectory
- .appendingPathComponent(UUID().uuidString, isDirectory: true)
- try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
- let fileURL = tempDir.appendingPathComponent("credentials.json")
- try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
- let keychainData = self.makeCredentialsData(
- accessToken: "keychain-token",
- expiresAt: Date(timeIntervalSinceNow: 3600))
- ClaudeOAuthCredentialsStore.setClaudeKeychainDataOverrideForTesting(keychainData)
-
- KeychainAccessPreflight.setCheckGenericPasswordOverrideForTesting { _, _ in
- .interactionRequired
- }
-
- var preAlertHits = 0
- KeychainPromptHandler.handler = { _ in
- preAlertHits += 1
- }
-
- let creds = try ClaudeOAuthCredentialsStore.load(environment: [:], allowKeychainPrompt: true)
- #expect(creds.accessToken == "keychain-token")
- #expect(preAlertHits == 1)
- }
- }
-
- @Test
- func showsPreAlertWhenClaudeKeychainPreflightFails() throws {
- KeychainCacheStore.setTestStoreForTesting(true)
- defer { KeychainCacheStore.setTestStoreForTesting(false) }
-
- ClaudeOAuthCredentialsStore.invalidateCache()
- ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
- defer {
- ClaudeOAuthCredentialsStore.invalidateCache()
- ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
- ClaudeOAuthCredentialsStore.setClaudeKeychainDataOverrideForTesting(nil)
- KeychainPromptHandler.handler = nil
- KeychainAccessPreflight.setCheckGenericPasswordOverrideForTesting(nil)
- }
-
- let tempDir = FileManager.default.temporaryDirectory
- .appendingPathComponent(UUID().uuidString, isDirectory: true)
- try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
- let fileURL = tempDir.appendingPathComponent("credentials.json")
- try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
- let keychainData = self.makeCredentialsData(
- accessToken: "keychain-token",
- expiresAt: Date(timeIntervalSinceNow: 3600))
- ClaudeOAuthCredentialsStore.setClaudeKeychainDataOverrideForTesting(keychainData)
-
- KeychainAccessPreflight.setCheckGenericPasswordOverrideForTesting { _, _ in
- .failure(-1)
- }
-
- var preAlertHits = 0
- KeychainPromptHandler.handler = { _ in
- preAlertHits += 1
- }
-
- let creds = try ClaudeOAuthCredentialsStore.load(environment: [:], allowKeychainPrompt: true)
- #expect(creds.accessToken == "keychain-token")
- #expect(preAlertHits == 1)
- }
- }
-
@Test
func syncFromClaudeKeychainWithoutPrompt_respectsBackoffInBackground() {
ProviderInteractionContext.$current.withValue(.background) {
KeychainAccessGate.withTaskOverrideForTesting(true) {
- ClaudeOAuthCredentialsStore.setKeychainAccessOverrideForTesting(true)
- defer { ClaudeOAuthCredentialsStore.setKeychainAccessOverrideForTesting(nil) }
-
- let store = ClaudeOAuthCredentialsStore.ClaudeKeychainOverrideStore(
- data: self.makeCredentialsData(
- accessToken: "override-token",
- expiresAt: Date(timeIntervalSinceNow: 3600)),
- fingerprint: ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint(
- modifiedAt: 1,
- createdAt: 1,
- persistentRefHash: "deadbeefdead"))
-
- let deniedStore = ClaudeOAuthKeychainAccessGate.DeniedUntilStore()
- deniedStore.deniedUntil = Date(timeIntervalSinceNow: 3600)
-
- ClaudeOAuthKeychainAccessGate.withDeniedUntilStoreOverrideForTesting(deniedStore) {
- ClaudeOAuthCredentialsStore.withMutableClaudeKeychainOverrideStoreForTesting(store) {
- #expect(ClaudeOAuthCredentialsStore.syncFromClaudeKeychainWithoutPrompt(now: Date()) == false)
+ ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(true) {
+ let store = ClaudeOAuthCredentialsStore.ClaudeKeychainOverrideStore(
+ data: self.makeCredentialsData(
+ accessToken: "override-token",
+ expiresAt: Date(timeIntervalSinceNow: 3600)),
+ fingerprint: ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint(
+ modifiedAt: 1,
+ createdAt: 1,
+ persistentRefHash: "deadbeefdead"))
+
+ let deniedStore = ClaudeOAuthKeychainAccessGate.DeniedUntilStore()
+ deniedStore.deniedUntil = Date(timeIntervalSinceNow: 3600)
+
+ ClaudeOAuthKeychainAccessGate.withDeniedUntilStoreOverrideForTesting(deniedStore) {
+ ClaudeOAuthCredentialsStore.withMutableClaudeKeychainOverrideStoreForTesting(store) {
+ #expect(ClaudeOAuthCredentialsStore
+ .syncFromClaudeKeychainWithoutPrompt(now: Date()) == false)
+ }
}
}
}
diff --git a/Tests/CodexBarTests/ClaudeOAuthDelegatedRefreshCoordinatorTests.swift b/Tests/CodexBarTests/ClaudeOAuthDelegatedRefreshCoordinatorTests.swift
index 0f6b7ab4a..3ab8f87c9 100644
--- a/Tests/CodexBarTests/ClaudeOAuthDelegatedRefreshCoordinatorTests.swift
+++ b/Tests/CodexBarTests/ClaudeOAuthDelegatedRefreshCoordinatorTests.swift
@@ -15,6 +15,20 @@ struct ClaudeOAuthDelegatedRefreshCoordinatorTests {
}
}
+ private func makeCredentialsData(accessToken: String, expiresAt: Date) -> Data {
+ let millis = Int(expiresAt.timeIntervalSince1970 * 1000)
+ let json = """
+ {
+ "claudeAiOauth": {
+ "accessToken": "\(accessToken)",
+ "expiresAt": \(millis),
+ "scopes": ["user:profile"]
+ }
+ }
+ """
+ return Data(json.utf8)
+ }
+
@Test
func cooldownPreventsRepeatedAttempts() async {
ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting()
@@ -210,4 +224,226 @@ struct ClaudeOAuthDelegatedRefreshCoordinatorTests {
#expect(outcomes.allSatisfy { $0 == .attemptedSucceeded })
#expect(counter.count == 1)
}
+
+ @Test
+ func experimentalStrategy_doesNotUseSecurityFrameworkFingerprintObservation() async {
+ ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting()
+ defer { ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting() }
+ await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
+ .securityCLIExperimental)
+ {
+ final class CounterBox: @unchecked Sendable {
+ private let lock = NSLock()
+ private(set) var count: Int = 0
+ func increment() {
+ self.lock.lock()
+ self.count += 1
+ self.lock.unlock()
+ }
+ }
+ let fingerprintCounter = CounterBox()
+ ClaudeOAuthDelegatedRefreshCoordinator.setKeychainFingerprintOverrideForTesting {
+ fingerprintCounter.increment()
+ return ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint(
+ modifiedAt: 1,
+ createdAt: 1,
+ persistentRefHash: "framework-fingerprint")
+ }
+ ClaudeOAuthDelegatedRefreshCoordinator.setCLIAvailableOverrideForTesting(true)
+ ClaudeOAuthDelegatedRefreshCoordinator.setTouchAuthPathOverrideForTesting { _ in }
+
+ let securityData = self.makeCredentialsData(
+ accessToken: "security-token-a",
+ expiresAt: Date(timeIntervalSinceNow: 3600))
+ ClaudeOAuthCredentialsStore.setSecurityCLIReadOverrideForTesting(.data(securityData))
+ defer { ClaudeOAuthCredentialsStore.setSecurityCLIReadOverrideForTesting(nil) }
+ let outcome = await ClaudeOAuthDelegatedRefreshCoordinator.attempt(
+ now: Date(timeIntervalSince1970: 60000),
+ timeout: 0.1)
+
+ guard case .attemptedFailed = outcome else {
+ Issue.record("Expected .attemptedFailed outcome")
+ return
+ }
+ #expect(fingerprintCounter.count < 1)
+ }
+ }
+
+ @Test
+ func experimentalStrategy_observesSecurityCLIChangeAfterTouch() async {
+ ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting()
+ defer { ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting() }
+ await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
+ .securityCLIExperimental)
+ {
+ final class DataBox: @unchecked Sendable {
+ private let lock = NSLock()
+ private var _data: Data?
+ init(data: Data?) {
+ self._data = data
+ }
+
+ func load() -> Data? {
+ self.lock.lock()
+ defer { self.lock.unlock() }
+ return self._data
+ }
+
+ func store(_ data: Data?) {
+ self.lock.lock()
+ self._data = data
+ self.lock.unlock()
+ }
+ }
+ final class CounterBox: @unchecked Sendable {
+ private let lock = NSLock()
+ private(set) var count: Int = 0
+ func increment() {
+ self.lock.lock()
+ self.count += 1
+ self.lock.unlock()
+ }
+ }
+ let fingerprintCounter = CounterBox()
+ ClaudeOAuthDelegatedRefreshCoordinator.setKeychainFingerprintOverrideForTesting {
+ fingerprintCounter.increment()
+ return ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint(
+ modifiedAt: 11,
+ createdAt: 11,
+ persistentRefHash: "framework-fingerprint")
+ }
+
+ let beforeData = self.makeCredentialsData(
+ accessToken: "security-token-before",
+ expiresAt: Date(timeIntervalSinceNow: -60))
+ let afterData = self.makeCredentialsData(
+ accessToken: "security-token-after",
+ expiresAt: Date(timeIntervalSinceNow: 3600))
+ let dataBox = DataBox(data: beforeData)
+
+ ClaudeOAuthDelegatedRefreshCoordinator.setCLIAvailableOverrideForTesting(true)
+ ClaudeOAuthDelegatedRefreshCoordinator.setTouchAuthPathOverrideForTesting { _ in
+ dataBox.store(afterData)
+ }
+ ClaudeOAuthCredentialsStore.setSecurityCLIReadOverrideForTesting(.dynamic { _ in dataBox.load() })
+ defer { ClaudeOAuthCredentialsStore.setSecurityCLIReadOverrideForTesting(nil) }
+
+ let outcome = await ClaudeOAuthDelegatedRefreshCoordinator.attempt(
+ now: Date(timeIntervalSince1970: 61000),
+ timeout: 0.1)
+
+ #expect(outcome == .attemptedSucceeded)
+ #expect(fingerprintCounter.count < 1)
+ }
+ }
+
+ @Test
+ func experimentalStrategy_missingBaselineDoesNotAutoSucceedWhenLaterReadSucceeds() async {
+ ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting()
+ defer { ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting() }
+ await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
+ .securityCLIExperimental)
+ {
+ final class DataBox: @unchecked Sendable {
+ private let lock = NSLock()
+ private var _data: Data?
+ init(data: Data?) {
+ self._data = data
+ }
+
+ func load() -> Data? {
+ self.lock.lock()
+ defer { self.lock.unlock() }
+ return self._data
+ }
+
+ func store(_ data: Data?) {
+ self.lock.lock()
+ self._data = data
+ self.lock.unlock()
+ }
+ }
+ final class CounterBox: @unchecked Sendable {
+ private let lock = NSLock()
+ private(set) var count: Int = 0
+ func increment() {
+ self.lock.lock()
+ self.count += 1
+ self.lock.unlock()
+ }
+ }
+ let fingerprintCounter = CounterBox()
+ ClaudeOAuthDelegatedRefreshCoordinator.setKeychainFingerprintOverrideForTesting {
+ fingerprintCounter.increment()
+ return ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint(
+ modifiedAt: 21,
+ createdAt: 21,
+ persistentRefHash: "framework-fingerprint")
+ }
+
+ let afterData = self.makeCredentialsData(
+ accessToken: "security-token-after-baseline-miss",
+ expiresAt: Date(timeIntervalSinceNow: 3600))
+ let dataBox = DataBox(data: nil)
+
+ ClaudeOAuthDelegatedRefreshCoordinator.setCLIAvailableOverrideForTesting(true)
+ ClaudeOAuthDelegatedRefreshCoordinator.setTouchAuthPathOverrideForTesting { _ in
+ dataBox.store(afterData)
+ }
+ ClaudeOAuthCredentialsStore.setSecurityCLIReadOverrideForTesting(.dynamic { _ in dataBox.load() })
+ defer { ClaudeOAuthCredentialsStore.setSecurityCLIReadOverrideForTesting(nil) }
+
+ let outcome = await ClaudeOAuthDelegatedRefreshCoordinator.attempt(
+ now: Date(timeIntervalSince1970: 61500),
+ timeout: 0.1)
+
+ guard case .attemptedFailed = outcome else {
+ Issue.record("Expected .attemptedFailed outcome when baseline is unavailable")
+ return
+ }
+ #expect(fingerprintCounter.count < 1)
+ }
+ }
+
+ @Test
+ func experimentalStrategy_observationSkipsSecurityCLIWhenGlobalKeychainDisabled() async {
+ ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting()
+ defer { ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting() }
+ await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
+ .securityCLIExperimental)
+ {
+ final class CounterBox: @unchecked Sendable {
+ private let lock = NSLock()
+ private(set) var count: Int = 0
+ func increment() {
+ self.lock.lock()
+ self.count += 1
+ self.lock.unlock()
+ }
+ }
+
+ let securityReadCounter = CounterBox()
+ let securityData = self.makeCredentialsData(
+ accessToken: "security-should-not-be-read",
+ expiresAt: Date(timeIntervalSinceNow: 3600))
+ ClaudeOAuthDelegatedRefreshCoordinator.setCLIAvailableOverrideForTesting(true)
+ ClaudeOAuthDelegatedRefreshCoordinator.setTouchAuthPathOverrideForTesting { _ in }
+ ClaudeOAuthCredentialsStore.setSecurityCLIReadOverrideForTesting(.dynamic { _ in
+ securityReadCounter.increment()
+ return securityData
+ })
+ defer { ClaudeOAuthCredentialsStore.setSecurityCLIReadOverrideForTesting(nil) }
+ let outcome = await KeychainAccessGate.withTaskOverrideForTesting(true) {
+ await ClaudeOAuthDelegatedRefreshCoordinator.attempt(
+ now: Date(timeIntervalSince1970: 62000),
+ timeout: 0.1)
+ }
+
+ guard case .attemptedFailed = outcome else {
+ Issue.record("Expected .attemptedFailed outcome")
+ return
+ }
+ #expect(securityReadCounter.count < 1)
+ }
+ }
}
diff --git a/Tests/CodexBarTests/ClaudeOAuthDelegatedRefreshRecoveryTests.swift b/Tests/CodexBarTests/ClaudeOAuthDelegatedRefreshRecoveryTests.swift
index d8b3d3a4f..fa25a78e6 100644
--- a/Tests/CodexBarTests/ClaudeOAuthDelegatedRefreshRecoveryTests.swift
+++ b/Tests/CodexBarTests/ClaudeOAuthDelegatedRefreshRecoveryTests.swift
@@ -74,77 +74,88 @@ struct ClaudeOAuthDelegatedRefreshRecoveryTests {
ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeTrackingForTesting()
defer { ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeTrackingForTesting() }
- try await ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {
- let tempDir = FileManager.default.temporaryDirectory
- .appendingPathComponent(UUID().uuidString, isDirectory: true)
- try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
- let fileURL = tempDir.appendingPathComponent("credentials.json")
- let snapshot = try await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
- // Seed an expired cache entry owned by Claude CLI, so the initial load delegates refresh.
- ClaudeOAuthCredentialsStore.invalidateCache()
- let expiredData = self.makeCredentialsData(
- accessToken: "expired-token",
- expiresAt: Date(timeIntervalSinceNow: -3600))
- let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude)
- let cacheEntry = ClaudeOAuthCredentialsStore.CacheEntry(
- data: expiredData,
- storedAt: Date(),
- owner: .claudeCLI)
- KeychainCacheStore.store(key: cacheKey, entry: cacheEntry)
- defer { KeychainCacheStore.clear(key: cacheKey) }
-
- // Sanity: setup should be visible to the code under test.
- // Otherwise it may attempt interactive reads.
- #expect(ClaudeOAuthCredentialsStore.hasCachedCredentials(environment: [:]) == true)
-
- // Simulate Claude CLI writing fresh credentials into the Claude Code keychain entry.
- let freshData = self.makeCredentialsData(
- accessToken: "fresh-token",
- expiresAt: Date(timeIntervalSinceNow: 3600))
- let fingerprint = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint(
- modifiedAt: 1,
- createdAt: 1,
- persistentRefHash: "test")
-
- let fetcher = ClaudeUsageFetcher(
- browserDetection: BrowserDetection(cacheTTL: 0),
- environment: [:],
- dataSource: .oauth,
- oauthKeychainPromptCooldownEnabled: true)
-
- let fetchOverride: (@Sendable (String) async throws -> OAuthUsageResponse)? = { token in
- await tokenCapture.set(token)
- return usageResponse
- }
- let delegatedOverride: (@Sendable (
- Date,
- TimeInterval) async -> ClaudeOAuthDelegatedRefreshCoordinator.Outcome)? = { _, _ in
- _ = await delegatedCounter.increment()
- return .attemptedSucceeded
- }
+ try await ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting {
+ try await ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {
+ let tempDir = FileManager.default.temporaryDirectory
+ .appendingPathComponent(UUID().uuidString, isDirectory: true)
+ try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
+ let fileURL = tempDir.appendingPathComponent("credentials.json")
+ let snapshot = try await ClaudeOAuthCredentialsStore
+ .withCredentialsURLOverrideForTesting(fileURL) {
+ // Seed an expired cache entry owned by Claude CLI, so the initial load delegates
+ // refresh.
+ ClaudeOAuthCredentialsStore.invalidateCache()
+ let expiredData = self.makeCredentialsData(
+ accessToken: "expired-token",
+ expiresAt: Date(timeIntervalSinceNow: -3600))
+ let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude)
+ let cacheEntry = ClaudeOAuthCredentialsStore.CacheEntry(
+ data: expiredData,
+ storedAt: Date(),
+ owner: .claudeCLI)
+ KeychainCacheStore.store(key: cacheKey, entry: cacheEntry)
+ defer { KeychainCacheStore.clear(key: cacheKey) }
+
+ // Sanity: setup should be visible to the code under test.
+ // Otherwise it may attempt interactive reads.
+ #expect(ClaudeOAuthCredentialsStore.hasCachedCredentials(environment: [:]) == true)
+
+ // Simulate Claude CLI writing fresh credentials into the Claude Code keychain entry.
+ let freshData = self.makeCredentialsData(
+ accessToken: "fresh-token",
+ expiresAt: Date(timeIntervalSinceNow: 3600))
+ let fingerprint = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint(
+ modifiedAt: 1,
+ createdAt: 1,
+ persistentRefHash: "test")
+
+ let fetcher = ClaudeUsageFetcher(
+ browserDetection: BrowserDetection(cacheTTL: 0),
+ environment: [:],
+ dataSource: .oauth,
+ oauthKeychainPromptCooldownEnabled: true)
+
+ let fetchOverride: (@Sendable (String) async throws -> OAuthUsageResponse)? = { token in
+ await tokenCapture.set(token)
+ return usageResponse
+ }
+ let delegatedOverride: (@Sendable (
+ Date,
+ TimeInterval) async -> ClaudeOAuthDelegatedRefreshCoordinator.Outcome)? = { _, _ in
+ _ = await delegatedCounter.increment()
+ return .attemptedSucceeded
+ }
- let snapshot = try await ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting(
- data: freshData,
- fingerprint: fingerprint)
- {
- try await ClaudeUsageFetcher.$fetchOAuthUsageOverride.withValue(fetchOverride) {
- try await ClaudeUsageFetcher.$delegatedRefreshAttemptOverride
- .withValue(delegatedOverride) {
- try await fetcher.loadLatestUsage(model: "sonnet")
+ let snapshot = try await ClaudeOAuthKeychainPromptPreference
+ .withTaskOverrideForTesting(.onlyOnUserAction) {
+ try await ProviderInteractionContext.$current.withValue(.userInitiated) {
+ try await ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting(
+ data: freshData,
+ fingerprint: fingerprint)
+ {
+ try await ClaudeUsageFetcher.$fetchOAuthUsageOverride
+ .withValue(fetchOverride) {
+ try await ClaudeUsageFetcher.$delegatedRefreshAttemptOverride
+ .withValue(delegatedOverride) {
+ try await fetcher.loadLatestUsage(model: "sonnet")
+ }
+ }
+ }
+ }
}
- }
- }
- // If Claude keychain already contains fresh credentials, we should recover without needing a
- // CLI
- // touch.
- #expect(await delegatedCounter.current() == 0)
- #expect(await tokenCapture.get() == "fresh-token")
- #expect(snapshot.primary.usedPercent == 7)
- #expect(snapshot.secondary?.usedPercent == 21)
- return snapshot
+ // If Claude keychain already contains fresh credentials, we should recover without
+ // needing a
+ // CLI
+ // touch.
+ #expect(await delegatedCounter.current() == 0)
+ #expect(await tokenCapture.get() == "fresh-token")
+ #expect(snapshot.primary.usedPercent == 7)
+ #expect(snapshot.secondary?.usedPercent == 21)
+ return snapshot
+ }
+ _ = snapshot
}
- _ = snapshot
}
}
}
@@ -167,84 +178,195 @@ struct ClaudeOAuthDelegatedRefreshRecoveryTests {
ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeTrackingForTesting()
defer { ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeTrackingForTesting() }
- try await ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {
- let tempDir = FileManager.default.temporaryDirectory
- .appendingPathComponent(UUID().uuidString, isDirectory: true)
- try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
- let fileURL = tempDir.appendingPathComponent("credentials.json")
- let snapshot = try await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
- // Seed an expired cache entry owned by Claude CLI, so the initial load delegates refresh.
- ClaudeOAuthCredentialsStore.invalidateCache()
- let expiredData = self.makeCredentialsData(
- accessToken: "expired-token",
- expiresAt: Date(timeIntervalSinceNow: -3600))
- let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude)
- let cacheEntry = ClaudeOAuthCredentialsStore.CacheEntry(
- data: expiredData,
- storedAt: Date(),
- owner: .claudeCLI)
- KeychainCacheStore.store(key: cacheKey, entry: cacheEntry)
- defer { KeychainCacheStore.clear(key: cacheKey) }
-
- // Ensure we don't silently repair from the Claude keychain before delegation.
- // Use an explicit empty-data override so we never consult the real system Keychain during
- // tests.
- let stubFingerprint = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint(
- modifiedAt: 1,
- createdAt: 1,
- persistentRefHash: "test")
- let keychainOverrideStore = ClaudeOAuthCredentialsStore.ClaudeKeychainOverrideStore(
- data: Data(),
- fingerprint: stubFingerprint)
-
- let freshData = self.makeCredentialsData(
- accessToken: "fresh-token",
- expiresAt: Date(timeIntervalSinceNow: 3600))
-
- let fetcher = ClaudeUsageFetcher(
- browserDetection: BrowserDetection(cacheTTL: 0),
- environment: [:],
- dataSource: .oauth,
- oauthKeychainPromptCooldownEnabled: true)
-
- let fetchOverride: (@Sendable (String) async throws -> OAuthUsageResponse)? = { token in
- await tokenCapture.set(token)
- return usageResponse
- }
+ try await ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting {
+ try await ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {
+ let tempDir = FileManager.default.temporaryDirectory
+ .appendingPathComponent(UUID().uuidString, isDirectory: true)
+ try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
+ let fileURL = tempDir.appendingPathComponent("credentials.json")
+ let snapshot = try await ClaudeOAuthCredentialsStore
+ .withCredentialsURLOverrideForTesting(fileURL) {
+ // Seed an expired cache entry owned by Claude CLI, so the initial load delegates
+ // refresh.
+ ClaudeOAuthCredentialsStore.invalidateCache()
+ let expiredData = self.makeCredentialsData(
+ accessToken: "expired-token",
+ expiresAt: Date(timeIntervalSinceNow: -3600))
+ let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude)
+ let cacheEntry = ClaudeOAuthCredentialsStore.CacheEntry(
+ data: expiredData,
+ storedAt: Date(),
+ owner: .claudeCLI)
+ KeychainCacheStore.store(key: cacheKey, entry: cacheEntry)
+ defer { KeychainCacheStore.clear(key: cacheKey) }
- let delegatedOverride: (@Sendable (
- Date,
- TimeInterval) async -> ClaudeOAuthDelegatedRefreshCoordinator.Outcome)? = { _, _ in
- // Simulate Claude CLI writing fresh credentials after the delegated refresh touch.
- keychainOverrideStore.data = freshData
- keychainOverrideStore.fingerprint = stubFingerprint
- _ = await delegatedCounter.increment()
- return .attemptedSucceeded
- }
+ // Ensure we don't silently repair from the Claude keychain before delegation.
+ // Use an explicit empty-data override so we never consult the real system Keychain
+ // during
+ // tests.
+ let stubFingerprint = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint(
+ modifiedAt: 1,
+ createdAt: 1,
+ persistentRefHash: "test")
+ let keychainOverrideStore = ClaudeOAuthCredentialsStore.ClaudeKeychainOverrideStore(
+ data: Data(),
+ fingerprint: stubFingerprint)
- let snapshot = try await ClaudeOAuthCredentialsStore
- .withMutableClaudeKeychainOverrideStoreForTesting(
- keychainOverrideStore)
- {
- try await ClaudeUsageFetcher.$fetchOAuthUsageOverride.withValue(fetchOverride) {
- try await ClaudeUsageFetcher.$delegatedRefreshAttemptOverride
- .withValue(delegatedOverride) {
- try await fetcher.loadLatestUsage(model: "sonnet")
+ let freshData = self.makeCredentialsData(
+ accessToken: "fresh-token",
+ expiresAt: Date(timeIntervalSinceNow: 3600))
+
+ let fetcher = ClaudeUsageFetcher(
+ browserDetection: BrowserDetection(cacheTTL: 0),
+ environment: [:],
+ dataSource: .oauth,
+ oauthKeychainPromptCooldownEnabled: true)
+
+ let fetchOverride: (@Sendable (String) async throws -> OAuthUsageResponse)? = { token in
+ await tokenCapture.set(token)
+ return usageResponse
+ }
+
+ let delegatedOverride: (@Sendable (
+ Date,
+ TimeInterval) async -> ClaudeOAuthDelegatedRefreshCoordinator.Outcome)? = { _, _ in
+ // Simulate Claude CLI writing fresh credentials after the delegated refresh touch.
+ keychainOverrideStore.data = freshData
+ keychainOverrideStore.fingerprint = stubFingerprint
+ _ = await delegatedCounter.increment()
+ return .attemptedSucceeded
+ }
+
+ let snapshot = try await ClaudeOAuthKeychainPromptPreference
+ .withTaskOverrideForTesting(.always) {
+ try await ProviderInteractionContext.$current.withValue(.userInitiated) {
+ try await ClaudeOAuthCredentialsStore
+ .withMutableClaudeKeychainOverrideStoreForTesting(
+ keychainOverrideStore)
+ {
+ try await ClaudeUsageFetcher.$fetchOAuthUsageOverride
+ .withValue(fetchOverride) {
+ try await ClaudeUsageFetcher
+ .$delegatedRefreshAttemptOverride
+ .withValue(delegatedOverride) {
+ try await fetcher.loadLatestUsage(model: "sonnet")
+ }
+ }
+ }
}
+ }
+
+ #expect(await delegatedCounter.current() == 1)
+ let capturedToken = await tokenCapture.get()
+ if capturedToken != "fresh-token" {
+ Issue.record("Expected fresh-token, got \(capturedToken ?? "nil")")
+ }
+ #expect(capturedToken == "fresh-token")
+ #expect(snapshot.primary.usedPercent == 7)
+ #expect(snapshot.secondary?.usedPercent == 21)
+ return snapshot
+ }
+ _ = snapshot
+ }
+ }
+ }
+ }
+ }
+
+ @Test
+ func delegatedRefresh_attemptedSucceeded_backgroundOnlyOnUserAction_doesNotRecoverFromKeychain() async throws {
+ let delegatedCounter = AsyncCounter()
+ let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
+
+ try await KeychainCacheStore.withServiceOverrideForTesting(service) {
+ try await KeychainAccessGate.withTaskOverrideForTesting(false) {
+ KeychainCacheStore.setTestStoreForTesting(true)
+ defer { KeychainCacheStore.setTestStoreForTesting(false) }
+
+ ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
+ defer { ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() }
+ ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeTrackingForTesting()
+ defer { ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeTrackingForTesting() }
+
+ try await ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting {
+ try await ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {
+ let tempDir = FileManager.default.temporaryDirectory
+ .appendingPathComponent(UUID().uuidString, isDirectory: true)
+ try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
+ let fileURL = tempDir.appendingPathComponent("credentials.json")
+
+ await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
+ ClaudeOAuthCredentialsStore.invalidateCache()
+ let expiredData = self.makeCredentialsData(
+ accessToken: "expired-token",
+ expiresAt: Date(timeIntervalSinceNow: -3600))
+ let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude)
+ let cacheEntry = ClaudeOAuthCredentialsStore.CacheEntry(
+ data: expiredData,
+ storedAt: Date(),
+ owner: .claudeCLI)
+ KeychainCacheStore.store(key: cacheKey, entry: cacheEntry)
+ defer { KeychainCacheStore.clear(key: cacheKey) }
+
+ // Expired Claude-CLI-owned credentials are still considered cache-present (delegatable).
+ #expect(ClaudeOAuthCredentialsStore.hasCachedCredentials(environment: [:]) == true)
+
+ let stubFingerprint = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint(
+ modifiedAt: 1,
+ createdAt: 1,
+ persistentRefHash: "test")
+ let keychainOverrideStore = ClaudeOAuthCredentialsStore.ClaudeKeychainOverrideStore(
+ data: Data(),
+ fingerprint: stubFingerprint)
+ let freshData = self.makeCredentialsData(
+ accessToken: "fresh-token",
+ expiresAt: Date(timeIntervalSinceNow: 3600))
+
+ let fetcher = ClaudeUsageFetcher(
+ browserDetection: BrowserDetection(cacheTTL: 0),
+ environment: [:],
+ dataSource: .oauth,
+ oauthKeychainPromptCooldownEnabled: false,
+ allowBackgroundDelegatedRefresh: true)
+
+ let delegatedOverride: (@Sendable (
+ Date,
+ TimeInterval) async -> ClaudeOAuthDelegatedRefreshCoordinator.Outcome)? = { _, _ in
+ keychainOverrideStore.data = freshData
+ keychainOverrideStore.fingerprint = stubFingerprint
+ _ = await delegatedCounter.increment()
+ return .attemptedSucceeded
+ }
+
+ do {
+ _ = try await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(
+ .onlyOnUserAction)
+ {
+ try await ProviderInteractionContext.$current.withValue(.background) {
+ try await ClaudeOAuthCredentialsStore
+ .withMutableClaudeKeychainOverrideStoreForTesting(keychainOverrideStore) {
+ try await ClaudeUsageFetcher.$delegatedRefreshAttemptOverride
+ .withValue(delegatedOverride) {
+ try await fetcher.loadLatestUsage(model: "sonnet")
+ }
+ }
+ }
+ }
+ Issue.record(
+ "Expected OAuth fetch failure: background keychain recovery should stay blocked")
+ } catch let error as ClaudeUsageError {
+ guard case let .oauthFailed(message) = error else {
+ Issue.record("Expected ClaudeUsageError.oauthFailed, got \(error)")
+ return
}
+ #expect(message.contains("still unavailable after delegated Claude CLI refresh"))
+ } catch {
+ Issue.record("Expected ClaudeUsageError, got \(error)")
}
- #expect(await delegatedCounter.current() == 1)
- let capturedToken = await tokenCapture.get()
- if capturedToken != "fresh-token" {
- Issue.record("Expected fresh-token, got \(capturedToken ?? "nil")")
+ #expect(await delegatedCounter.current() == 1)
}
- #expect(capturedToken == "fresh-token")
- #expect(snapshot.primary.usedPercent == 7)
- #expect(snapshot.secondary?.usedPercent == 21)
- return snapshot
}
- _ = snapshot
}
}
}
diff --git a/Tests/CodexBarTests/ClaudeOAuthFetchStrategyAvailabilityTests.swift b/Tests/CodexBarTests/ClaudeOAuthFetchStrategyAvailabilityTests.swift
index 31db80875..6bebf67a4 100644
--- a/Tests/CodexBarTests/ClaudeOAuthFetchStrategyAvailabilityTests.swift
+++ b/Tests/CodexBarTests/ClaudeOAuthFetchStrategyAvailabilityTests.swift
@@ -4,7 +4,21 @@ import Testing
#if os(macOS)
@Suite(.serialized)
-struct ClaudeKeychainCLIFetchStrategyTests {
+struct ClaudeOAuthFetchStrategyAvailabilityTests {
+ private struct StubClaudeFetcher: ClaudeUsageFetching {
+ func loadLatestUsage(model _: String) async throws -> ClaudeUsageSnapshot {
+ throw ClaudeUsageError.parseFailed("stub")
+ }
+
+ func debugRawProbe(model _: String) async -> String {
+ "stub"
+ }
+
+ func detectVersion() -> String? {
+ nil
+ }
+ }
+
private func makeContext(sourceMode: ProviderSourceMode) -> ProviderFetchContext {
let env: [String: String] = [:]
return ProviderFetchContext(
@@ -17,25 +31,268 @@ struct ClaudeKeychainCLIFetchStrategyTests {
env: env,
settings: nil,
fetcher: UsageFetcher(environment: env),
- claudeFetcher: ClaudeUsageFetcher(browserDetection: BrowserDetection(cacheTTL: 0)),
+ claudeFetcher: StubClaudeFetcher(),
browserDetection: BrowserDetection(cacheTTL: 0))
}
+ private func expiredRecord(owner: ClaudeOAuthCredentialOwner = .claudeCLI) -> ClaudeOAuthCredentialRecord {
+ ClaudeOAuthCredentialRecord(
+ credentials: ClaudeOAuthCredentials(
+ accessToken: "expired-token",
+ refreshToken: "refresh-token",
+ expiresAt: Date(timeIntervalSinceNow: -60),
+ scopes: ["user:profile"],
+ rateLimitTier: nil),
+ owner: owner,
+ source: .cacheKeychain)
+ }
+
+ @Test
+ func autoModeExpiredCreds_cliAvailable_returnsAvailable() async {
+ let context = self.makeContext(sourceMode: .auto)
+ let strategy = ClaudeOAuthFetchStrategy()
+ let available = await ClaudeOAuthFetchStrategy.$nonInteractiveCredentialRecordOverride
+ .withValue(self.expiredRecord()) {
+ await ClaudeOAuthFetchStrategy.$claudeCLIAvailableOverride.withValue(true) {
+ await strategy.isAvailable(context)
+ }
+ }
+ #expect(available == true)
+ }
+
@Test
- func keychainCLIStrategyAlwaysAvailable() async {
+ func autoModeExpiredCreds_cliUnavailable_returnsUnavailable() async {
let context = self.makeContext(sourceMode: .auto)
- let strategy = ClaudeKeychainCLIFetchStrategy()
- let available = await strategy.isAvailable(context)
+ let strategy = ClaudeOAuthFetchStrategy()
+ let available = await ClaudeOAuthFetchStrategy.$nonInteractiveCredentialRecordOverride
+ .withValue(self.expiredRecord()) {
+ await ClaudeOAuthFetchStrategy.$claudeCLIAvailableOverride.withValue(false) {
+ await strategy.isAvailable(context)
+ }
+ }
+ #expect(available == false)
+ }
+
+ @Test
+ func oauthModeExpiredCreds_cliAvailable_returnsAvailable() async {
+ let context = self.makeContext(sourceMode: .oauth)
+ let strategy = ClaudeOAuthFetchStrategy()
+ let available = await ClaudeOAuthFetchStrategy.$nonInteractiveCredentialRecordOverride
+ .withValue(self.expiredRecord()) {
+ await ClaudeOAuthFetchStrategy.$claudeCLIAvailableOverride.withValue(true) {
+ await strategy.isAvailable(context)
+ }
+ }
#expect(available == true)
}
@Test
- func keychainCLIStrategyNeverFallsBack() {
+ func autoModeExpiredCodexbarCreds_cliUnavailable_stillAvailable() async {
let context = self.makeContext(sourceMode: .auto)
- let strategy = ClaudeKeychainCLIFetchStrategy()
+ let strategy = ClaudeOAuthFetchStrategy()
+ let available = await ClaudeOAuthFetchStrategy.$nonInteractiveCredentialRecordOverride
+ .withValue(self.expiredRecord(owner: .codexbar)) {
+ await ClaudeOAuthFetchStrategy.$claudeCLIAvailableOverride.withValue(false) {
+ await strategy.isAvailable(context)
+ }
+ }
+ #expect(available == true)
+ }
+
+ @Test
+ func oauthModeDoesNotFallbackAfterOAuthFailure() {
+ let context = self.makeContext(sourceMode: .oauth)
+ let strategy = ClaudeOAuthFetchStrategy()
#expect(strategy.shouldFallback(
- on: ClaudeUsageError.oauthFailed("test"),
+ on: ClaudeUsageError.oauthFailed("oauth failed"),
context: context) == false)
}
+
+ @Test
+ func autoModeFallsBackAfterOAuthFailure() {
+ let context = self.makeContext(sourceMode: .auto)
+ let strategy = ClaudeOAuthFetchStrategy()
+ #expect(strategy.shouldFallback(
+ on: ClaudeUsageError.oauthFailed("oauth failed"),
+ context: context) == true)
+ }
+
+ @Test
+ func autoMode_userInitiated_clearsKeychainCooldownGate() async {
+ let context = self.makeContext(sourceMode: .auto)
+ let strategy = ClaudeOAuthFetchStrategy()
+ let recordWithoutRequiredScope = ClaudeOAuthCredentialRecord(
+ credentials: ClaudeOAuthCredentials(
+ accessToken: "expired-token",
+ refreshToken: "refresh-token",
+ expiresAt: Date(timeIntervalSinceNow: -60),
+ scopes: ["user:inference"],
+ rateLimitTier: nil),
+ owner: .claudeCLI,
+ source: .cacheKeychain)
+
+ await KeychainAccessGate.withTaskOverrideForTesting(false) {
+ ClaudeOAuthKeychainAccessGate.resetForTesting()
+ defer { ClaudeOAuthKeychainAccessGate.resetForTesting() }
+
+ let now = Date(timeIntervalSince1970: 1000)
+ ClaudeOAuthKeychainAccessGate.recordDenied(now: now)
+ #expect(ClaudeOAuthKeychainAccessGate.shouldAllowPrompt(now: now) == false)
+
+ _ = await ClaudeOAuthFetchStrategy.$nonInteractiveCredentialRecordOverride
+ .withValue(recordWithoutRequiredScope) {
+ await ProviderInteractionContext.$current.withValue(.userInitiated) {
+ await strategy.isAvailable(context)
+ }
+ }
+
+ #expect(ClaudeOAuthKeychainAccessGate.shouldAllowPrompt(now: now))
+ }
+ }
+
+ @Test
+ func autoMode_onlyOnUserAction_background_startup_withoutCache_isAvailableForBootstrap() async throws {
+ let context = self.makeContext(sourceMode: .auto)
+ let strategy = ClaudeOAuthFetchStrategy()
+ let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
+
+ try await KeychainCacheStore.withServiceOverrideForTesting(service) {
+ KeychainCacheStore.setTestStoreForTesting(true)
+ defer { KeychainCacheStore.setTestStoreForTesting(false) }
+
+ try await ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {
+ ClaudeOAuthCredentialsStore.invalidateCache()
+ ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
+ ClaudeOAuthKeychainAccessGate.resetForTesting()
+ defer {
+ ClaudeOAuthCredentialsStore.invalidateCache()
+ ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
+ ClaudeOAuthKeychainAccessGate.resetForTesting()
+ }
+
+ let tempDir = FileManager.default.temporaryDirectory
+ .appendingPathComponent(UUID().uuidString, isDirectory: true)
+ try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
+ let fileURL = tempDir.appendingPathComponent("credentials.json")
+
+ let available = await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
+ await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(.securityFramework) {
+ await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) {
+ await ProviderRefreshContext.$current.withValue(.startup) {
+ await ProviderInteractionContext.$current.withValue(.background) {
+ await strategy.isAvailable(context)
+ }
+ }
+ }
+ }
+ }
+
+ #expect(available == true)
+ }
+ }
+ }
+
+ @Test
+ func autoMode_experimental_reader_ignoresPromptPolicyCooldownGate() async {
+ let context = self.makeContext(sourceMode: .auto)
+ let strategy = ClaudeOAuthFetchStrategy()
+ let securityData = Data("""
+ {
+ "claudeAiOauth": {
+ "accessToken": "security-token",
+ "expiresAt": \(Int(Date(timeIntervalSinceNow: 3600).timeIntervalSince1970 * 1000)),
+ "scopes": ["user:profile"]
+ }
+ }
+ """.utf8)
+
+ let recordWithoutRequiredScope = ClaudeOAuthCredentialRecord(
+ credentials: ClaudeOAuthCredentials(
+ accessToken: "token-no-scope",
+ refreshToken: "refresh-token",
+ expiresAt: Date(timeIntervalSinceNow: -60),
+ scopes: ["user:inference"],
+ rateLimitTier: nil),
+ owner: .claudeCLI,
+ source: .cacheKeychain)
+
+ let available = await KeychainAccessGate.withTaskOverrideForTesting(false) {
+ await ClaudeOAuthKeychainAccessGate.withShouldAllowPromptOverrideForTesting(false) {
+ await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
+ .securityCLIExperimental)
+ {
+ await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) {
+ await ClaudeOAuthFetchStrategy.$nonInteractiveCredentialRecordOverride.withValue(
+ recordWithoutRequiredScope)
+ {
+ await ProviderInteractionContext.$current.withValue(.background) {
+ await ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(.data(
+ securityData))
+ {
+ await strategy.isAvailable(context)
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ #expect(available == true)
+ }
+
+ @Test
+ func autoMode_experimental_reader_securityFailure_blocksAvailabilityWhenStoredPolicyBlocksFallback() async {
+ let context = self.makeContext(sourceMode: .auto)
+ let strategy = ClaudeOAuthFetchStrategy()
+ let fallbackData = Data("""
+ {
+ "claudeAiOauth": {
+ "accessToken": "fallback-token",
+ "expiresAt": \(Int(Date(timeIntervalSinceNow: 3600).timeIntervalSince1970 * 1000)),
+ "scopes": ["user:profile"]
+ }
+ }
+ """.utf8)
+
+ let recordWithoutRequiredScope = ClaudeOAuthCredentialRecord(
+ credentials: ClaudeOAuthCredentials(
+ accessToken: "token-no-scope",
+ refreshToken: "refresh-token",
+ expiresAt: Date(timeIntervalSinceNow: -60),
+ scopes: ["user:inference"],
+ rateLimitTier: nil),
+ owner: .claudeCLI,
+ source: .cacheKeychain)
+
+ let available = await KeychainAccessGate.withTaskOverrideForTesting(false) {
+ await ClaudeOAuthKeychainAccessGate.withShouldAllowPromptOverrideForTesting(true) {
+ await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
+ .securityCLIExperimental)
+ {
+ await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) {
+ await ClaudeOAuthFetchStrategy.$nonInteractiveCredentialRecordOverride.withValue(
+ recordWithoutRequiredScope)
+ {
+ await ProviderInteractionContext.$current.withValue(.background) {
+ await ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting(
+ data: fallbackData,
+ fingerprint: nil)
+ {
+ await ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(
+ .nonZeroExit)
+ {
+ await strategy.isAvailable(context)
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ #expect(available == false)
+ }
}
#endif
diff --git a/Tests/CodexBarTests/ClaudeOAuthKeychainAccessGateTests.swift b/Tests/CodexBarTests/ClaudeOAuthKeychainAccessGateTests.swift
index e40052306..3854b7b12 100644
--- a/Tests/CodexBarTests/ClaudeOAuthKeychainAccessGateTests.swift
+++ b/Tests/CodexBarTests/ClaudeOAuthKeychainAccessGateTests.swift
@@ -7,17 +7,18 @@ struct ClaudeOAuthKeychainAccessGateTests {
@Test
func blocksUntilCooldownExpires() {
KeychainAccessGate.withTaskOverrideForTesting(false) {
- ClaudeOAuthKeychainAccessGate.resetForTesting()
- defer { ClaudeOAuthKeychainAccessGate.resetForTesting() }
-
- let now = Date(timeIntervalSince1970: 1000)
- #expect(ClaudeOAuthKeychainAccessGate.shouldAllowPrompt(now: now))
+ let store = ClaudeOAuthKeychainAccessGate.DeniedUntilStore()
+ ClaudeOAuthKeychainAccessGate.withDeniedUntilStoreOverrideForTesting(store) {
+ let now = Date(timeIntervalSince1970: 1000)
+ #expect(ClaudeOAuthKeychainAccessGate.shouldAllowPrompt(now: now))
- ClaudeOAuthKeychainAccessGate.recordDenied(now: now)
- #expect(ClaudeOAuthKeychainAccessGate.shouldAllowPrompt(now: now) == false)
- #expect(
- ClaudeOAuthKeychainAccessGate.shouldAllowPrompt(now: now.addingTimeInterval(60 * 60 * 6 - 1)) == false)
- #expect(ClaudeOAuthKeychainAccessGate.shouldAllowPrompt(now: now.addingTimeInterval(60 * 60 * 6 + 1)))
+ ClaudeOAuthKeychainAccessGate.recordDenied(now: now)
+ #expect(ClaudeOAuthKeychainAccessGate.shouldAllowPrompt(now: now) == false)
+ #expect(
+ ClaudeOAuthKeychainAccessGate.shouldAllowPrompt(now: now.addingTimeInterval(60 * 60 * 6 - 1))
+ == false)
+ #expect(ClaudeOAuthKeychainAccessGate.shouldAllowPrompt(now: now.addingTimeInterval(60 * 60 * 6 + 1)))
+ }
}
}
diff --git a/Tests/CodexBarTests/ClaudeOAuthTests.swift b/Tests/CodexBarTests/ClaudeOAuthTests.swift
index 2a7660bf5..478dcc6ef 100644
--- a/Tests/CodexBarTests/ClaudeOAuthTests.swift
+++ b/Tests/CodexBarTests/ClaudeOAuthTests.swift
@@ -244,26 +244,40 @@ struct ClaudeOAuthTests {
selectedDataSource: .auto,
webExtrasEnabled: false,
hasWebSession: true,
+ hasCLI: true,
hasOAuthCredentials: true)
#expect(strategy.dataSource == .oauth)
}
@Test
- func fallsBackToWebWhenOAuthMissing() {
+ func fallsBackToCLIWhenOAuthMissingAndCLIAvailable() {
let strategy = ClaudeProviderDescriptor.resolveUsageStrategy(
selectedDataSource: .auto,
webExtrasEnabled: false,
hasWebSession: true,
+ hasCLI: true,
+ hasOAuthCredentials: false)
+ #expect(strategy.dataSource == .cli)
+ }
+
+ @Test
+ func fallsBackToWebWhenOAuthMissingAndCLIMissing() {
+ let strategy = ClaudeProviderDescriptor.resolveUsageStrategy(
+ selectedDataSource: .auto,
+ webExtrasEnabled: false,
+ hasWebSession: true,
+ hasCLI: false,
hasOAuthCredentials: false)
#expect(strategy.dataSource == .web)
}
@Test
- func fallsBackToCLIWhenNoOAuthOrWeb() {
+ func fallsBackToCLIWhenOAuthMissingAndWebMissing() {
let strategy = ClaudeProviderDescriptor.resolveUsageStrategy(
selectedDataSource: .auto,
webExtrasEnabled: false,
hasWebSession: false,
+ hasCLI: true,
hasOAuthCredentials: false)
#expect(strategy.dataSource == .cli)
}
diff --git a/Tests/CodexBarTests/ClaudeUsageTests.swift b/Tests/CodexBarTests/ClaudeUsageTests.swift
index ddf6d715f..7f007b811 100644
--- a/Tests/CodexBarTests/ClaudeUsageTests.swift
+++ b/Tests/CodexBarTests/ClaudeUsageTests.swift
@@ -80,13 +80,20 @@ struct ClaudeUsageTests {
rateLimitTier: nil)
}
- let snapshot = try await ClaudeUsageFetcher.$fetchOAuthUsageOverride.withValue(fetchOverride, operation: {
- try await ClaudeUsageFetcher.$delegatedRefreshAttemptOverride.withValue(delegatedOverride, operation: {
- try await ClaudeUsageFetcher.$loadOAuthCredentialsOverride.withValue(loadCredsOverride, operation: {
- try await fetcher.loadLatestUsage(model: "sonnet")
+ let snapshot = try await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) {
+ try await ProviderInteractionContext.$current.withValue(.userInitiated) {
+ try await ClaudeUsageFetcher.$fetchOAuthUsageOverride.withValue(fetchOverride, operation: {
+ try await ClaudeUsageFetcher.$delegatedRefreshAttemptOverride.withValue(
+ delegatedOverride,
+ operation: {
+ try await ClaudeUsageFetcher.$loadOAuthCredentialsOverride
+ .withValue(loadCredsOverride, operation: {
+ try await fetcher.loadLatestUsage(model: "sonnet")
+ })
+ })
})
- })
- })
+ }
+ }
#expect(await loadCounter.current() == 2)
#expect(await delegatedCounter.current() == 1)
@@ -119,11 +126,19 @@ struct ClaudeUsageTests {
throw ClaudeOAuthCredentialsError.refreshDelegatedToClaudeCLI
}
- _ = try await ClaudeUsageFetcher.$delegatedRefreshAttemptOverride.withValue(delegatedOverride, operation: {
- try await ClaudeUsageFetcher.$loadOAuthCredentialsOverride.withValue(loadCredsOverride, operation: {
- try await fetcher.loadLatestUsage(model: "sonnet")
- })
- })
+ _ = try await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) {
+ try await ProviderInteractionContext.$current.withValue(.userInitiated) {
+ try await ClaudeUsageFetcher.$delegatedRefreshAttemptOverride.withValue(
+ delegatedOverride,
+ operation: {
+ try await ClaudeUsageFetcher.$loadOAuthCredentialsOverride.withValue(
+ loadCredsOverride,
+ operation: {
+ try await fetcher.loadLatestUsage(model: "sonnet")
+ })
+ })
+ }
+ }
Issue.record("Expected delegated retry to fail when credentials remain expired")
} catch let error as ClaudeUsageError {
guard case let .oauthFailed(message) = error else {
@@ -164,11 +179,19 @@ struct ClaudeUsageTests {
}
do {
- _ = try await ClaudeUsageFetcher.$delegatedRefreshAttemptOverride.withValue(delegatedOverride, operation: {
- try await ClaudeUsageFetcher.$loadOAuthCredentialsOverride.withValue(loadCredsOverride, operation: {
- try await fetcher.loadLatestUsage(model: "sonnet")
- })
- })
+ _ = try await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) {
+ try await ProviderInteractionContext.$current.withValue(.userInitiated) {
+ try await ClaudeUsageFetcher.$delegatedRefreshAttemptOverride.withValue(
+ delegatedOverride,
+ operation: {
+ try await ClaudeUsageFetcher.$loadOAuthCredentialsOverride.withValue(
+ loadCredsOverride,
+ operation: {
+ try await fetcher.loadLatestUsage(model: "sonnet")
+ })
+ })
+ }
+ }
Issue.record("Expected delegated retry to fail fast when CLI is unavailable")
} catch let error as ClaudeUsageError {
guard case let .oauthFailed(message) = error else {
@@ -225,21 +248,237 @@ struct ClaudeUsageTests {
rateLimitTier: nil)
}
- let snapshot = try await ClaudeUsageFetcher.$fetchOAuthUsageOverride.withValue(fetchOverride, operation: {
- try await ClaudeUsageFetcher.$delegatedRefreshAttemptOverride.withValue(delegatedOverride, operation: {
- try await ClaudeUsageFetcher.$loadOAuthCredentialsOverride.withValue(loadCredsOverride, operation: {
- try await fetcher.loadLatestUsage(model: "sonnet")
+ let snapshot = try await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) {
+ try await ProviderInteractionContext.$current.withValue(.userInitiated) {
+ try await ClaudeUsageFetcher.$fetchOAuthUsageOverride.withValue(fetchOverride, operation: {
+ try await ClaudeUsageFetcher.$delegatedRefreshAttemptOverride.withValue(
+ delegatedOverride,
+ operation: {
+ try await ClaudeUsageFetcher.$loadOAuthCredentialsOverride
+ .withValue(loadCredsOverride, operation: {
+ try await fetcher.loadLatestUsage(model: "sonnet")
+ })
+ })
})
- })
- })
+ }
+ }
#expect(await loadCounter.current() == 2)
#expect(await delegatedCounter.current() == 1)
#expect(snapshot.primary.usedPercent == 7)
- // Second call in Auto-mode must keep Keychain non-interactive (allowKeychainPrompt=false).
+ // User-initiated repair: if the delegated refresh couldn't sync silently, we may allow an interactive prompt
+ // on the retry to help recovery.
#expect(flags.allowKeychainPromptFlags.count == 2)
- #expect(flags.allowKeychainPromptFlags[1] == false)
+ #expect(flags.allowKeychainPromptFlags[1] == true)
+ }
+
+ @Test
+ func oauthDelegatedRetry_onlyOnUserAction_background_suppressesDelegation() async throws {
+ let loadCounter = AsyncCounter()
+ let delegatedCounter = AsyncCounter()
+
+ let fetcher = ClaudeUsageFetcher(
+ browserDetection: BrowserDetection(cacheTTL: 0),
+ environment: [:],
+ dataSource: .oauth,
+ oauthKeychainPromptCooldownEnabled: true)
+
+ let delegatedOverride: (@Sendable (
+ Date,
+ TimeInterval) async -> ClaudeOAuthDelegatedRefreshCoordinator.Outcome)? = { _, _ in
+ _ = await delegatedCounter.increment()
+ return .attemptedSucceeded
+ }
+ let loadCredsOverride: (@Sendable (
+ [String: String],
+ Bool,
+ Bool) async throws -> ClaudeOAuthCredentials)? = { _, _, _ in
+ _ = await loadCounter.increment()
+ throw ClaudeOAuthCredentialsError.refreshDelegatedToClaudeCLI
+ }
+
+ do {
+ _ = try await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) {
+ try await ProviderInteractionContext.$current.withValue(.background) {
+ try await ClaudeUsageFetcher.$delegatedRefreshAttemptOverride.withValue(delegatedOverride) {
+ try await ClaudeUsageFetcher.$loadOAuthCredentialsOverride.withValue(loadCredsOverride) {
+ try await fetcher.loadLatestUsage(model: "sonnet")
+ }
+ }
+ }
+ }
+ Issue.record("Expected delegated refresh to be suppressed in background")
+ } catch let error as ClaudeUsageError {
+ guard case let .oauthFailed(message) = error else {
+ Issue.record("Expected ClaudeUsageError.oauthFailed, got \(error)")
+ return
+ }
+ #expect(message.contains("background repair is suppressed"))
+ } catch {
+ Issue.record("Expected ClaudeUsageError, got \(error)")
+ }
+
+ #expect(await loadCounter.current() == 1)
+ #expect(await delegatedCounter.current() == 0)
+ }
+
+ @Test
+ func oauthDelegatedRetry_never_background_suppressesDelegationEvenForCLI() async throws {
+ let loadCounter = AsyncCounter()
+ let delegatedCounter = AsyncCounter()
+
+ let fetcher = ClaudeUsageFetcher(
+ browserDetection: BrowserDetection(cacheTTL: 0),
+ environment: [:],
+ dataSource: .oauth,
+ oauthKeychainPromptCooldownEnabled: true,
+ allowBackgroundDelegatedRefresh: true)
+
+ let delegatedOverride: (@Sendable (
+ Date,
+ TimeInterval) async -> ClaudeOAuthDelegatedRefreshCoordinator.Outcome)? = { _, _ in
+ _ = await delegatedCounter.increment()
+ return .attemptedSucceeded
+ }
+ let loadCredsOverride: (@Sendable (
+ [String: String],
+ Bool,
+ Bool) async throws -> ClaudeOAuthCredentials)? = { _, _, _ in
+ _ = await loadCounter.increment()
+ throw ClaudeOAuthCredentialsError.refreshDelegatedToClaudeCLI
+ }
+
+ do {
+ _ = try await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.never) {
+ try await ProviderInteractionContext.$current.withValue(.background) {
+ try await ClaudeUsageFetcher.$delegatedRefreshAttemptOverride.withValue(delegatedOverride) {
+ try await ClaudeUsageFetcher.$loadOAuthCredentialsOverride.withValue(loadCredsOverride) {
+ try await fetcher.loadLatestUsage(model: "sonnet")
+ }
+ }
+ }
+ }
+ Issue.record("Expected delegated refresh to be suppressed for prompt policy 'never'")
+ } catch let error as ClaudeUsageError {
+ guard case let .oauthFailed(message) = error else {
+ Issue.record("Expected ClaudeUsageError.oauthFailed, got \(error)")
+ return
+ }
+ #expect(message.contains("Delegated refresh is disabled by 'never' keychain policy"))
+ } catch {
+ Issue.record("Expected ClaudeUsageError, got \(error)")
+ }
+
+ #expect(await loadCounter.current() == 1)
+ #expect(await delegatedCounter.current() == 0)
+ }
+
+ @Test
+ func oauthBootstrap_onlyOnUserAction_background_startup_allowsInteractiveReadWhenNoCache() async throws {
+ final class FlagBox: @unchecked Sendable {
+ var allowKeychainPromptFlags: [Bool] = []
+ }
+
+ let flags = FlagBox()
+ let usageResponse = try Self.makeOAuthUsageResponse()
+ let fetcher = ClaudeUsageFetcher(
+ browserDetection: BrowserDetection(cacheTTL: 0),
+ environment: [:],
+ dataSource: .oauth,
+ oauthKeychainPromptCooldownEnabled: true,
+ allowStartupBootstrapPrompt: true)
+
+ let fetchOverride: (@Sendable (String) async throws -> OAuthUsageResponse)? = { _ in usageResponse }
+ let loadCredsOverride: (@Sendable (
+ [String: String],
+ Bool,
+ Bool) async throws -> ClaudeOAuthCredentials)? = { _, allowKeychainPrompt, _ in
+ flags.allowKeychainPromptFlags.append(allowKeychainPrompt)
+ return ClaudeOAuthCredentials(
+ accessToken: "fresh-token",
+ refreshToken: "refresh-token",
+ expiresAt: Date(timeIntervalSinceNow: 3600),
+ scopes: ["user:profile"],
+ rateLimitTier: nil)
+ }
+
+ let snapshot = try await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) {
+ try await ProviderRefreshContext.$current.withValue(.startup) {
+ try await ProviderInteractionContext.$current.withValue(.background) {
+ try await ClaudeUsageFetcher.$hasCachedCredentialsOverride.withValue(false) {
+ try await ClaudeUsageFetcher.$fetchOAuthUsageOverride.withValue(fetchOverride) {
+ try await ClaudeUsageFetcher.$loadOAuthCredentialsOverride.withValue(loadCredsOverride) {
+ try await fetcher.loadLatestUsage(model: "sonnet")
+ }
+ }
+ }
+ }
+ }
+ }
+
+ #expect(flags.allowKeychainPromptFlags == [true])
+ #expect(snapshot.primary.usedPercent == 7)
+ }
+
+ @Test
+ func oauthDelegatedRetry_onlyOnUserAction_background_allowsDelegationForCLI() async throws {
+ let loadCounter = AsyncCounter()
+ let delegatedCounter = AsyncCounter()
+ let usageResponse = try Self.makeOAuthUsageResponse()
+
+ final class FlagBox: @unchecked Sendable {
+ var allowKeychainPromptFlags: [Bool] = []
+ }
+ let flags = FlagBox()
+
+ let fetcher = ClaudeUsageFetcher(
+ browserDetection: BrowserDetection(cacheTTL: 0),
+ environment: [:],
+ dataSource: .oauth,
+ oauthKeychainPromptCooldownEnabled: false,
+ allowBackgroundDelegatedRefresh: true)
+
+ let fetchOverride: (@Sendable (String) async throws -> OAuthUsageResponse)? = { _ in usageResponse }
+ let delegatedOverride: (@Sendable (
+ Date,
+ TimeInterval) async -> ClaudeOAuthDelegatedRefreshCoordinator.Outcome)? = { _, _ in
+ _ = await delegatedCounter.increment()
+ return .attemptedSucceeded
+ }
+ let loadCredsOverride: (@Sendable (
+ [String: String],
+ Bool,
+ Bool) async throws -> ClaudeOAuthCredentials)? = { _, allowKeychainPrompt, _ in
+ flags.allowKeychainPromptFlags.append(allowKeychainPrompt)
+ let call = await loadCounter.increment()
+ if call == 1 {
+ throw ClaudeOAuthCredentialsError.refreshDelegatedToClaudeCLI
+ }
+ return ClaudeOAuthCredentials(
+ accessToken: "fresh-token",
+ refreshToken: "refresh-token",
+ expiresAt: Date(timeIntervalSinceNow: 3600),
+ scopes: ["user:profile"],
+ rateLimitTier: nil)
+ }
+
+ let snapshot = try await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) {
+ try await ProviderInteractionContext.$current.withValue(.background) {
+ try await ClaudeUsageFetcher.$fetchOAuthUsageOverride.withValue(fetchOverride) {
+ try await ClaudeUsageFetcher.$delegatedRefreshAttemptOverride.withValue(delegatedOverride) {
+ try await ClaudeUsageFetcher.$loadOAuthCredentialsOverride.withValue(loadCredsOverride) {
+ try await fetcher.loadLatestUsage(model: "sonnet")
+ }
+ }
+ }
+ }
+ }
+
+ #expect(await loadCounter.current() == 2)
+ #expect(await delegatedCounter.current() == 1)
+ #expect(snapshot.primary.usedPercent == 7)
+ #expect(flags.allowKeychainPromptFlags.allSatisfy { !$0 })
}
@Test
@@ -616,3 +855,105 @@ struct ClaudeUsageTests {
#expect(cliVersion?.isEmpty != true)
}
}
+
+extension ClaudeUsageTests {
+ @Test
+ func oauthDelegatedRetry_experimental_background_ignoresOnlyOnUserActionSuppression() async throws {
+ let loadCounter = AsyncCounter()
+ let delegatedCounter = AsyncCounter()
+ let usageResponse = try Self.makeOAuthUsageResponse()
+
+ let fetcher = ClaudeUsageFetcher(
+ browserDetection: BrowserDetection(cacheTTL: 0),
+ environment: [:],
+ dataSource: .oauth,
+ oauthKeychainPromptCooldownEnabled: true,
+ allowBackgroundDelegatedRefresh: false)
+
+ let fetchOverride: (@Sendable (String) async throws -> OAuthUsageResponse)? = { _ in usageResponse }
+ let delegatedOverride: (@Sendable (
+ Date,
+ TimeInterval) async -> ClaudeOAuthDelegatedRefreshCoordinator.Outcome)? = { _, _ in
+ _ = await delegatedCounter.increment()
+ return .attemptedSucceeded
+ }
+ let loadCredsOverride: (@Sendable (
+ [String: String],
+ Bool,
+ Bool) async throws -> ClaudeOAuthCredentials)? = { _, _, _ in
+ let call = await loadCounter.increment()
+ if call == 1 {
+ throw ClaudeOAuthCredentialsError.refreshDelegatedToClaudeCLI
+ }
+ return ClaudeOAuthCredentials(
+ accessToken: "fresh-token",
+ refreshToken: "refresh-token",
+ expiresAt: Date(timeIntervalSinceNow: 3600),
+ scopes: ["user:profile"],
+ rateLimitTier: nil)
+ }
+
+ let snapshot = try await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
+ .securityCLIExperimental,
+ operation: {
+ try await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) {
+ try await ProviderInteractionContext.$current.withValue(.background) {
+ try await ClaudeUsageFetcher.$hasCachedCredentialsOverride.withValue(true) {
+ try await ClaudeUsageFetcher.$fetchOAuthUsageOverride.withValue(fetchOverride) {
+ try await ClaudeUsageFetcher.$delegatedRefreshAttemptOverride.withValue(
+ delegatedOverride)
+ {
+ try await ClaudeUsageFetcher.$loadOAuthCredentialsOverride.withValue(
+ loadCredsOverride)
+ {
+ try await fetcher.loadLatestUsage(model: "sonnet")
+ }
+ }
+ }
+ }
+ }
+ }
+ })
+
+ #expect(await loadCounter.current() == 2)
+ #expect(await delegatedCounter.current() == 1)
+ #expect(snapshot.primary.usedPercent == 7)
+ }
+
+ @Test
+ func oauthLoad_experimental_background_fallbackBlocked_propagatesOAuthFailure() async throws {
+ final class FlagBox: @unchecked Sendable {
+ var respectPromptCooldownFlags: [Bool] = []
+ }
+ let flags = FlagBox()
+ let fetcher = ClaudeUsageFetcher(
+ browserDetection: BrowserDetection(cacheTTL: 0),
+ environment: [:],
+ dataSource: .oauth,
+ oauthKeychainPromptCooldownEnabled: true,
+ allowBackgroundDelegatedRefresh: false)
+
+ let loadCredsOverride: (@Sendable (
+ [String: String],
+ Bool,
+ Bool) async throws -> ClaudeOAuthCredentials)? = { _, _, respectKeychainPromptCooldown in
+ flags.respectPromptCooldownFlags.append(respectKeychainPromptCooldown)
+ throw ClaudeOAuthCredentialsError.notFound
+ }
+
+ await #expect(throws: ClaudeUsageError.self) {
+ try await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
+ .securityCLIExperimental,
+ operation: {
+ try await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) {
+ try await ProviderInteractionContext.$current.withValue(.background) {
+ try await ClaudeUsageFetcher.$loadOAuthCredentialsOverride.withValue(loadCredsOverride) {
+ try await fetcher.loadLatestUsage(model: "sonnet")
+ }
+ }
+ }
+ })
+ }
+ #expect(flags.respectPromptCooldownFlags == [true])
+ }
+}
diff --git a/Tests/CodexBarTests/CodexbarTests.swift b/Tests/CodexBarTests/CodexbarTests.swift
index 4b1fecfe5..737081635 100644
--- a/Tests/CodexBarTests/CodexbarTests.swift
+++ b/Tests/CodexBarTests/CodexbarTests.swift
@@ -115,6 +115,59 @@ struct CodexBarTests {
#expect(internalHoles >= 16) // at least one 4×4 eye block, but typically two eyes => 32
}
+ @Test
+ func iconRendererWarpEyesCutOutAtExpectedCenters() {
+ // Regression: Warp eyes should be tilted in-place and remain centered on the face.
+ let image = IconRenderer.makeIcon(
+ primaryRemaining: 50,
+ weeklyRemaining: 50,
+ creditsRemaining: nil,
+ stale: false,
+ style: .warp)
+
+ let bitmapReps = image.representations.compactMap { $0 as? NSBitmapImageRep }
+ let rep = bitmapReps.first(where: { $0.pixelsWide == 36 && $0.pixelsHigh == 36 })
+ #expect(rep != nil)
+ guard let rep else { return }
+
+ func alphaAt(px x: Int, _ y: Int) -> CGFloat {
+ (rep.colorAt(x: x, y: y) ?? .clear).alphaComponent
+ }
+
+ func minAlphaNear(px cx: Int, _ cy: Int, radius: Int) -> CGFloat {
+ var minAlpha: CGFloat = 1.0
+ let x0 = max(0, cx - radius)
+ let x1 = min(rep.pixelsWide - 1, cx + radius)
+ let y0 = max(0, cy - radius)
+ let y1 = min(rep.pixelsHigh - 1, cy + radius)
+ for y in y0...y1 {
+ for x in x0...x1 {
+ minAlpha = min(minAlpha, alphaAt(px: x, y))
+ }
+ }
+ return minAlpha
+ }
+
+ func minAlphaNearEitherOrigin(px cx: Int, _ cy: Int, radius: Int) -> CGFloat {
+ let flippedY = (rep.pixelsHigh - 1) - cy
+ return min(minAlphaNear(px: cx, cy, radius: radius), minAlphaNear(px: cx, flippedY, radius: radius))
+ }
+
+ // These are the center pixels for the two Warp eye cutouts in the top bar (36×36 canvas).
+ // If the eyes are rotated around the wrong origin, these points will not be fully punched out.
+ let leftEyeCenter = (x: 11, y: 25)
+ let rightEyeCenter = (x: 25, y: 25)
+
+ // The eye ellipse height is even (8 px), so the exact center can land between pixel rows.
+ // Assert via a small neighborhood search rather than a single pixel.
+ #expect(minAlphaNearEitherOrigin(px: leftEyeCenter.x, leftEyeCenter.y, radius: 2) < 0.05)
+ #expect(minAlphaNearEitherOrigin(px: rightEyeCenter.x, rightEyeCenter.y, radius: 2) < 0.05)
+
+ // Sanity: nearby top bar track area should remain visible (not everything is transparent).
+ let midAlpha = max(alphaAt(px: 18, 25), alphaAt(px: 18, (rep.pixelsHigh - 1) - 25))
+ #expect(midAlpha > 0.05)
+ }
+
@Test
func accountInfoParsesAuthToken() throws {
let tmp = try FileManager.default.url(
diff --git a/Tests/CodexBarTests/ConfigValidationTests.swift b/Tests/CodexBarTests/ConfigValidationTests.swift
index d076b3185..ca0363a85 100644
--- a/Tests/CodexBarTests/ConfigValidationTests.swift
+++ b/Tests/CodexBarTests/ConfigValidationTests.swift
@@ -39,4 +39,16 @@ struct ConfigValidationTests {
let issues = CodexBarConfigValidator.validate(config)
#expect(issues.contains(where: { $0.code == "token_accounts_unused" }))
}
+
+ @Test
+ func allowsOllamaTokenAccounts() {
+ let accounts = ProviderTokenAccountData(
+ version: 1,
+ accounts: [ProviderTokenAccount(id: UUID(), label: "a", token: "t", addedAt: 0, lastUsed: nil)],
+ activeIndex: 0)
+ var config = CodexBarConfig.makeDefault()
+ config.setProviderConfig(ProviderConfig(id: .ollama, tokenAccounts: accounts))
+ let issues = CodexBarConfigValidator.validate(config)
+ #expect(!issues.contains(where: { $0.code == "token_accounts_unused" && $0.provider == .ollama }))
+ }
}
diff --git a/Tests/CodexBarTests/CostUsagePricingTests.swift b/Tests/CodexBarTests/CostUsagePricingTests.swift
index 695a4843a..edef3a885 100644
--- a/Tests/CodexBarTests/CostUsagePricingTests.swift
+++ b/Tests/CodexBarTests/CostUsagePricingTests.swift
@@ -36,6 +36,17 @@ struct CostUsagePricingTests {
#expect(cost != nil)
}
+ @Test
+ func claudeCostSupportsOpus46DatedVariant() {
+ let cost = CostUsagePricing.claudeCostUSD(
+ model: "claude-opus-4-6-20260205",
+ inputTokens: 10,
+ cacheReadInputTokens: 0,
+ cacheCreationInputTokens: 0,
+ outputTokens: 5)
+ #expect(cost != nil)
+ }
+
@Test
func claudeCostReturnsNilForUnknownModels() {
let cost = CostUsagePricing.claudeCostUSD(
diff --git a/Tests/CodexBarTests/KiroStatusProbeTests.swift b/Tests/CodexBarTests/KiroStatusProbeTests.swift
index 03e6fac83..e1b35be14 100644
--- a/Tests/CodexBarTests/KiroStatusProbeTests.swift
+++ b/Tests/CodexBarTests/KiroStatusProbeTests.swift
@@ -122,6 +122,87 @@ struct KiroStatusProbeTests {
}
}
+ // MARK: - New Format (kiro-cli 1.24+, Q Developer)
+
+ @Test
+ func parsesQDeveloperManagedPlan() throws {
+ let output = """
+ Plan: Q Developer Pro
+ Your plan is managed by admin
+
+ Tip: to see context window usage, run /context
+ """
+
+ let probe = KiroStatusProbe()
+ let snapshot = try probe.parse(output: output)
+
+ #expect(snapshot.planName == "Q Developer Pro")
+ #expect(snapshot.creditsPercent == 0)
+ #expect(snapshot.creditsUsed == 0)
+ #expect(snapshot.creditsTotal == 0)
+ #expect(snapshot.bonusCreditsUsed == nil)
+ #expect(snapshot.resetsAt == nil)
+ }
+
+ @Test
+ func parsesQDeveloperFreePlan() throws {
+ let output = """
+ Plan: Q Developer Free
+ Your plan is managed by admin
+ """
+
+ let probe = KiroStatusProbe()
+ let snapshot = try probe.parse(output: output)
+
+ #expect(snapshot.planName == "Q Developer Free")
+ #expect(snapshot.creditsPercent == 0)
+ }
+
+ @Test
+ func parsesNewFormatWithANSICodes() throws {
+ let output = """
+ \u{001B}[38;5;141mPlan: Q Developer Pro\u{001B}[0m
+ Your plan is managed by admin
+ """
+
+ let probe = KiroStatusProbe()
+ let snapshot = try probe.parse(output: output)
+
+ #expect(snapshot.planName == "Q Developer Pro")
+ }
+
+ @Test
+ func rejectsHeaderOnlyNewFormatWithoutManagedMarker() {
+ let output = """
+ Plan: Q Developer Pro
+ Tip: to see context window usage, run /context
+ """
+
+ let probe = KiroStatusProbe()
+ #expect(throws: KiroStatusProbeError.self) {
+ try probe.parse(output: output)
+ }
+ }
+
+ @Test
+ func preservesParsedUsageForManagedPlanWithMetrics() throws {
+ let output = """
+ Plan: Q Developer Enterprise
+ Your plan is managed by admin
+ ████████████████████████████████████████████████████ 40%
+ (20.00 of 50 covered in plan), resets on 03/15
+ """
+
+ let probe = KiroStatusProbe()
+ let snapshot = try probe.parse(output: output)
+
+ #expect(snapshot.planName == "Q Developer Enterprise")
+ #expect(snapshot.creditsPercent == 40)
+ #expect(snapshot.creditsUsed == 20)
+ #expect(snapshot.creditsTotal == 50)
+ #expect(snapshot.resetsAt != nil)
+ }
+
// MARK: - Snapshot Conversion
@Test
diff --git a/Tests/CodexBarTests/MenuCardModelTests.swift b/Tests/CodexBarTests/MenuCardModelTests.swift
index a735f6a19..1df1bb3bb 100644
--- a/Tests/CodexBarTests/MenuCardModelTests.swift
+++ b/Tests/CodexBarTests/MenuCardModelTests.swift
@@ -391,6 +391,133 @@ struct MenuCardModelTests {
#expect(model.providerCost == nil)
}
+ @Test
+ @MainActor
+ func openRouterModel_usesAPIKeyQuotaBarAndQuotaDetail() throws {
+ let now = Date()
+ let metadata = try #require(ProviderDefaults.metadata[.openrouter])
+ let snapshot = OpenRouterUsageSnapshot(
+ totalCredits: 50,
+ totalUsage: 45.3895596325,
+ balance: 4.6104403675,
+ usedPercent: 90.779119265,
+ keyLimit: 20,
+ keyUsage: 0.5,
+ rateLimit: nil,
+ updatedAt: now).toUsageSnapshot()
+
+ let model = UsageMenuCardView.Model.make(.init(
+ provider: .openrouter,
+ metadata: metadata,
+ snapshot: snapshot,
+ credits: nil,
+ creditsError: nil,
+ dashboard: nil,
+ dashboardError: nil,
+ tokenSnapshot: nil,
+ tokenError: nil,
+ account: AccountInfo(email: nil, plan: nil),
+ isRefreshing: false,
+ lastError: nil,
+ usageBarsShowUsed: false,
+ resetTimeDisplayStyle: .countdown,
+ tokenCostUsageEnabled: false,
+ showOptionalCreditsAndExtraUsage: true,
+ hidePersonalInfo: false,
+ now: now))
+
+ #expect(model.creditsText == nil)
+ #expect(model.metrics.count == 1)
+ #expect(model.usageNotes.isEmpty)
+ let metric = try #require(model.metrics.first)
+ let popupTitle = UsageMenuCardView.popupMetricTitle(
+ provider: .openrouter,
+ metric: metric)
+ #expect(popupTitle == "API key limit")
+ #expect(metric.resetText == "$19.50/$20.00 left")
+ #expect(metric.detailRightText == nil)
+ }
+
+ @Test
+ func openRouterModel_withoutKeyLimitShowsTextOnlySummary() throws {
+ let now = Date()
+ let metadata = try #require(ProviderDefaults.metadata[.openrouter])
+ let snapshot = OpenRouterUsageSnapshot(
+ totalCredits: 50,
+ totalUsage: 45.3895596325,
+ balance: 4.6104403675,
+ usedPercent: 90.779119265,
+ keyDataFetched: true,
+ keyLimit: nil,
+ keyUsage: nil,
+ rateLimit: nil,
+ updatedAt: now).toUsageSnapshot()
+
+ let model = UsageMenuCardView.Model.make(.init(
+ provider: .openrouter,
+ metadata: metadata,
+ snapshot: snapshot,
+ credits: nil,
+ creditsError: nil,
+ dashboard: nil,
+ dashboardError: nil,
+ tokenSnapshot: nil,
+ tokenError: nil,
+ account: AccountInfo(email: nil, plan: nil),
+ isRefreshing: false,
+ lastError: nil,
+ usageBarsShowUsed: false,
+ resetTimeDisplayStyle: .countdown,
+ tokenCostUsageEnabled: false,
+ showOptionalCreditsAndExtraUsage: true,
+ hidePersonalInfo: false,
+ now: now))
+
+ #expect(model.metrics.isEmpty)
+ #expect(model.creditsText == nil)
+ #expect(model.placeholder == nil)
+ #expect(model.usageNotes == ["No limit set for the API key"])
+ }
+
+ @Test
+ func openRouterModel_whenKeyFetchUnavailableShowsUnavailableNote() throws {
+ let now = Date()
+ let metadata = try #require(ProviderDefaults.metadata[.openrouter])
+ let snapshot = OpenRouterUsageSnapshot(
+ totalCredits: 50,
+ totalUsage: 45.3895596325,
+ balance: 4.6104403675,
+ usedPercent: 90.779119265,
+ keyDataFetched: false,
+ keyLimit: nil,
+ keyUsage: nil,
+ rateLimit: nil,
+ updatedAt: now).toUsageSnapshot()
+
+ let model = UsageMenuCardView.Model.make(.init(
+ provider: .openrouter,
+ metadata: metadata,
+ snapshot: snapshot,
+ credits: nil,
+ creditsError: nil,
+ dashboard: nil,
+ dashboardError: nil,
+ tokenSnapshot: nil,
+ tokenError: nil,
+ account: AccountInfo(email: nil, plan: nil),
+ isRefreshing: false,
+ lastError: nil,
+ usageBarsShowUsed: false,
+ resetTimeDisplayStyle: .countdown,
+ tokenCostUsageEnabled: false,
+ showOptionalCreditsAndExtraUsage: true,
+ hidePersonalInfo: false,
+ now: now))
+
+ #expect(model.metrics.isEmpty)
+ #expect(model.usageNotes == ["API key limit unavailable right now"])
+ }
+
@Test
func hidesEmailWhenPersonalInfoHidden() throws {
let now = Date()
@@ -432,4 +559,49 @@ struct MenuCardModelTests {
#expect(model.creditsHintCopyText?.isEmpty == true)
#expect(model.creditsHintText?.contains("codex@example.com") == false)
}
+
+ @Test
+ func warpModelShowsPrimaryDetailWhenResetDateMissing() throws {
+ let now = Date()
+ let identity = ProviderIdentitySnapshot(
+ providerID: .warp,
+ accountEmail: nil,
+ accountOrganization: nil,
+ loginMethod: nil)
+ let snapshot = UsageSnapshot(
+ primary: RateWindow(
+ usedPercent: 10,
+ windowMinutes: nil,
+ resetsAt: nil,
+ resetDescription: "10/100 credits"),
+ secondary: nil,
+ tertiary: nil,
+ updatedAt: now,
+ identity: identity)
+ let metadata = try #require(ProviderDefaults.metadata[.warp])
+
+ let model = UsageMenuCardView.Model.make(.init(
+ provider: .warp,
+ metadata: metadata,
+ snapshot: snapshot,
+ credits: nil,
+ creditsError: nil,
+ dashboard: nil,
+ dashboardError: nil,
+ tokenSnapshot: nil,
+ tokenError: nil,
+ account: AccountInfo(email: nil, plan: nil),
+ isRefreshing: false,
+ lastError: nil,
+ usageBarsShowUsed: true,
+ resetTimeDisplayStyle: .countdown,
+ tokenCostUsageEnabled: false,
+ showOptionalCreditsAndExtraUsage: true,
+ hidePersonalInfo: false,
+ now: now))
+
+ let primary = try #require(model.metrics.first)
+ #expect(primary.resetText == nil)
+ #expect(primary.detailText == "10/100 credits")
+ }
}
diff --git a/Tests/CodexBarTests/MiniMaxAPITokenFetchTests.swift b/Tests/CodexBarTests/MiniMaxAPITokenFetchTests.swift
new file mode 100644
index 000000000..e427484f8
--- /dev/null
+++ b/Tests/CodexBarTests/MiniMaxAPITokenFetchTests.swift
@@ -0,0 +1,177 @@
+import CodexBarCore
+import Foundation
+import Testing
+
+@Suite(.serialized)
+struct MiniMaxAPITokenFetchTests {
+ @Test
+ func retriesChinaHostWhenGlobalRejectsToken() async throws {
+ let registered = URLProtocol.registerClass(MiniMaxAPITokenStubURLProtocol.self)
+ defer {
+ if registered {
+ URLProtocol.unregisterClass(MiniMaxAPITokenStubURLProtocol.self)
+ }
+ MiniMaxAPITokenStubURLProtocol.handler = nil
+ MiniMaxAPITokenStubURLProtocol.requests = []
+ }
+
+ MiniMaxAPITokenStubURLProtocol.handler = { request in
+ guard let url = request.url else { throw URLError(.badURL) }
+ let host = url.host ?? ""
+ if host == "api.minimax.io" {
+ return Self.makeResponse(url: url, body: "{}", statusCode: 401)
+ }
+ if host == "api.minimaxi.com" {
+ let start = 1_700_000_000_000
+ let end = start + 5 * 60 * 60 * 1000
+ let body = """
+ {
+ "base_resp": { "status_code": 0 },
+ "current_subscribe_title": "Max",
+ "model_remains": [
+ {
+ "current_interval_total_count": 1000,
+ "current_interval_usage_count": 250,
+ "start_time": \(start),
+ "end_time": \(end),
+ "remains_time": 240000
+ }
+ ]
+ }
+ """
+ return Self.makeResponse(url: url, body: body)
+ }
+ return Self.makeResponse(url: url, body: "{}", statusCode: 404)
+ }
+
+ let now = Date(timeIntervalSince1970: 1_700_000_000)
+ let snapshot = try await MiniMaxUsageFetcher.fetchUsage(apiToken: "sk-cp-test", region: .global, now: now)
+
+ #expect(snapshot.planName == "Max")
+ #expect(MiniMaxAPITokenStubURLProtocol.requests.count == 2)
+ #expect(MiniMaxAPITokenStubURLProtocol.requests.first?.url?.host == "api.minimax.io")
+ #expect(MiniMaxAPITokenStubURLProtocol.requests.last?.url?.host == "api.minimaxi.com")
+ }
+
+ @Test
+ func preservesInvalidCredentialsWhenChinaRetryFailsTransport() async throws {
+ let registered = URLProtocol.registerClass(MiniMaxAPITokenStubURLProtocol.self)
+ defer {
+ if registered {
+ URLProtocol.unregisterClass(MiniMaxAPITokenStubURLProtocol.self)
+ }
+ MiniMaxAPITokenStubURLProtocol.handler = nil
+ MiniMaxAPITokenStubURLProtocol.requests = []
+ }
+
+ MiniMaxAPITokenStubURLProtocol.handler = { request in
+ guard let url = request.url else { throw URLError(.badURL) }
+ let host = url.host ?? ""
+ if host == "api.minimax.io" {
+ return Self.makeResponse(url: url, body: "{}", statusCode: 401)
+ }
+ if host == "api.minimaxi.com" {
+ throw URLError(.cannotFindHost)
+ }
+ return Self.makeResponse(url: url, body: "{}", statusCode: 404)
+ }
+
+ let now = Date(timeIntervalSince1970: 1_700_000_000)
+ await #expect(throws: MiniMaxUsageError.invalidCredentials) {
+ _ = try await MiniMaxUsageFetcher.fetchUsage(apiToken: "sk-cp-test", region: .global, now: now)
+ }
+
+ #expect(MiniMaxAPITokenStubURLProtocol.requests.count == 2)
+ #expect(MiniMaxAPITokenStubURLProtocol.requests.first?.url?.host == "api.minimax.io")
+ #expect(MiniMaxAPITokenStubURLProtocol.requests.last?.url?.host == "api.minimaxi.com")
+ }
+
+ @Test
+ func doesNotRetryWhenRegionIsChinaMainland() async throws {
+ let registered = URLProtocol.registerClass(MiniMaxAPITokenStubURLProtocol.self)
+ defer {
+ if registered {
+ URLProtocol.unregisterClass(MiniMaxAPITokenStubURLProtocol.self)
+ }
+ MiniMaxAPITokenStubURLProtocol.handler = nil
+ MiniMaxAPITokenStubURLProtocol.requests = []
+ }
+
+ MiniMaxAPITokenStubURLProtocol.handler = { request in
+ guard let url = request.url else { throw URLError(.badURL) }
+ let host = url.host ?? ""
+ if host == "api.minimaxi.com" {
+ let start = 1_700_000_000_000
+ let end = start + 5 * 60 * 60 * 1000
+ let body = """
+ {
+ "base_resp": { "status_code": 0 },
+ "current_subscribe_title": "Max",
+ "model_remains": [
+ {
+ "current_interval_total_count": 1000,
+ "current_interval_usage_count": 250,
+ "start_time": \(start),
+ "end_time": \(end),
+ "remains_time": 240000
+ }
+ ]
+ }
+ """
+ return Self.makeResponse(url: url, body: body)
+ }
+ return Self.makeResponse(url: url, body: "{}", statusCode: 401)
+ }
+
+ let now = Date(timeIntervalSince1970: 1_700_000_000)
+ _ = try await MiniMaxUsageFetcher.fetchUsage(apiToken: "sk-cp-test", region: .chinaMainland, now: now)
+
+ #expect(MiniMaxAPITokenStubURLProtocol.requests.count == 1)
+ #expect(MiniMaxAPITokenStubURLProtocol.requests.first?.url?.host == "api.minimaxi.com")
+ }
+
+ private static func makeResponse(
+ url: URL,
+ body: String,
+ statusCode: Int = 200) -> (HTTPURLResponse, Data)
+ {
+ let response = HTTPURLResponse(
+ url: url,
+ statusCode: statusCode,
+ httpVersion: "HTTP/1.1",
+ headerFields: ["Content-Type": "application/json"])!
+ return (response, Data(body.utf8))
+ }
+}
+
+final class MiniMaxAPITokenStubURLProtocol: URLProtocol {
+ nonisolated(unsafe) static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))?
+ nonisolated(unsafe) static var requests: [URLRequest] = []
+
+ override static func canInit(with request: URLRequest) -> Bool {
+ guard let host = request.url?.host else { return false }
+ return host == "api.minimax.io" || host == "api.minimaxi.com"
+ }
+
+ override static func canonicalRequest(for request: URLRequest) -> URLRequest {
+ request
+ }
+
+ override func startLoading() {
+ Self.requests.append(self.request)
+ guard let handler = Self.handler else {
+ self.client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse))
+ return
+ }
+ do {
+ let (response, data) = try handler(self.request)
+ self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
+ self.client?.urlProtocol(self, didLoad: data)
+ self.client?.urlProtocolDidFinishLoading(self)
+ } catch {
+ self.client?.urlProtocol(self, didFailWithError: error)
+ }
+ }
+
+ override func stopLoading() {}
+}
diff --git a/Tests/CodexBarTests/OllamaUsageFetcherRetryMappingTests.swift b/Tests/CodexBarTests/OllamaUsageFetcherRetryMappingTests.swift
new file mode 100644
index 000000000..ceff20813
--- /dev/null
+++ b/Tests/CodexBarTests/OllamaUsageFetcherRetryMappingTests.swift
@@ -0,0 +1,82 @@
+import Foundation
+import Testing
+@testable import CodexBarCore
+
+@Suite(.serialized)
+struct OllamaUsageFetcherRetryMappingTests {
+ @Test
+ func missingUsageShapeSurfacesPublicParseFailedMessage() async {
+ defer { OllamaRetryMappingStubURLProtocol.handler = nil }
+
+ OllamaRetryMappingStubURLProtocol.handler = { request in
+ guard let url = request.url else { throw URLError(.badURL) }
+ let body = "No usage data rendered."
+ return Self.makeResponse(url: url, body: body, statusCode: 200)
+ }
+
+ let fetcher = OllamaUsageFetcher(
+ browserDetection: BrowserDetection(cacheTTL: 0),
+ makeURLSession: { delegate in
+ let config = URLSessionConfiguration.ephemeral
+ config.protocolClasses = [OllamaRetryMappingStubURLProtocol.self]
+ return URLSession(configuration: config, delegate: delegate, delegateQueue: nil)
+ })
+ do {
+ _ = try await fetcher.fetch(
+ cookieHeaderOverride: "session=test-cookie",
+ manualCookieMode: true)
+ Issue.record("Expected OllamaUsageError.parseFailed")
+ } catch let error as OllamaUsageError {
+ guard case let .parseFailed(message) = error else {
+ Issue.record("Expected parseFailed, got \(error)")
+ return
+ }
+ #expect(message == "Missing Ollama usage data.")
+ } catch {
+ Issue.record("Expected OllamaUsageError.parseFailed, got \(error)")
+ }
+ }
+
+ private static func makeResponse(
+ url: URL,
+ body: String,
+ statusCode: Int) -> (HTTPURLResponse, Data)
+ {
+ let response = HTTPURLResponse(
+ url: url,
+ statusCode: statusCode,
+ httpVersion: "HTTP/1.1",
+ headerFields: ["Content-Type": "text/html"])!
+ return (response, Data(body.utf8))
+ }
+}
+
+final class OllamaRetryMappingStubURLProtocol: URLProtocol {
+ nonisolated(unsafe) static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))?
+
+ override static func canInit(with request: URLRequest) -> Bool {
+ guard let host = request.url?.host?.lowercased() else { return false }
+ return host == "ollama.com" || host == "www.ollama.com"
+ }
+
+ override static func canonicalRequest(for request: URLRequest) -> URLRequest {
+ request
+ }
+
+ override func startLoading() {
+ guard let handler = Self.handler else {
+ self.client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse))
+ return
+ }
+ do {
+ let (response, data) = try handler(self.request)
+ self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
+ self.client?.urlProtocol(self, didLoad: data)
+ self.client?.urlProtocolDidFinishLoading(self)
+ } catch {
+ self.client?.urlProtocol(self, didFailWithError: error)
+ }
+ }
+
+ override func stopLoading() {}
+}
diff --git a/Tests/CodexBarTests/OllamaUsageFetcherTests.swift b/Tests/CodexBarTests/OllamaUsageFetcherTests.swift
new file mode 100644
index 000000000..8020638f8
--- /dev/null
+++ b/Tests/CodexBarTests/OllamaUsageFetcherTests.swift
@@ -0,0 +1,204 @@
+import Foundation
+import Testing
+@testable import CodexBarCore
+
+@Suite
+struct OllamaUsageFetcherTests {
+ @Test
+ func attachesCookieForOllamaHosts() {
+ #expect(OllamaUsageFetcher.shouldAttachCookie(to: URL(string: "https://ollama.com/settings")))
+ #expect(OllamaUsageFetcher.shouldAttachCookie(to: URL(string: "https://www.ollama.com")))
+ #expect(OllamaUsageFetcher.shouldAttachCookie(to: URL(string: "https://app.ollama.com/path")))
+ }
+
+ @Test
+ func rejectsNonOllamaHosts() {
+ #expect(!OllamaUsageFetcher.shouldAttachCookie(to: URL(string: "https://example.com")))
+ #expect(!OllamaUsageFetcher.shouldAttachCookie(to: URL(string: "https://ollama.com.evil.com")))
+ #expect(!OllamaUsageFetcher.shouldAttachCookie(to: nil))
+ }
+
+ @Test
+ func manualModeWithoutValidHeaderThrowsNoSessionCookie() {
+ do {
+ _ = try OllamaUsageFetcher.resolveManualCookieHeader(
+ override: nil,
+ manualCookieMode: true)
+ Issue.record("Expected OllamaUsageError.noSessionCookie")
+ } catch OllamaUsageError.noSessionCookie {
+ // expected
+ } catch {
+ Issue.record("Expected OllamaUsageError.noSessionCookie, got \(error)")
+ }
+ }
+
+ @Test
+ func autoModeWithoutHeaderDoesNotForceManualError() throws {
+ let resolved = try OllamaUsageFetcher.resolveManualCookieHeader(
+ override: nil,
+ manualCookieMode: false)
+ #expect(resolved == nil)
+ }
+
+ @Test
+ func manualModeWithoutRecognizedSessionCookieThrowsNoSessionCookie() {
+ do {
+ _ = try OllamaUsageFetcher.resolveManualCookieHeader(
+ override: "analytics_session_id=noise; theme=dark",
+ manualCookieMode: true)
+ Issue.record("Expected OllamaUsageError.noSessionCookie")
+ } catch OllamaUsageError.noSessionCookie {
+ // expected
+ } catch {
+ Issue.record("Expected OllamaUsageError.noSessionCookie, got \(error)")
+ }
+ }
+
+ @Test
+ func manualModeWithRecognizedSessionCookieAcceptsHeader() throws {
+ let resolved = try OllamaUsageFetcher.resolveManualCookieHeader(
+ override: "next-auth.session-token.0=abc; theme=dark",
+ manualCookieMode: true)
+ #expect(resolved?.contains("next-auth.session-token.0=abc") == true)
+ }
+
+ @Test
+ func retryPolicyRetriesOnlyForAuthErrors() {
+ #expect(OllamaUsageFetcher.shouldRetryWithNextCookieCandidate(after: OllamaUsageError.invalidCredentials))
+ #expect(OllamaUsageFetcher.shouldRetryWithNextCookieCandidate(after: OllamaUsageError.notLoggedIn))
+ #expect(OllamaUsageFetcher.shouldRetryWithNextCookieCandidate(
+ after: OllamaUsageFetcher.RetryableParseFailure.missingUsageData))
+ #expect(!OllamaUsageFetcher.shouldRetryWithNextCookieCandidate(
+ after: OllamaUsageError.parseFailed("Missing Ollama usage data.")))
+ #expect(!OllamaUsageFetcher.shouldRetryWithNextCookieCandidate(
+ after: OllamaUsageError.parseFailed("Unexpected parser mismatch.")))
+ #expect(!OllamaUsageFetcher.shouldRetryWithNextCookieCandidate(after: OllamaUsageError.networkError("timeout")))
+ }
+
+ #if os(macOS)
+ @Test
+ func cookieImporterDefaultsToChromeFirst() {
+ #expect(OllamaCookieImporter.defaultPreferredBrowsers == [.chrome])
+ }
+
+ @Test
+ func cookieSelectorSkipsSessionLikeNoiseAndFindsRecognizedCookie() throws {
+ let first = OllamaCookieImporter.SessionInfo(
+ cookies: [Self.makeCookie(name: "analytics_session_id", value: "noise")],
+ sourceLabel: "Profile A")
+ let second = OllamaCookieImporter.SessionInfo(
+ cookies: [Self.makeCookie(name: "__Secure-next-auth.session-token", value: "auth")],
+ sourceLabel: "Profile B")
+
+ let selected = try OllamaCookieImporter.selectSessionInfo(from: [first, second])
+ #expect(selected.sourceLabel == "Profile B")
+ }
+
+ @Test
+ func cookieSelectorThrowsWhenNoRecognizedSessionCookieExists() {
+ let candidates = [
+ OllamaCookieImporter.SessionInfo(
+ cookies: [Self.makeCookie(name: "analytics_session_id", value: "noise")],
+ sourceLabel: "Profile A"),
+ OllamaCookieImporter.SessionInfo(
+ cookies: [Self.makeCookie(name: "tracking_session", value: "noise")],
+ sourceLabel: "Profile B"),
+ ]
+
+ do {
+ _ = try OllamaCookieImporter.selectSessionInfo(from: candidates)
+ Issue.record("Expected OllamaUsageError.noSessionCookie")
+ } catch OllamaUsageError.noSessionCookie {
+ // expected
+ } catch {
+ Issue.record("Expected OllamaUsageError.noSessionCookie, got \(error)")
+ }
+ }
+
+ @Test
+ func cookieSelectorAcceptsChunkedNextAuthSessionTokenCookie() throws {
+ let candidate = OllamaCookieImporter.SessionInfo(
+ cookies: [Self.makeCookie(name: "next-auth.session-token.0", value: "chunk0")],
+ sourceLabel: "Profile C")
+
+ let selected = try OllamaCookieImporter.selectSessionInfo(from: [candidate])
+ #expect(selected.sourceLabel == "Profile C")
+ }
+
+ @Test
+ func cookieSelectorKeepsRecognizedCandidatesInOrder() throws {
+ let first = OllamaCookieImporter.SessionInfo(
+ cookies: [Self.makeCookie(name: "session", value: "stale")],
+ sourceLabel: "Chrome Profile A")
+ let second = OllamaCookieImporter.SessionInfo(
+ cookies: [Self.makeCookie(name: "next-auth.session-token.0", value: "valid")],
+ sourceLabel: "Chrome Profile B")
+ let noise = OllamaCookieImporter.SessionInfo(
+ cookies: [Self.makeCookie(name: "analytics_session_id", value: "noise")],
+ sourceLabel: "Chrome Profile C")
+
+ let selected = try OllamaCookieImporter.selectSessionInfos(from: [first, noise, second])
+ #expect(selected.map(\.sourceLabel) == ["Chrome Profile A", "Chrome Profile B"])
+ }
+
+ @Test
+ func cookieSelectorDoesNotFallbackWhenFallbackDisabled() {
+ let preferred = [
+ OllamaCookieImporter.SessionInfo(
+ cookies: [Self.makeCookie(name: "analytics_session_id", value: "noise")],
+ sourceLabel: "Chrome Profile"),
+ ]
+ let fallback = [
+ OllamaCookieImporter.SessionInfo(
+ cookies: [Self.makeCookie(name: "next-auth.session-token.0", value: "chunk0")],
+ sourceLabel: "Safari Profile"),
+ ]
+
+ do {
+ _ = try OllamaCookieImporter.selectSessionInfoWithFallback(
+ preferredCandidates: preferred,
+ allowFallbackBrowsers: false,
+ loadFallbackCandidates: { fallback })
+ Issue.record("Expected OllamaUsageError.noSessionCookie")
+ } catch OllamaUsageError.noSessionCookie {
+ // expected
+ } catch {
+ Issue.record("Expected OllamaUsageError.noSessionCookie, got \(error)")
+ }
+ }
+
+ @Test
+ func cookieSelectorFallsBackToNonChromeCandidateWhenFallbackEnabled() throws {
+ let preferred = [
+ OllamaCookieImporter.SessionInfo(
+ cookies: [Self.makeCookie(name: "analytics_session_id", value: "noise")],
+ sourceLabel: "Chrome Profile"),
+ ]
+ let fallback = [
+ OllamaCookieImporter.SessionInfo(
+ cookies: [Self.makeCookie(name: "next-auth.session-token.0", value: "chunk0")],
+ sourceLabel: "Safari Profile"),
+ ]
+
+ let selected = try OllamaCookieImporter.selectSessionInfoWithFallback(
+ preferredCandidates: preferred,
+ allowFallbackBrowsers: true,
+ loadFallbackCandidates: { fallback })
+ #expect(selected.sourceLabel == "Safari Profile")
+ }
+
+ private static func makeCookie(
+ name: String,
+ value: String,
+ domain: String = "ollama.com") -> HTTPCookie
+ {
+ HTTPCookie(
+ properties: [
+ .name: name,
+ .value: value,
+ .domain: domain,
+ .path: "/",
+ ])!
+ }
+ #endif
+}
diff --git a/Tests/CodexBarTests/OllamaUsageParserTests.swift b/Tests/CodexBarTests/OllamaUsageParserTests.swift
new file mode 100644
index 000000000..e01d167ae
--- /dev/null
+++ b/Tests/CodexBarTests/OllamaUsageParserTests.swift
@@ -0,0 +1,178 @@
+import Foundation
+import Testing
+@testable import CodexBarCore
+
+@Suite
+struct OllamaUsageParserTests {
+ @Test
+ func parsesCloudUsageFromSettingsHTML() throws {
+ let now = Date(timeIntervalSince1970: 1_700_000_000)
+ let html = """
+
+
+ Cloud Usage
+ free
+
+
+
+
Session usage
+
0.1% used
+
Resets in 3 hours
+
+
+
Weekly usage
+
0.7% used
+
Resets in 2 days
+
+
+ """
+
+ let snapshot = try OllamaUsageParser.parse(html: html, now: now)
+
+ #expect(snapshot.planName == "free")
+ #expect(snapshot.accountEmail == "user@example.com")
+ #expect(snapshot.sessionUsedPercent == 0.1)
+ #expect(snapshot.weeklyUsedPercent == 0.7)
+
+ let formatter = ISO8601DateFormatter()
+ formatter.formatOptions = [.withInternetDateTime]
+ let expectedSession = formatter.date(from: "2026-01-30T18:00:00Z")
+ let expectedWeekly = formatter.date(from: "2026-02-02T00:00:00Z")
+ #expect(snapshot.sessionResetsAt == expectedSession)
+ #expect(snapshot.weeklyResetsAt == expectedWeekly)
+
+ let usage = snapshot.toUsageSnapshot()
+ #expect(usage.identity?.loginMethod == "free")
+ #expect(usage.identity?.accountEmail == "user@example.com")
+ }
+
+ @Test
+ func missingUsageThrowsParseFailed() {
+ let html = "No usage here. login status unknown."
+
+ #expect {
+ try OllamaUsageParser.parse(html: html)
+ } throws: { error in
+ guard case let OllamaUsageError.parseFailed(message) = error else { return false }
+ return message.contains("Missing Ollama usage data")
+ }
+ }
+
+ @Test
+ func classifiedParseMissingUsageReturnsTypedFailure() {
+ let html = "No usage here. login status unknown."
+ let result = OllamaUsageParser.parseClassified(html: html)
+
+ switch result {
+ case .success:
+ Issue.record("Expected classified parse failure for missing usage data")
+ case let .failure(failure):
+ #expect(failure == .missingUsageData)
+ }
+ }
+
+ @Test
+ func signedOutThrowsNotLoggedIn() {
+ let html = """
+
+
+ Sign in to Ollama
+
+
+
+ """
+
+ #expect {
+ try OllamaUsageParser.parse(html: html)
+ } throws: { error in
+ guard case OllamaUsageError.notLoggedIn = error else { return false }
+ return true
+ }
+ }
+
+ @Test
+ func classifiedParseSignedOutReturnsTypedFailure() {
+ let html = """
+
+
+ Sign in to Ollama
+
+
+
+ """
+
+ let result = OllamaUsageParser.parseClassified(html: html)
+ switch result {
+ case .success:
+ Issue.record("Expected classified parse failure for signed-out HTML")
+ case let .failure(failure):
+ #expect(failure == .notLoggedIn)
+ }
+ }
+
+ @Test
+ func genericSignInTextWithoutAuthMarkersThrowsParseFailed() {
+ let html = """
+
+
+ Usage Dashboard
+ If you have an account, you can sign in from the homepage.
+ No usage rows rendered.
+
+
+ """
+
+ #expect {
+ try OllamaUsageParser.parse(html: html)
+ } throws: { error in
+ guard case let OllamaUsageError.parseFailed(message) = error else { return false }
+ return message.contains("Missing Ollama usage data")
+ }
+ }
+
+ @Test
+ func parsesHourlyUsageAsPrimaryWindow() throws {
+ let now = Date(timeIntervalSince1970: 1_700_000_000)
+ let html = """
+
+
Hourly usage
+
2.5% used
+
Resets in 3 hours
+
Weekly usage
+
4.2% used
+
Resets in 2 days
+
+ """
+
+ let snapshot = try OllamaUsageParser.parse(html: html, now: now)
+
+ #expect(snapshot.sessionUsedPercent == 2.5)
+ #expect(snapshot.weeklyUsedPercent == 4.2)
+ }
+
+ @Test
+ func parsesUsageWhenUsedIsCapitalized() throws {
+ let now = Date(timeIntervalSince1970: 1_700_000_000)
+ let html = """
+
+
Session usage
+
1.2% Used
+
Resets in 3 hours
+
Weekly usage
+
3.4% USED
+
Resets in 2 days
+
+ """
+
+ let snapshot = try OllamaUsageParser.parse(html: html, now: now)
+
+ #expect(snapshot.sessionUsedPercent == 1.2)
+ #expect(snapshot.weeklyUsedPercent == 3.4)
+ }
+}
diff --git a/Tests/CodexBarTests/OpenAIDashboardNavigationDelegateTests.swift b/Tests/CodexBarTests/OpenAIDashboardNavigationDelegateTests.swift
new file mode 100644
index 000000000..f9cb184eb
--- /dev/null
+++ b/Tests/CodexBarTests/OpenAIDashboardNavigationDelegateTests.swift
@@ -0,0 +1,63 @@
+import Foundation
+import Testing
+import WebKit
+@testable import CodexBarCore
+
+@Suite
+struct OpenAIDashboardNavigationDelegateTests {
+ @Test("ignores NSURLErrorCancelled")
+ func ignoresCancelledNavigationError() {
+ let error = NSError(domain: NSURLErrorDomain, code: NSURLErrorCancelled)
+ #expect(NavigationDelegate.shouldIgnoreNavigationError(error))
+ }
+
+ @Test("does not ignore non-cancelled URL errors")
+ func doesNotIgnoreOtherURLErrors() {
+ let error = NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut)
+ #expect(!NavigationDelegate.shouldIgnoreNavigationError(error))
+ }
+
+ @MainActor
+ @Test("cancelled failure is ignored until finish")
+ func cancelledFailureIsIgnoredUntilFinish() {
+ let webView = WKWebView()
+ var result: Result?
+ let delegate = NavigationDelegate { result = $0 }
+
+ delegate.webView(webView, didFail: nil, withError: NSError(domain: NSURLErrorDomain, code: NSURLErrorCancelled))
+ #expect(result == nil)
+ delegate.webView(webView, didFinish: nil)
+
+ switch result {
+ case .success?:
+ #expect(Bool(true))
+ default:
+ #expect(Bool(false))
+ }
+ }
+
+ @MainActor
+ @Test("cancelled provisional failure is ignored until real failure")
+ func cancelledProvisionalFailureIsIgnoredUntilRealFailure() {
+ let webView = WKWebView()
+ var result: Result?
+ let delegate = NavigationDelegate { result = $0 }
+
+ delegate.webView(
+ webView,
+ didFailProvisionalNavigation: nil,
+ withError: NSError(domain: NSURLErrorDomain, code: NSURLErrorCancelled))
+ #expect(result == nil)
+
+ let timeout = NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut)
+ delegate.webView(webView, didFailProvisionalNavigation: nil, withError: timeout)
+
+ switch result {
+ case let .failure(error as NSError)?:
+ #expect(error.domain == NSURLErrorDomain)
+ #expect(error.code == NSURLErrorTimedOut)
+ default:
+ #expect(Bool(false))
+ }
+ }
+}
diff --git a/Tests/CodexBarTests/OpenCodeUsageFetcherErrorTests.swift b/Tests/CodexBarTests/OpenCodeUsageFetcherErrorTests.swift
new file mode 100644
index 000000000..d64ffb06b
--- /dev/null
+++ b/Tests/CodexBarTests/OpenCodeUsageFetcherErrorTests.swift
@@ -0,0 +1,247 @@
+import CodexBarCore
+import Foundation
+import Testing
+
+@Suite(.serialized)
+struct OpenCodeUsageFetcherErrorTests {
+ @Test
+ func extractsApiErrorFromUppercaseHTMLTitle() async throws {
+ let registered = URLProtocol.registerClass(OpenCodeStubURLProtocol.self)
+ defer {
+ if registered {
+ URLProtocol.unregisterClass(OpenCodeStubURLProtocol.self)
+ }
+ OpenCodeStubURLProtocol.handler = nil
+ }
+
+ OpenCodeStubURLProtocol.handler = { request in
+ guard let url = request.url else { throw URLError(.badURL) }
+ let body = "403 Forbiddendenied"
+ return Self.makeResponse(url: url, body: body, statusCode: 500, contentType: "text/html")
+ }
+
+ do {
+ _ = try await OpenCodeUsageFetcher.fetchUsage(
+ cookieHeader: "auth=test",
+ timeout: 2,
+ workspaceIDOverride: "wrk_TEST123")
+ Issue.record("Expected OpenCodeUsageError.apiError")
+ } catch let error as OpenCodeUsageError {
+ switch error {
+ case let .apiError(message):
+ #expect(message.contains("HTTP 500"))
+ #expect(message.contains("403 Forbidden"))
+ default:
+ Issue.record("Expected apiError, got: \(error)")
+ }
+ }
+ }
+
+ @Test
+ func extractsApiErrorFromDetailField() async throws {
+ let registered = URLProtocol.registerClass(OpenCodeStubURLProtocol.self)
+ defer {
+ if registered {
+ URLProtocol.unregisterClass(OpenCodeStubURLProtocol.self)
+ }
+ OpenCodeStubURLProtocol.handler = nil
+ }
+
+ OpenCodeStubURLProtocol.handler = { request in
+ guard let url = request.url else { throw URLError(.badURL) }
+ let body = #"{"detail":"Workspace missing"}"#
+ return Self.makeResponse(url: url, body: body, statusCode: 500, contentType: "application/json")
+ }
+
+ do {
+ _ = try await OpenCodeUsageFetcher.fetchUsage(
+ cookieHeader: "auth=test",
+ timeout: 2,
+ workspaceIDOverride: "wrk_TEST123")
+ Issue.record("Expected OpenCodeUsageError.apiError")
+ } catch let error as OpenCodeUsageError {
+ switch error {
+ case let .apiError(message):
+ #expect(message.contains("HTTP 500"))
+ #expect(message.contains("Workspace missing"))
+ default:
+ Issue.record("Expected apiError, got: \(error)")
+ }
+ }
+ }
+
+ @Test
+ func subscriptionGetNullSkipsPostAndReturnsGracefulError() async throws {
+ let registered = URLProtocol.registerClass(OpenCodeStubURLProtocol.self)
+ defer {
+ if registered {
+ URLProtocol.unregisterClass(OpenCodeStubURLProtocol.self)
+ }
+ OpenCodeStubURLProtocol.handler = nil
+ }
+
+ var methods: [String] = []
+ var urls: [URL] = []
+ var queries: [String] = []
+ var contentTypes: [String] = []
+ OpenCodeStubURLProtocol.handler = { request in
+ guard let url = request.url else { throw URLError(.badURL) }
+ methods.append(request.httpMethod ?? "GET")
+ urls.append(url)
+ queries.append(url.query ?? "")
+ contentTypes.append(request.value(forHTTPHeaderField: "Content-Type") ?? "")
+
+ if request.httpMethod?.uppercased() == "GET" {
+ return Self.makeResponse(url: url, body: "null", statusCode: 200, contentType: "application/json")
+ }
+
+ let body = #"{"status":500,"unhandled":true,"message":"HTTPError"}"#
+ return Self.makeResponse(url: url, body: body, statusCode: 500, contentType: "application/json")
+ }
+
+ do {
+ _ = try await OpenCodeUsageFetcher.fetchUsage(
+ cookieHeader: "auth=test",
+ timeout: 2,
+ workspaceIDOverride: "wrk_TEST123")
+ Issue.record("Expected OpenCodeUsageError.apiError")
+ } catch let error as OpenCodeUsageError {
+ switch error {
+ case let .apiError(message):
+ #expect(message.contains("No subscription usage data"))
+ #expect(message.contains("wrk_TEST123"))
+ default:
+ Issue.record("Expected apiError, got: \(error)")
+ }
+ }
+
+ #expect(methods == ["GET"])
+ #expect(queries[0].contains("id="))
+ #expect(queries[0].contains("wrk_TEST123"))
+ #expect(urls[0].path == "/_server")
+ #expect(contentTypes[0].isEmpty)
+ }
+
+ @Test
+ func subscriptionGetPayloadDoesNotFallbackToPost() async throws {
+ let registered = URLProtocol.registerClass(OpenCodeStubURLProtocol.self)
+ defer {
+ if registered {
+ URLProtocol.unregisterClass(OpenCodeStubURLProtocol.self)
+ }
+ OpenCodeStubURLProtocol.handler = nil
+ }
+
+ var methods: [String] = []
+ OpenCodeStubURLProtocol.handler = { request in
+ guard let url = request.url else { throw URLError(.badURL) }
+ methods.append(request.httpMethod ?? "GET")
+
+ let body = """
+ {
+ "rollingUsage": { "usagePercent": 17, "resetInSec": 600 },
+ "weeklyUsage": { "usagePercent": 75, "resetInSec": 7200 }
+ }
+ """
+ return Self.makeResponse(url: url, body: body, statusCode: 200, contentType: "application/json")
+ }
+
+ let snapshot = try await OpenCodeUsageFetcher.fetchUsage(
+ cookieHeader: "auth=test",
+ timeout: 2,
+ workspaceIDOverride: "wrk_TEST123")
+
+ #expect(snapshot.rollingUsagePercent == 17)
+ #expect(snapshot.weeklyUsagePercent == 75)
+ #expect(methods == ["GET"])
+ }
+
+ @Test
+ func subscriptionGetMissingFieldsFallsBackToPost() async throws {
+ let registered = URLProtocol.registerClass(OpenCodeStubURLProtocol.self)
+ defer {
+ if registered {
+ URLProtocol.unregisterClass(OpenCodeStubURLProtocol.self)
+ }
+ OpenCodeStubURLProtocol.handler = nil
+ }
+
+ var methods: [String] = []
+ OpenCodeStubURLProtocol.handler = { request in
+ guard let url = request.url else { throw URLError(.badURL) }
+ methods.append(request.httpMethod ?? "GET")
+
+ if request.httpMethod?.uppercased() == "GET" {
+ return Self.makeResponse(
+ url: url,
+ body: #"{"ok":true}"#,
+ statusCode: 200,
+ contentType: "application/json")
+ }
+
+ let body = """
+ {
+ "rollingUsage": { "usagePercent": 22, "resetInSec": 300 },
+ "weeklyUsage": { "usagePercent": 44, "resetInSec": 3600 }
+ }
+ """
+ return Self.makeResponse(
+ url: url,
+ body: body,
+ statusCode: 200,
+ contentType: "application/json")
+ }
+
+ let snapshot = try await OpenCodeUsageFetcher.fetchUsage(
+ cookieHeader: "auth=test",
+ timeout: 2,
+ workspaceIDOverride: "wrk_TEST123")
+
+ #expect(snapshot.rollingUsagePercent == 22)
+ #expect(snapshot.weeklyUsagePercent == 44)
+ #expect(methods == ["GET", "POST"])
+ }
+
+ private static func makeResponse(
+ url: URL,
+ body: String,
+ statusCode: Int,
+ contentType: String) -> (HTTPURLResponse, Data)
+ {
+ let response = HTTPURLResponse(
+ url: url,
+ statusCode: statusCode,
+ httpVersion: "HTTP/1.1",
+ headerFields: ["Content-Type": contentType])!
+ return (response, Data(body.utf8))
+ }
+}
+
+final class OpenCodeStubURLProtocol: URLProtocol {
+ nonisolated(unsafe) static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))?
+
+ override static func canInit(with request: URLRequest) -> Bool {
+ request.url?.host == "opencode.ai"
+ }
+
+ override static func canonicalRequest(for request: URLRequest) -> URLRequest {
+ request
+ }
+
+ override func startLoading() {
+ guard let handler = Self.handler else {
+ self.client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse))
+ return
+ }
+ do {
+ let (response, data) = try handler(self.request)
+ self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
+ self.client?.urlProtocol(self, didLoad: data)
+ self.client?.urlProtocolDidFinishLoading(self)
+ } catch {
+ self.client?.urlProtocol(self, didFailWithError: error)
+ }
+ }
+
+ override func stopLoading() {}
+}
diff --git a/Tests/CodexBarTests/OpenRouterUsageStatsTests.swift b/Tests/CodexBarTests/OpenRouterUsageStatsTests.swift
new file mode 100644
index 000000000..25ec01bcf
--- /dev/null
+++ b/Tests/CodexBarTests/OpenRouterUsageStatsTests.swift
@@ -0,0 +1,255 @@
+import Foundation
+import Testing
+@testable import CodexBarCore
+
+@Suite(.serialized)
+struct OpenRouterUsageStatsTests {
+ @Test
+ func toUsageSnapshot_usesKeyQuotaForPrimaryWindow() {
+ let snapshot = OpenRouterUsageSnapshot(
+ totalCredits: 50,
+ totalUsage: 45.3895596325,
+ balance: 4.6104403675,
+ usedPercent: 90.779119265,
+ keyLimit: 20,
+ keyUsage: 5,
+ rateLimit: nil,
+ updatedAt: Date(timeIntervalSince1970: 1_739_841_600))
+
+ let usage = snapshot.toUsageSnapshot()
+
+ #expect(usage.primary?.usedPercent == 25)
+ #expect(usage.primary?.resetsAt == nil)
+ #expect(usage.primary?.resetDescription == nil)
+ #expect(usage.openRouterUsage?.keyQuotaStatus == .available)
+ }
+
+ @Test
+ func toUsageSnapshot_withoutValidKeyLimitOmitsPrimaryWindow() {
+ let snapshot = OpenRouterUsageSnapshot(
+ totalCredits: 50,
+ totalUsage: 45.3895596325,
+ balance: 4.6104403675,
+ usedPercent: 90.779119265,
+ keyLimit: nil,
+ keyUsage: nil,
+ rateLimit: nil,
+ updatedAt: Date(timeIntervalSince1970: 1_739_841_600))
+
+ let usage = snapshot.toUsageSnapshot()
+
+ #expect(usage.primary == nil)
+ #expect(usage.openRouterUsage?.keyQuotaStatus == .unavailable)
+ }
+
+ @Test
+ func toUsageSnapshot_whenNoLimitConfiguredOmitsPrimaryAndMarksNoLimit() {
+ let snapshot = OpenRouterUsageSnapshot(
+ totalCredits: 50,
+ totalUsage: 45.3895596325,
+ balance: 4.6104403675,
+ usedPercent: 90.779119265,
+ keyDataFetched: true,
+ keyLimit: nil,
+ keyUsage: nil,
+ rateLimit: nil,
+ updatedAt: Date(timeIntervalSince1970: 1_739_841_600))
+
+ let usage = snapshot.toUsageSnapshot()
+
+ #expect(usage.primary == nil)
+ #expect(usage.openRouterUsage?.keyQuotaStatus == .noLimitConfigured)
+ }
+
+ @Test
+ func sanitizers_redactSensitiveTokenShapes() {
+ let body = """
+ {"error":"bad token sk-or-v1-abc123","token":"secret-token","authorization":"Bearer sk-or-v1-xyz789"}
+ """
+
+ let summary = OpenRouterUsageFetcher._sanitizedResponseBodySummaryForTesting(body)
+ let debugBody = OpenRouterUsageFetcher._redactedDebugResponseBodyForTesting(body)
+
+ #expect(summary.contains("sk-or-v1-[REDACTED]"))
+ #expect(summary.contains("\"token\":\"[REDACTED]\""))
+ #expect(!summary.contains("secret-token"))
+ #expect(!summary.contains("sk-or-v1-abc123"))
+
+ #expect(debugBody?.contains("sk-or-v1-[REDACTED]") == true)
+ #expect(debugBody?.contains("\"token\":\"[REDACTED]\"") == true)
+ #expect(debugBody?.contains("secret-token") == false)
+ #expect(debugBody?.contains("sk-or-v1-xyz789") == false)
+ }
+
+ @Test
+ func non200FetchThrowsGenericHTTPErrorWithoutBodyDetails() async throws {
+ let registered = URLProtocol.registerClass(OpenRouterStubURLProtocol.self)
+ defer {
+ if registered {
+ URLProtocol.unregisterClass(OpenRouterStubURLProtocol.self)
+ }
+ OpenRouterStubURLProtocol.handler = nil
+ }
+
+ OpenRouterStubURLProtocol.handler = { request in
+ guard let url = request.url else { throw URLError(.badURL) }
+ let body = #"{"error":"invalid sk-or-v1-super-secret","token":"dont-leak-me"}"#
+ return Self.makeResponse(url: url, body: body, statusCode: 401)
+ }
+
+ do {
+ _ = try await OpenRouterUsageFetcher.fetchUsage(
+ apiKey: "sk-or-v1-test",
+ environment: ["OPENROUTER_API_URL": "https://openrouter.test/api/v1"])
+ Issue.record("Expected OpenRouterUsageError.apiError")
+ } catch let error as OpenRouterUsageError {
+ guard case let .apiError(message) = error else {
+ Issue.record("Expected apiError, got: \(error)")
+ return
+ }
+ #expect(message == "HTTP 401")
+ #expect(!message.contains("dont-leak-me"))
+ #expect(!message.contains("sk-or-v1-super-secret"))
+ }
+ }
+
+ @Test
+ func fetchUsage_setsCreditsTimeoutAndClientHeaders() async throws {
+ let registered = URLProtocol.registerClass(OpenRouterStubURLProtocol.self)
+ defer {
+ if registered {
+ URLProtocol.unregisterClass(OpenRouterStubURLProtocol.self)
+ }
+ OpenRouterStubURLProtocol.handler = nil
+ }
+
+ OpenRouterStubURLProtocol.handler = { request in
+ guard let url = request.url else { throw URLError(.badURL) }
+ switch url.path {
+ case "/api/v1/credits":
+ #expect(request.timeoutInterval == 15)
+ #expect(request.value(forHTTPHeaderField: "HTTP-Referer") == "https://codexbar.example")
+ #expect(request.value(forHTTPHeaderField: "X-Title") == "CodexBar QA")
+ let body = #"{"data":{"total_credits":100,"total_usage":40}}"#
+ return Self.makeResponse(url: url, body: body, statusCode: 200)
+ case "/api/v1/key":
+ let body = #"{"data":{"limit":20,"usage":0.5,"rate_limit":{"requests":120,"interval":"10s"}}}"#
+ return Self.makeResponse(url: url, body: body, statusCode: 200)
+ default:
+ return Self.makeResponse(url: url, body: "{}", statusCode: 404)
+ }
+ }
+
+ let usage = try await OpenRouterUsageFetcher.fetchUsage(
+ apiKey: "sk-or-v1-test",
+ environment: [
+ "OPENROUTER_API_URL": "https://openrouter.test/api/v1",
+ "OPENROUTER_HTTP_REFERER": " https://codexbar.example ",
+ "OPENROUTER_X_TITLE": "CodexBar QA",
+ ])
+
+ #expect(usage.totalCredits == 100)
+ #expect(usage.totalUsage == 40)
+ #expect(usage.keyDataFetched)
+ #expect(usage.keyLimit == 20)
+ #expect(usage.keyUsage == 0.5)
+ #expect(usage.keyRemaining == 19.5)
+ #expect(usage.keyUsedPercent == 2.5)
+ #expect(usage.keyQuotaStatus == .available)
+ }
+
+ @Test
+ func fetchUsage_whenKeyEndpointFailsMarksQuotaUnavailable() async throws {
+ let registered = URLProtocol.registerClass(OpenRouterStubURLProtocol.self)
+ defer {
+ if registered {
+ URLProtocol.unregisterClass(OpenRouterStubURLProtocol.self)
+ }
+ OpenRouterStubURLProtocol.handler = nil
+ }
+
+ OpenRouterStubURLProtocol.handler = { request in
+ guard let url = request.url else { throw URLError(.badURL) }
+ switch url.path {
+ case "/api/v1/credits":
+ let body = #"{"data":{"total_credits":100,"total_usage":40}}"#
+ return Self.makeResponse(url: url, body: body, statusCode: 200)
+ case "/api/v1/key":
+ return Self.makeResponse(url: url, body: "{}", statusCode: 500)
+ default:
+ return Self.makeResponse(url: url, body: "{}", statusCode: 404)
+ }
+ }
+
+ let usage = try await OpenRouterUsageFetcher.fetchUsage(
+ apiKey: "sk-or-v1-test",
+ environment: ["OPENROUTER_API_URL": "https://openrouter.test/api/v1"])
+
+ #expect(!usage.keyDataFetched)
+ #expect(usage.keyQuotaStatus == .unavailable)
+ }
+
+ @Test
+ func usageSnapshot_roundTripPersistsOpenRouterUsageMetadata() throws {
+ let openRouter = OpenRouterUsageSnapshot(
+ totalCredits: 50,
+ totalUsage: 45.3895596325,
+ balance: 4.6104403675,
+ usedPercent: 90.779119265,
+ keyDataFetched: true,
+ keyLimit: nil,
+ keyUsage: nil,
+ rateLimit: nil,
+ updatedAt: Date(timeIntervalSince1970: 1_739_841_600))
+ let snapshot = openRouter.toUsageSnapshot()
+
+ let encoder = JSONEncoder()
+ let data = try encoder.encode(snapshot)
+ let decoded = try JSONDecoder().decode(UsageSnapshot.self, from: data)
+
+ #expect(decoded.openRouterUsage?.keyDataFetched == true)
+ #expect(decoded.openRouterUsage?.keyQuotaStatus == .noLimitConfigured)
+ }
+
+ private static func makeResponse(
+ url: URL,
+ body: String,
+ statusCode: Int = 200) -> (HTTPURLResponse, Data)
+ {
+ let response = HTTPURLResponse(
+ url: url,
+ statusCode: statusCode,
+ httpVersion: "HTTP/1.1",
+ headerFields: ["Content-Type": "application/json"])!
+ return (response, Data(body.utf8))
+ }
+}
+
+final class OpenRouterStubURLProtocol: URLProtocol {
+ nonisolated(unsafe) static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))?
+
+ override static func canInit(with request: URLRequest) -> Bool {
+ request.url?.host == "openrouter.test"
+ }
+
+ override static func canonicalRequest(for request: URLRequest) -> URLRequest {
+ request
+ }
+
+ override func startLoading() {
+ guard let handler = Self.handler else {
+ self.client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse))
+ return
+ }
+ do {
+ let (response, data) = try handler(self.request)
+ self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
+ self.client?.urlProtocol(self, didLoad: data)
+ self.client?.urlProtocolDidFinishLoading(self)
+ } catch {
+ self.client?.urlProtocol(self, didFailWithError: error)
+ }
+ }
+
+ override func stopLoading() {}
+}
diff --git a/Tests/CodexBarTests/PreferencesPaneSmokeTests.swift b/Tests/CodexBarTests/PreferencesPaneSmokeTests.swift
index ff20e3952..cedeeca59 100644
--- a/Tests/CodexBarTests/PreferencesPaneSmokeTests.swift
+++ b/Tests/CodexBarTests/PreferencesPaneSmokeTests.swift
@@ -30,6 +30,7 @@ struct PreferencesPaneSmokeTests {
settings.hidePersonalInfo = true
settings.resetTimesShowAbsolute = true
settings.debugDisableKeychainAccess = true
+ settings.claudeOAuthKeychainPromptMode = .always
settings.refreshFrequency = .manual
let store = Self.makeUsageStore(settings: settings)
diff --git a/Tests/CodexBarTests/ProviderCandidateRetryRunnerTests.swift b/Tests/CodexBarTests/ProviderCandidateRetryRunnerTests.swift
new file mode 100644
index 000000000..4632d1b95
--- /dev/null
+++ b/Tests/CodexBarTests/ProviderCandidateRetryRunnerTests.swift
@@ -0,0 +1,120 @@
+import Testing
+@testable import CodexBarCore
+
+@Suite
+struct ProviderCandidateRetryRunnerTests {
+ private enum TestError: Error, Equatable {
+ case retryable(Int)
+ case nonRetryable(Int)
+ }
+
+ @Test
+ func retriesThenSucceeds() async throws {
+ let candidates = [1, 2, 3]
+ var attempted: [Int] = []
+ var retried: [Int] = []
+
+ let output = try await ProviderCandidateRetryRunner.run(
+ candidates,
+ shouldRetry: { error in
+ if case TestError.retryable = error {
+ return true
+ }
+ return false
+ },
+ onRetry: { candidate, _ in
+ retried.append(candidate)
+ },
+ attempt: { candidate in
+ attempted.append(candidate)
+ guard candidate == 3 else {
+ throw TestError.retryable(candidate)
+ }
+ return candidate * 10
+ })
+
+ #expect(output == 30)
+ #expect(attempted == [1, 2, 3])
+ #expect(retried == [1, 2])
+ }
+
+ @Test
+ func nonRetryableFailsImmediately() async {
+ let candidates = [1, 2, 3]
+ var attempted: [Int] = []
+ var retried: [Int] = []
+
+ do {
+ _ = try await ProviderCandidateRetryRunner.run(
+ candidates,
+ shouldRetry: { error in
+ if case TestError.retryable = error {
+ return true
+ }
+ return false
+ },
+ onRetry: { candidate, _ in
+ retried.append(candidate)
+ },
+ attempt: { candidate in
+ attempted.append(candidate)
+ throw TestError.nonRetryable(candidate)
+ })
+ Issue.record("Expected TestError.nonRetryable")
+ } catch let error as TestError {
+ #expect(error == .nonRetryable(1))
+ #expect(attempted == [1])
+ #expect(retried.isEmpty)
+ } catch {
+ Issue.record("Expected TestError.nonRetryable(1), got \(error)")
+ }
+ }
+
+ @Test
+ func exhaustedRetryableThrowsLastError() async {
+ let candidates = [1, 2]
+ var attempted: [Int] = []
+ var retried: [Int] = []
+
+ do {
+ _ = try await ProviderCandidateRetryRunner.run(
+ candidates,
+ shouldRetry: { error in
+ if case TestError.retryable = error {
+ return true
+ }
+ return false
+ },
+ onRetry: { candidate, _ in
+ retried.append(candidate)
+ },
+ attempt: { candidate in
+ attempted.append(candidate)
+ throw TestError.retryable(candidate)
+ })
+ Issue.record("Expected TestError.retryable")
+ } catch let error as TestError {
+ #expect(error == .retryable(2))
+ #expect(attempted == [1, 2])
+ #expect(retried == [1])
+ } catch {
+ Issue.record("Expected TestError.retryable(2), got \(error)")
+ }
+ }
+
+ @Test
+ func emptyCandidatesThrowsNoCandidates() async {
+ do {
+ let candidates: [Int] = []
+ _ = try await ProviderCandidateRetryRunner.run(
+ candidates,
+ shouldRetry: { _ in true },
+ attempt: { _ in 1 })
+ Issue.record("Expected ProviderCandidateRetryRunnerError.noCandidates")
+ } catch ProviderCandidateRetryRunnerError.noCandidates {
+ // expected
+ } catch {
+ Issue.record("Expected ProviderCandidateRetryRunnerError.noCandidates, got \(error)")
+ }
+ }
+}
diff --git a/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift b/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift
index e61a2e3d1..88f1b35c7 100644
--- a/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift
+++ b/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift
@@ -14,6 +14,44 @@ struct ProviderConfigEnvironmentTests {
#expect(env[ZaiSettingsReader.apiTokenKey] == "z-token")
}
+ @Test
+ func appliesAPIKeyOverrideForWarp() {
+ let config = ProviderConfig(id: .warp, apiKey: "w-token")
+ let env = ProviderConfigEnvironment.applyAPIKeyOverride(
+ base: [:],
+ provider: .warp,
+ config: config)
+
+ let key = WarpSettingsReader.apiKeyEnvironmentKeys.first
+ #expect(key != nil)
+ guard let key else { return }
+
+ #expect(env[key] == "w-token")
+ }
+
+ @Test
+ func appliesAPIKeyOverrideForOpenRouter() {
+ let config = ProviderConfig(id: .openrouter, apiKey: "or-token")
+ let env = ProviderConfigEnvironment.applyAPIKeyOverride(
+ base: [:],
+ provider: .openrouter,
+ config: config)
+
+ #expect(env[OpenRouterSettingsReader.envKey] == "or-token")
+ }
+
+ @Test
+ func openRouterConfigOverrideWinsOverEnvironmentToken() {
+ let config = ProviderConfig(id: .openrouter, apiKey: "config-token")
+ let env = ProviderConfigEnvironment.applyAPIKeyOverride(
+ base: [OpenRouterSettingsReader.envKey: "env-token"],
+ provider: .openrouter,
+ config: config)
+
+ #expect(env[OpenRouterSettingsReader.envKey] == "config-token")
+ #expect(ProviderTokenResolver.openRouterToken(environment: env) == "config-token")
+ }
+
@Test
func leavesEnvironmentWhenAPIKeyMissing() {
let config = ProviderConfig(id: .zai, apiKey: nil)
diff --git a/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift b/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift
index 9e05c7462..74ba76f8f 100644
--- a/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift
+++ b/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift
@@ -134,6 +134,7 @@ struct ProviderSettingsDescriptorTests {
configStore: configStore,
zaiTokenStore: NoopZaiTokenStore(),
syntheticTokenStore: NoopSyntheticTokenStore())
+ settings.debugDisableKeychainAccess = false
let store = UsageStore(
fetcher: UsageFetcher(environment: [:]),
browserDetection: BrowserDetection(cacheTTL: 0),
@@ -161,6 +162,100 @@ struct ProviderSettingsDescriptorTests {
let pickers = ClaudeProviderImplementation().settingsPickers(context: context)
#expect(pickers.contains(where: { $0.id == "claude-usage-source" }))
#expect(pickers.contains(where: { $0.id == "claude-cookie-source" }))
+ let keychainPicker = try #require(pickers.first(where: { $0.id == "claude-keychain-prompt-policy" }))
+ let optionIDs = Set(keychainPicker.options.map(\.id))
+ #expect(optionIDs.contains(ClaudeOAuthKeychainPromptMode.never.rawValue))
+ #expect(optionIDs.contains(ClaudeOAuthKeychainPromptMode.onlyOnUserAction.rawValue))
+ #expect(optionIDs.contains(ClaudeOAuthKeychainPromptMode.always.rawValue))
+ #expect(keychainPicker.isEnabled?() ?? true)
+ }
+
+ @Test
+ func claudePromptPolicyPickerHiddenWhenExperimentalReaderSelected() throws {
+ let suite = "ProviderSettingsDescriptorTests-claude-prompt-hidden-experimental"
+ let defaults = try #require(UserDefaults(suiteName: suite))
+ defaults.removePersistentDomain(forName: suite)
+ let configStore = testConfigStore(suiteName: suite)
+ let settings = SettingsStore(
+ userDefaults: defaults,
+ configStore: configStore,
+ zaiTokenStore: NoopZaiTokenStore(),
+ syntheticTokenStore: NoopSyntheticTokenStore())
+ settings.debugDisableKeychainAccess = false
+ settings.claudeOAuthKeychainReadStrategy = .securityCLIExperimental
+
+ let store = UsageStore(
+ fetcher: UsageFetcher(environment: [:]),
+ browserDetection: BrowserDetection(cacheTTL: 0),
+ settings: settings)
+
+ let context = ProviderSettingsContext(
+ provider: .claude,
+ settings: settings,
+ store: store,
+ boolBinding: { keyPath in
+ Binding(
+ get: { settings[keyPath: keyPath] },
+ set: { settings[keyPath: keyPath] = $0 })
+ },
+ stringBinding: { keyPath in
+ Binding(
+ get: { settings[keyPath: keyPath] },
+ set: { settings[keyPath: keyPath] = $0 })
+ },
+ statusText: { _ in nil },
+ setStatusText: { _, _ in },
+ lastAppActiveRunAt: { _ in nil },
+ setLastAppActiveRunAt: { _, _ in },
+ requestConfirmation: { _ in })
+
+ let pickers = ClaudeProviderImplementation().settingsPickers(context: context)
+ let keychainPicker = try #require(pickers.first(where: { $0.id == "claude-keychain-prompt-policy" }))
+ #expect(keychainPicker.isVisible?() == false)
+ }
+
+ @Test
+ func claudeKeychainPromptPolicyPickerDisabledWhenGlobalKeychainDisabled() throws {
+ let suite = "ProviderSettingsDescriptorTests-claude-keychain-disabled"
+ let defaults = try #require(UserDefaults(suiteName: suite))
+ defaults.removePersistentDomain(forName: suite)
+ let configStore = testConfigStore(suiteName: suite)
+ let settings = SettingsStore(
+ userDefaults: defaults,
+ configStore: configStore,
+ zaiTokenStore: NoopZaiTokenStore(),
+ syntheticTokenStore: NoopSyntheticTokenStore())
+ settings.debugDisableKeychainAccess = true
+ let store = UsageStore(
+ fetcher: UsageFetcher(environment: [:]),
+ browserDetection: BrowserDetection(cacheTTL: 0),
+ settings: settings)
+
+ let context = ProviderSettingsContext(
+ provider: .claude,
+ settings: settings,
+ store: store,
+ boolBinding: { keyPath in
+ Binding(
+ get: { settings[keyPath: keyPath] },
+ set: { settings[keyPath: keyPath] = $0 })
+ },
+ stringBinding: { keyPath in
+ Binding(
+ get: { settings[keyPath: keyPath] },
+ set: { settings[keyPath: keyPath] = $0 })
+ },
+ statusText: { _ in nil },
+ setStatusText: { _, _ in },
+ lastAppActiveRunAt: { _ in nil },
+ setLastAppActiveRunAt: { _, _ in },
+ requestConfirmation: { _ in })
+
+ let pickers = ClaudeProviderImplementation().settingsPickers(context: context)
+ let keychainPicker = try #require(pickers.first(where: { $0.id == "claude-keychain-prompt-policy" }))
+ #expect(keychainPicker.isEnabled?() == false)
+ let subtitle = keychainPicker.dynamicSubtitle?() ?? ""
+ #expect(subtitle.localizedCaseInsensitiveContains("inactive"))
}
@Test
diff --git a/Tests/CodexBarTests/ProviderTokenResolverTests.swift b/Tests/CodexBarTests/ProviderTokenResolverTests.swift
index 004b63b15..867b3ad45 100644
--- a/Tests/CodexBarTests/ProviderTokenResolverTests.swift
+++ b/Tests/CodexBarTests/ProviderTokenResolverTests.swift
@@ -17,4 +17,26 @@ struct ProviderTokenResolverTests {
let resolution = ProviderTokenResolver.copilotResolution(environment: env)
#expect(resolution?.token == "token")
}
+
+ @Test
+ func warpResolutionUsesEnvironmentToken() {
+ let env = ["WARP_API_KEY": "wk-test-token"]
+ let resolution = ProviderTokenResolver.warpResolution(environment: env)
+ #expect(resolution?.token == "wk-test-token")
+ #expect(resolution?.source == .environment)
+ }
+
+ @Test
+ func warpResolutionTrimsToken() {
+ let env = ["WARP_API_KEY": " wk-token "]
+ let resolution = ProviderTokenResolver.warpResolution(environment: env)
+ #expect(resolution?.token == "wk-token")
+ }
+
+ @Test
+ func warpResolutionReturnsNilWhenMissing() {
+ let env: [String: String] = [:]
+ let resolution = ProviderTokenResolver.warpResolution(environment: env)
+ #expect(resolution == nil)
+ }
}
diff --git a/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift b/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift
index e3f2bd553..e5ea668de 100644
--- a/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift
+++ b/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift
@@ -14,6 +14,39 @@ struct ProvidersPaneCoverageTests {
ProvidersPaneTestHarness.exercise(settings: settings, store: store)
}
+ @Test
+ func openRouterMenuBarMetricPicker_showsOnlyAutomaticAndPrimary() {
+ let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-openrouter-picker")
+ let store = Self.makeUsageStore(settings: settings)
+ let pane = ProvidersPane(settings: settings, store: store)
+
+ let picker = pane._test_menuBarMetricPicker(for: .openrouter)
+ #expect(picker?.options.map(\.id) == [
+ MenuBarMetricPreference.automatic.rawValue,
+ MenuBarMetricPreference.primary.rawValue,
+ ])
+ #expect(picker?.options.map(\.title) == [
+ "Automatic",
+ "Primary (API key limit)",
+ ])
+ }
+
+ @Test
+ func providerDetailPlanRow_formatsOpenRouterAsBalance() {
+ let row = ProviderDetailView.planRow(provider: .openrouter, planText: "Balance: $4.61")
+
+ #expect(row?.label == "Balance")
+ #expect(row?.value == "$4.61")
+ }
+
+ @Test
+ func providerDetailPlanRow_keepsPlanLabelForNonOpenRouter() {
+ let row = ProviderDetailView.planRow(provider: .codex, planText: "Pro")
+
+ #expect(row?.label == "Plan")
+ #expect(row?.value == "Pro")
+ }
+
private static func makeSettingsStore(suite: String) -> SettingsStore {
let defaults = UserDefaults(suiteName: suite)!
defaults.removePersistentDomain(forName: suite)
diff --git a/Tests/CodexBarTests/SettingsStoreAdditionalTests.swift b/Tests/CodexBarTests/SettingsStoreAdditionalTests.swift
index c70eb957b..7ec91584c 100644
--- a/Tests/CodexBarTests/SettingsStoreAdditionalTests.swift
+++ b/Tests/CodexBarTests/SettingsStoreAdditionalTests.swift
@@ -20,6 +20,20 @@ struct SettingsStoreAdditionalTests {
#expect(settings.menuBarMetricPreference(for: .gemini) == .average)
}
+ @Test
+ func menuBarMetricPreferenceRestrictsOpenRouterToAutomaticOrPrimary() {
+ let settings = Self.makeSettingsStore(suite: "SettingsStoreAdditionalTests-openrouter-metric")
+
+ settings.setMenuBarMetricPreference(.secondary, for: .openrouter)
+ #expect(settings.menuBarMetricPreference(for: .openrouter) == .automatic)
+
+ settings.setMenuBarMetricPreference(.average, for: .openrouter)
+ #expect(settings.menuBarMetricPreference(for: .openrouter) == .automatic)
+
+ settings.setMenuBarMetricPreference(.primary, for: .openrouter)
+ #expect(settings.menuBarMetricPreference(for: .openrouter) == .primary)
+ }
+
@Test
func minimaxAuthModeUsesStoredValues() {
let settings = Self.makeSettingsStore(suite: "SettingsStoreAdditionalTests-minimax")
@@ -42,6 +56,16 @@ struct SettingsStoreAdditionalTests {
#expect(settings.claudeCookieSource == .manual)
}
+ @Test
+ func ollamaTokenAccountsSetManualCookieSourceWhenRequired() {
+ let settings = Self.makeSettingsStore(suite: "SettingsStoreAdditionalTests-ollama-token-accounts")
+
+ settings.addTokenAccount(provider: .ollama, label: "Primary", token: "session=token-1")
+
+ #expect(settings.tokenAccounts(for: .ollama).count == 1)
+ #expect(settings.ollamaCookieSource == .manual)
+ }
+
@Test
func detectsTokenCostUsageSourcesFromFilesystem() throws {
let fm = FileManager.default
diff --git a/Tests/CodexBarTests/SettingsStoreCoverageTests.swift b/Tests/CodexBarTests/SettingsStoreCoverageTests.swift
index 724e8dece..aa3c096b8 100644
--- a/Tests/CodexBarTests/SettingsStoreCoverageTests.swift
+++ b/Tests/CodexBarTests/SettingsStoreCoverageTests.swift
@@ -124,6 +124,7 @@ struct SettingsStoreCoverageTests {
settings.ensureKimiK2APITokenLoaded()
settings.ensureAugmentCookieLoaded()
settings.ensureAmpCookieLoaded()
+ settings.ensureOllamaCookieLoaded()
settings.ensureCopilotAPITokenLoaded()
settings.ensureTokenAccountsLoaded()
@@ -149,6 +150,88 @@ struct SettingsStoreCoverageTests {
#expect(settings.kimiCookieSource == .off)
}
+ @Test
+ func claudeKeychainPromptMode_defaultsToOnlyOnUserAction() {
+ let settings = Self.makeSettingsStore()
+ #expect(settings.claudeOAuthKeychainPromptMode == .onlyOnUserAction)
+ }
+
+ @Test
+ func claudeKeychainPromptMode_persistsAcrossStoreReload() throws {
+ let suite = "SettingsStoreCoverageTests-claude-keychain-prompt-mode"
+ let defaults = try #require(UserDefaults(suiteName: suite))
+ defaults.removePersistentDomain(forName: suite)
+ let configStore = testConfigStore(suiteName: suite)
+
+ let first = Self.makeSettingsStore(userDefaults: defaults, configStore: configStore)
+ first.claudeOAuthKeychainPromptMode = .never
+ #expect(
+ defaults.string(forKey: "claudeOAuthKeychainPromptMode")
+ == ClaudeOAuthKeychainPromptMode.never.rawValue)
+
+ let second = Self.makeSettingsStore(userDefaults: defaults, configStore: configStore)
+ #expect(second.claudeOAuthKeychainPromptMode == .never)
+ }
+
+ @Test
+ func claudeKeychainPromptMode_invalidRawFallsBackToOnlyOnUserAction() throws {
+ let suite = "SettingsStoreCoverageTests-claude-keychain-prompt-mode-invalid"
+ let defaults = try #require(UserDefaults(suiteName: suite))
+ defaults.removePersistentDomain(forName: suite)
+ defaults.set("invalid-mode", forKey: "claudeOAuthKeychainPromptMode")
+ let configStore = testConfigStore(suiteName: suite)
+
+ let settings = Self.makeSettingsStore(userDefaults: defaults, configStore: configStore)
+ #expect(settings.claudeOAuthKeychainPromptMode == .onlyOnUserAction)
+ }
+
+ @Test
+ func claudeKeychainReadStrategy_defaultsToSecurityFramework() {
+ let settings = Self.makeSettingsStore()
+ #expect(settings.claudeOAuthKeychainReadStrategy == .securityFramework)
+ }
+
+ @Test
+ func claudeKeychainReadStrategy_persistsAcrossStoreReload() throws {
+ let suite = "SettingsStoreCoverageTests-claude-keychain-read-strategy"
+ let defaults = try #require(UserDefaults(suiteName: suite))
+ defaults.removePersistentDomain(forName: suite)
+ let configStore = testConfigStore(suiteName: suite)
+
+ let first = Self.makeSettingsStore(userDefaults: defaults, configStore: configStore)
+ first.claudeOAuthKeychainReadStrategy = .securityCLIExperimental
+ #expect(
+ defaults.string(forKey: "claudeOAuthKeychainReadStrategy")
+ == ClaudeOAuthKeychainReadStrategy.securityCLIExperimental.rawValue)
+
+ let second = Self.makeSettingsStore(userDefaults: defaults, configStore: configStore)
+ #expect(second.claudeOAuthKeychainReadStrategy == .securityCLIExperimental)
+ }
+
+ @Test
+ func claudeKeychainReadStrategy_invalidRawFallsBackToSecurityFramework() throws {
+ let suite = "SettingsStoreCoverageTests-claude-keychain-read-strategy-invalid"
+ let defaults = try #require(UserDefaults(suiteName: suite))
+ defaults.removePersistentDomain(forName: suite)
+ defaults.set("invalid-strategy", forKey: "claudeOAuthKeychainReadStrategy")
+ let configStore = testConfigStore(suiteName: suite)
+
+ let settings = Self.makeSettingsStore(userDefaults: defaults, configStore: configStore)
+ #expect(settings.claudeOAuthKeychainReadStrategy == .securityFramework)
+ }
+
+ @Test
+ func claudePromptFreeCredentialsToggle_mapsToReadStrategy() {
+ let settings = Self.makeSettingsStore()
+ #expect(settings.claudeOAuthPromptFreeCredentialsEnabled == false)
+
+ settings.claudeOAuthPromptFreeCredentialsEnabled = true
+ #expect(settings.claudeOAuthKeychainReadStrategy == .securityCLIExperimental)
+
+ settings.claudeOAuthPromptFreeCredentialsEnabled = false
+ #expect(settings.claudeOAuthKeychainReadStrategy == .securityFramework)
+ }
+
private static func makeSettingsStore(suiteName: String = "SettingsStoreCoverageTests") -> SettingsStore {
let defaults = UserDefaults(suiteName: suiteName)!
defaults.removePersistentDomain(forName: suiteName)
diff --git a/Tests/CodexBarTests/SettingsStoreTests.swift b/Tests/CodexBarTests/SettingsStoreTests.swift
index fad99a763..cfeb90048 100644
--- a/Tests/CodexBarTests/SettingsStoreTests.swift
+++ b/Tests/CodexBarTests/SettingsStoreTests.swift
@@ -363,7 +363,10 @@ struct SettingsStoreTests {
.jetbrains,
.kimik2,
.amp,
+ .ollama,
.synthetic,
+ .warp,
+ .openrouter,
])
// Move one provider; ensure it's persisted across instances.
diff --git a/Tests/CodexBarTests/StatusItemAnimationTests.swift b/Tests/CodexBarTests/StatusItemAnimationTests.swift
index cfb94bb72..c103ba4ea 100644
--- a/Tests/CodexBarTests/StatusItemAnimationTests.swift
+++ b/Tests/CodexBarTests/StatusItemAnimationTests.swift
@@ -6,6 +6,19 @@ import Testing
@MainActor
@Suite
struct StatusItemAnimationTests {
+ private func maxAlpha(in rep: NSBitmapImageRep) -> CGFloat {
+ var maxAlpha: CGFloat = 0
+ for x in 0.. maxAlpha {
+ maxAlpha = alpha
+ }
+ }
+ }
+ return maxAlpha
+ }
+
private func makeStatusBarForTesting() -> NSStatusBar {
let env = ProcessInfo.processInfo.environment
if env["GITHUB_ACTIONS"] == "true" || env["CI"] == "true" {
@@ -124,6 +137,112 @@ struct StatusItemAnimationTests {
#expect(alpha > 0.05)
}
+ @Test
+ func warpNoBonusLayoutIsPreservedInShowUsedModeWhenBonusIsExhausted() {
+ let settings = SettingsStore(
+ configStore: testConfigStore(suiteName: "StatusItemAnimationTests-warp-no-bonus-used"),
+ zaiTokenStore: NoopZaiTokenStore(),
+ syntheticTokenStore: NoopSyntheticTokenStore())
+ settings.statusChecksEnabled = false
+ settings.refreshFrequency = .manual
+ settings.mergeIcons = false
+ settings.menuBarShowsBrandIconWithPercent = false
+ settings.usageBarsShowUsed = true
+
+ let registry = ProviderRegistry.shared
+ if let warpMeta = registry.metadata[.warp] {
+ settings.setProviderEnabled(provider: .warp, metadata: warpMeta, enabled: true)
+ }
+
+ let fetcher = UsageFetcher()
+ let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings)
+ let controller = StatusItemController(
+ store: store,
+ settings: settings,
+ account: fetcher.loadAccountInfo(),
+ updater: DisabledUpdaterController(),
+ preferencesSelection: PreferencesSelection(),
+ statusBar: self.makeStatusBarForTesting())
+
+ // Primary used=10%. Bonus exhausted: used=100% (remaining=0%).
+ let snapshot = UsageSnapshot(
+ primary: RateWindow(usedPercent: 10, windowMinutes: nil, resetsAt: nil, resetDescription: nil),
+ secondary: RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: nil),
+ updatedAt: Date())
+ store._setSnapshotForTesting(snapshot, provider: .warp)
+ store._setErrorForTesting(nil, provider: .warp)
+
+ controller.applyIcon(for: .warp, phase: nil)
+
+ guard let image = controller.statusItems[.warp]?.button?.image else {
+ #expect(Bool(false))
+ return
+ }
+ let rep = image.representations.compactMap { $0 as? NSBitmapImageRep }.first(where: {
+ $0.pixelsWide == 36 && $0.pixelsHigh == 36
+ })
+ #expect(rep != nil)
+ guard let rep else { return }
+
+ // In the Warp "no bonus/exhausted bonus" layout, the bottom bar is a dimmed track.
+ // A pixel near the right side of the bottom bar should remain subdued (not fully opaque).
+ let alpha = (rep.colorAt(x: 25, y: 9) ?? .clear).alphaComponent
+ #expect(alpha < 0.6)
+ }
+
+ @Test
+ func warpBonusLaneIsPreservedInShowUsedModeWhenBonusIsUnused() {
+ let settings = SettingsStore(
+ configStore: testConfigStore(suiteName: "StatusItemAnimationTests-warp-unused-bonus-used"),
+ zaiTokenStore: NoopZaiTokenStore(),
+ syntheticTokenStore: NoopSyntheticTokenStore())
+ settings.statusChecksEnabled = false
+ settings.refreshFrequency = .manual
+ settings.mergeIcons = false
+ settings.menuBarShowsBrandIconWithPercent = false
+ settings.usageBarsShowUsed = true
+
+ let registry = ProviderRegistry.shared
+ if let warpMeta = registry.metadata[.warp] {
+ settings.setProviderEnabled(provider: .warp, metadata: warpMeta, enabled: true)
+ }
+
+ let fetcher = UsageFetcher()
+ let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings)
+ let controller = StatusItemController(
+ store: store,
+ settings: settings,
+ account: fetcher.loadAccountInfo(),
+ updater: DisabledUpdaterController(),
+ preferencesSelection: PreferencesSelection(),
+ statusBar: self.makeStatusBarForTesting())
+
+ // Bonus exists but is unused: used=0% (remaining=100%).
+ let snapshot = UsageSnapshot(
+ primary: RateWindow(usedPercent: 10, windowMinutes: nil, resetsAt: nil, resetDescription: nil),
+ secondary: RateWindow(usedPercent: 0, windowMinutes: nil, resetsAt: nil, resetDescription: nil),
+ updatedAt: Date())
+ store._setSnapshotForTesting(snapshot, provider: .warp)
+ store._setErrorForTesting(nil, provider: .warp)
+
+ controller.applyIcon(for: .warp, phase: nil)
+
+ guard let image = controller.statusItems[.warp]?.button?.image else {
+ #expect(Bool(false))
+ return
+ }
+ let rep = image.representations.compactMap { $0 as? NSBitmapImageRep }.first(where: {
+ $0.pixelsWide == 36 && $0.pixelsHigh == 36
+ })
+ #expect(rep != nil)
+ guard let rep else { return }
+
+ // When we incorrectly treat "0 used" as "no bonus", the Warp branch makes the top bar full (100%).
+ // A pixel near the right side of the top bar should remain in the track-only range for 10% usage.
+ let alpha = (rep.colorAt(x: 31, y: 25) ?? .clear).alphaComponent
+ #expect(alpha < 0.6)
+ }
+
@Test
func menuBarPercentUsesConfiguredMetric() {
let settings = SettingsStore(
@@ -163,6 +282,45 @@ struct StatusItemAnimationTests {
#expect(window?.usedPercent == 42)
}
+ @Test
+ func menuBarPercentAutomaticPrefersRateLimitForKimi() {
+ let settings = SettingsStore(
+ configStore: testConfigStore(suiteName: "StatusItemAnimationTests-kimi-automatic"),
+ zaiTokenStore: NoopZaiTokenStore())
+ settings.statusChecksEnabled = false
+ settings.refreshFrequency = .manual
+ settings.mergeIcons = true
+ settings.selectedMenuProvider = .kimi
+ settings.setMenuBarMetricPreference(.automatic, for: .kimi)
+
+ let registry = ProviderRegistry.shared
+ if let kimiMeta = registry.metadata[.kimi] {
+ settings.setProviderEnabled(provider: .kimi, metadata: kimiMeta, enabled: true)
+ }
+
+ let fetcher = UsageFetcher()
+ let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings)
+ let controller = StatusItemController(
+ store: store,
+ settings: settings,
+ account: fetcher.loadAccountInfo(),
+ updater: DisabledUpdaterController(),
+ preferencesSelection: PreferencesSelection(),
+ statusBar: self.makeStatusBarForTesting())
+
+ let snapshot = UsageSnapshot(
+ primary: RateWindow(usedPercent: 12, windowMinutes: nil, resetsAt: nil, resetDescription: nil),
+ secondary: RateWindow(usedPercent: 42, windowMinutes: 300, resetsAt: nil, resetDescription: nil),
+ updatedAt: Date())
+
+ store._setSnapshotForTesting(snapshot, provider: .kimi)
+ store._setErrorForTesting(nil, provider: .kimi)
+
+ let window = controller.menuBarMetricWindow(for: .kimi, snapshot: snapshot)
+
+ #expect(window?.usedPercent == 42)
+ }
+
@Test
func menuBarPercentUsesAverageForGemini() {
let settings = SettingsStore(
@@ -341,4 +499,130 @@ struct StatusItemAnimationTests {
#expect(percentRaw == "weekly")
#expect(paceRaw == "session")
}
+
+ @Test
+ func menuBarDisplayTextUsesCreditsWhenCodexWeeklyIsExhausted() {
+ let settings = SettingsStore(
+ configStore: testConfigStore(suiteName: "StatusItemAnimationTests-credits-fallback"),
+ zaiTokenStore: NoopZaiTokenStore())
+ settings.statusChecksEnabled = false
+ settings.refreshFrequency = .manual
+ settings.mergeIcons = true
+ settings.selectedMenuProvider = .codex
+ settings.menuBarDisplayMode = .percent
+ settings.usageBarsShowUsed = false
+ settings.setMenuBarMetricPreference(.secondary, for: .codex)
+
+ let registry = ProviderRegistry.shared
+ if let codexMeta = registry.metadata[.codex] {
+ settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true)
+ }
+
+ let fetcher = UsageFetcher()
+ let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings)
+ let controller = StatusItemController(
+ store: store,
+ settings: settings,
+ account: fetcher.loadAccountInfo(),
+ updater: DisabledUpdaterController(),
+ preferencesSelection: PreferencesSelection(),
+ statusBar: self.makeStatusBarForTesting())
+
+ let snapshot = UsageSnapshot(
+ primary: RateWindow(usedPercent: 20, windowMinutes: nil, resetsAt: nil, resetDescription: nil),
+ secondary: RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: nil),
+ updatedAt: Date())
+
+ let remainingCredits = (snapshot.primary?.usedPercent ?? 0) * 4.5 + (snapshot.secondary?.usedPercent ?? 0) / 10
+ store._setSnapshotForTesting(snapshot, provider: .codex)
+ store._setErrorForTesting(nil, provider: .codex)
+ store.credits = CreditsSnapshot(remaining: remainingCredits, events: [], updatedAt: Date())
+
+ let displayText = controller.menuBarDisplayText(for: .codex, snapshot: snapshot)
+ let expected = UsageFormatter
+ .creditsString(from: remainingCredits)
+ .replacingOccurrences(of: " left", with: "")
+
+ #expect(displayText == expected)
+ }
+
+ @Test
+ func menuBarDisplayTextUsesCreditsWhenCodexSessionIsExhausted() {
+ let settings = SettingsStore(
+ configStore: testConfigStore(suiteName: "StatusItemAnimationTests-credits-fallback-session"),
+ zaiTokenStore: NoopZaiTokenStore())
+ settings.statusChecksEnabled = false
+ settings.refreshFrequency = .manual
+ settings.mergeIcons = true
+ settings.selectedMenuProvider = .codex
+ settings.menuBarDisplayMode = .percent
+ settings.usageBarsShowUsed = false
+ settings.setMenuBarMetricPreference(.primary, for: .codex)
+
+ let registry = ProviderRegistry.shared
+ if let codexMeta = registry.metadata[.codex] {
+ settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true)
+ }
+
+ let fetcher = UsageFetcher()
+ let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings)
+ let controller = StatusItemController(
+ store: store,
+ settings: settings,
+ account: fetcher.loadAccountInfo(),
+ updater: DisabledUpdaterController(),
+ preferencesSelection: PreferencesSelection(),
+ statusBar: self.makeStatusBarForTesting())
+
+ let snapshot = UsageSnapshot(
+ primary: RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: nil),
+ secondary: RateWindow(usedPercent: 40, windowMinutes: nil, resetsAt: nil, resetDescription: nil),
+ updatedAt: Date())
+
+ let remainingCredits = (snapshot.primary?.usedPercent ?? 0) - (snapshot.secondary?.usedPercent ?? 0) / 2
+ store._setSnapshotForTesting(snapshot, provider: .codex)
+ store._setErrorForTesting(nil, provider: .codex)
+ store.credits = CreditsSnapshot(remaining: remainingCredits, events: [], updatedAt: Date())
+
+ let displayText = controller.menuBarDisplayText(for: .codex, snapshot: snapshot)
+ let expected = UsageFormatter
+ .creditsString(from: remainingCredits)
+ .replacingOccurrences(of: " left", with: "")
+
+ #expect(displayText == expected)
+ }
+
+ @Test
+ func brandImageWithStatusOverlayReturnsOriginalImageWhenNoIssue() {
+ let brand = NSImage(size: NSSize(width: 16, height: 16))
+ brand.isTemplate = true
+
+ let output = StatusItemController.brandImageWithStatusOverlay(brand: brand, statusIndicator: .none)
+
+ #expect(output === brand)
+ }
+
+ @Test
+ func brandImageWithStatusOverlayDrawsIssueMark() throws {
+ let size = NSSize(width: 16, height: 16)
+ let brand = NSImage(size: size)
+ brand.lockFocus()
+ NSColor.clear.setFill()
+ NSBezierPath(rect: NSRect(origin: .zero, size: size)).fill()
+ brand.unlockFocus()
+ brand.isTemplate = true
+
+ let baselineData = try #require(brand.tiffRepresentation)
+ let baselineRep = try #require(NSBitmapImageRep(data: baselineData))
+ let baselineAlpha = self.maxAlpha(in: baselineRep)
+
+ let output = StatusItemController.brandImageWithStatusOverlay(brand: brand, statusIndicator: .major)
+
+ #expect(output !== brand)
+ let outputData = try #require(output.tiffRepresentation)
+ let outputRep = try #require(NSBitmapImageRep(data: outputData))
+ let outputAlpha = self.maxAlpha(in: outputRep)
+ #expect(baselineAlpha < 0.01)
+ #expect(outputAlpha > 0.01)
+ }
}
diff --git a/Tests/CodexBarTests/StatusItemControllerMenuTests.swift b/Tests/CodexBarTests/StatusItemControllerMenuTests.swift
index 9e83f1238..976a0d965 100644
--- a/Tests/CodexBarTests/StatusItemControllerMenuTests.swift
+++ b/Tests/CodexBarTests/StatusItemControllerMenuTests.swift
@@ -50,4 +50,59 @@ struct StatusItemControllerMenuTests {
#expect(percent == 80)
}
+
+ @Test
+ func openRouterBrandFallbackEnabledWhenNoKeyLimitConfigured() {
+ let snapshot = OpenRouterUsageSnapshot(
+ totalCredits: 50,
+ totalUsage: 45,
+ balance: 5,
+ usedPercent: 90,
+ keyDataFetched: true,
+ keyLimit: nil,
+ keyUsage: nil,
+ rateLimit: nil,
+ updatedAt: Date()).toUsageSnapshot()
+
+ #expect(StatusItemController.shouldUseOpenRouterBrandFallback(
+ provider: .openrouter,
+ snapshot: snapshot))
+ #expect(MenuBarDisplayText.percentText(window: snapshot.primary, showUsed: false) == nil)
+ }
+
+ @Test
+ func openRouterBrandFallbackDisabledWhenKeyQuotaFetchUnavailable() {
+ let snapshot = OpenRouterUsageSnapshot(
+ totalCredits: 50,
+ totalUsage: 45,
+ balance: 5,
+ usedPercent: 90,
+ keyDataFetched: false,
+ keyLimit: nil,
+ keyUsage: nil,
+ rateLimit: nil,
+ updatedAt: Date()).toUsageSnapshot()
+
+ #expect(!StatusItemController.shouldUseOpenRouterBrandFallback(
+ provider: .openrouter,
+ snapshot: snapshot))
+ }
+
+ @Test
+ func openRouterBrandFallbackDisabledWhenKeyQuotaAvailable() {
+ let snapshot = OpenRouterUsageSnapshot(
+ totalCredits: 50,
+ totalUsage: 45,
+ balance: 5,
+ usedPercent: 90,
+ keyLimit: 20,
+ keyUsage: 2,
+ rateLimit: nil,
+ updatedAt: Date()).toUsageSnapshot()
+
+ #expect(!StatusItemController.shouldUseOpenRouterBrandFallback(
+ provider: .openrouter,
+ snapshot: snapshot))
+ #expect(snapshot.primary?.usedPercent == 10)
+ }
}
diff --git a/Tests/CodexBarTests/StatusMenuTests.swift b/Tests/CodexBarTests/StatusMenuTests.swift
index 37d33c5e8..212ecd897 100644
--- a/Tests/CodexBarTests/StatusMenuTests.swift
+++ b/Tests/CodexBarTests/StatusMenuTests.swift
@@ -75,6 +75,161 @@ struct StatusMenuTests {
#expect(controller.lastMenuProvider == .codex)
}
+ @Test
+ func mergedMenuOpenDoesNotPersistResolvedProviderWhenSelectionIsNil() {
+ self.disableMenuCardsForTesting()
+ let settings = self.makeSettings()
+ settings.statusChecksEnabled = false
+ settings.refreshFrequency = .manual
+ settings.mergeIcons = true
+ settings.selectedMenuProvider = nil
+
+ let registry = ProviderRegistry.shared
+ var enabledProviders: [UsageProvider] = []
+ for provider in UsageProvider.allCases {
+ guard let metadata = registry.metadata[provider] else { continue }
+ let shouldEnable = enabledProviders.count < 2
+ settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: shouldEnable)
+ if shouldEnable {
+ enabledProviders.append(provider)
+ }
+ }
+ #expect(enabledProviders.count == 2)
+
+ let fetcher = UsageFetcher()
+ let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings)
+ let controller = StatusItemController(
+ store: store,
+ settings: settings,
+ account: fetcher.loadAccountInfo(),
+ updater: DisabledUpdaterController(),
+ preferencesSelection: PreferencesSelection(),
+ statusBar: self.makeStatusBarForTesting())
+
+ let expectedResolved = store.enabledProviders().first ?? .codex
+ #expect(store.enabledProviders().count > 1)
+ #expect(controller.shouldMergeIcons == true)
+ let menu = controller.makeMenu()
+ #expect(settings.selectedMenuProvider == nil)
+ controller.menuWillOpen(menu)
+ #expect(settings.selectedMenuProvider == nil)
+ #expect(controller.lastMenuProvider == expectedResolved)
+ }
+
+ @Test
+ func mergedMenuRefreshUsesResolvedEnabledProviderWhenPersistedSelectionIsDisabled() {
+ self.disableMenuCardsForTesting()
+ let settings = self.makeSettings()
+ settings.statusChecksEnabled = false
+ settings.refreshFrequency = .manual
+ settings.mergeIcons = true
+ settings.selectedMenuProvider = .codex
+
+ let registry = ProviderRegistry.shared
+ if let codexMeta = registry.metadata[.codex] {
+ settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: false)
+ }
+ if let claudeMeta = registry.metadata[.claude] {
+ settings.setProviderEnabled(provider: .claude, metadata: claudeMeta, enabled: true)
+ }
+ if let geminiMeta = registry.metadata[.gemini] {
+ settings.setProviderEnabled(provider: .gemini, metadata: geminiMeta, enabled: true)
+ }
+
+ let fetcher = UsageFetcher()
+ let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings)
+ let event = CreditEvent(date: Date(), service: "CLI", creditsUsed: 1)
+ let breakdown = OpenAIDashboardSnapshot.makeDailyBreakdown(from: [event], maxDays: 30)
+ store.openAIDashboard = OpenAIDashboardSnapshot(
+ signedInEmail: "user@example.com",
+ codeReviewRemainingPercent: 100,
+ creditEvents: [event],
+ dailyBreakdown: breakdown,
+ usageBreakdown: breakdown,
+ creditsPurchaseURL: nil,
+ updatedAt: Date())
+ let controller = StatusItemController(
+ store: store,
+ settings: settings,
+ account: fetcher.loadAccountInfo(),
+ updater: DisabledUpdaterController(),
+ preferencesSelection: PreferencesSelection(),
+ statusBar: self.makeStatusBarForTesting())
+
+ let expectedResolved = store.enabledProviders().first ?? .codex
+ #expect(store.enabledProviders().count > 1)
+ #expect(controller.shouldMergeIcons == true)
+
+ func hasOpenAIWebSubmenus(_ menu: NSMenu) -> Bool {
+ let usageItem = menu.items.first { ($0.representedObject as? String) == "menuCardUsage" }
+ let creditsItem = menu.items.first { ($0.representedObject as? String) == "menuCardCredits" }
+ let hasUsageBreakdown = usageItem?.submenu?.items
+ .contains { ($0.representedObject as? String) == "usageBreakdownChart" } == true
+ let hasCreditsHistory = creditsItem?.submenu?.items
+ .contains { ($0.representedObject as? String) == "creditsHistoryChart" } == true
+ return hasUsageBreakdown || hasCreditsHistory
+ }
+
+ let menu = controller.makeMenu()
+ controller.menuWillOpen(menu)
+
+ #expect(controller.lastMenuProvider == expectedResolved)
+ #expect(settings.selectedMenuProvider == .codex)
+ #expect(hasOpenAIWebSubmenus(menu) == false)
+
+ controller.menuContentVersion &+= 1
+ controller.refreshOpenMenusIfNeeded()
+
+ #expect(hasOpenAIWebSubmenus(menu) == false)
+ }
+
+ @Test
+ func openMergedMenuRebuildsSwitcherWhenUsageBarsModeChanges() {
+ self.disableMenuCardsForTesting()
+ let settings = self.makeSettings()
+ settings.statusChecksEnabled = false
+ settings.refreshFrequency = .manual
+ settings.mergeIcons = true
+ settings.selectedMenuProvider = .codex
+ settings.usageBarsShowUsed = false
+
+ let registry = ProviderRegistry.shared
+ for provider in UsageProvider.allCases {
+ guard let metadata = registry.metadata[provider] else { continue }
+ let shouldEnable = provider == .codex || provider == .claude
+ settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: shouldEnable)
+ }
+
+ let fetcher = UsageFetcher()
+ let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings)
+ let controller = StatusItemController(
+ store: store,
+ settings: settings,
+ account: fetcher.loadAccountInfo(),
+ updater: DisabledUpdaterController(),
+ preferencesSelection: PreferencesSelection(),
+ statusBar: self.makeStatusBarForTesting())
+
+ #expect(store.enabledProviders().count == 2)
+ #expect(controller.shouldMergeIcons == true)
+
+ let menu = controller.makeMenu()
+ controller.menuWillOpen(menu)
+
+ let initialSwitcher = menu.items.first?.view as? ProviderSwitcherView
+ #expect(initialSwitcher != nil)
+ let initialSwitcherID = initialSwitcher.map(ObjectIdentifier.init)
+
+ settings.usageBarsShowUsed = true
+ controller.handleProviderConfigChange(reason: "usageBarsShowUsed")
+
+ let updatedSwitcher = menu.items.first?.view as? ProviderSwitcherView
+ #expect(updatedSwitcher != nil)
+ if let initialSwitcherID, let updatedSwitcher {
+ #expect(initialSwitcherID != ObjectIdentifier(updatedSwitcher))
+ }
+ }
+
@Test
func providerToggleUpdatesStatusItemVisibility() {
self.disableMenuCardsForTesting()
diff --git a/Tests/CodexBarTests/TTYCommandRunnerTests.swift b/Tests/CodexBarTests/TTYCommandRunnerTests.swift
index 4c0257067..7d18f52eb 100644
--- a/Tests/CodexBarTests/TTYCommandRunnerTests.swift
+++ b/Tests/CodexBarTests/TTYCommandRunnerTests.swift
@@ -112,24 +112,28 @@ struct TTYCommandRunnerEnvTests {
let script = """
#!/bin/sh
echo "hello"
- sleep 10
+ sleep 30
"""
try script.write(to: scriptURL, atomically: true, encoding: .utf8)
try fm.setAttributes([.posixPermissions: 0o755], ofItemAtPath: scriptURL.path)
let runner = TTYCommandRunner()
- let timeout: TimeInterval = 12
- let scriptedSleep: TimeInterval = 10
- let startedAt = Date()
- let result = try runner.run(
- binary: scriptURL.path,
- send: "",
- options: .init(timeout: timeout, idleTimeout: 0.2))
- let elapsed = Date().timeIntervalSince(startedAt)
-
- #expect(result.text.contains("hello"))
- // CI runners can delay PTY scheduling/reads; assert we stop well before script completion.
- #expect(elapsed < (scriptedSleep - 1.0))
+ let timeout: TimeInterval = 6
+ var fastestElapsed = TimeInterval.greatestFiniteMagnitude
+ // CI can occasionally pause a test process long enough to miss an idle window.
+ // Retry once and assert that at least one run exits well before timeout.
+ for _ in 0..<2 {
+ let startedAt = Date()
+ let result = try runner.run(
+ binary: scriptURL.path,
+ send: "",
+ options: .init(timeout: timeout, idleTimeout: 0.2))
+ let elapsed = Date().timeIntervalSince(startedAt)
+
+ #expect(result.text.contains("hello"))
+ fastestElapsed = min(fastestElapsed, elapsed)
+ }
+ #expect(fastestElapsed < (timeout - 1.0))
}
@Test
diff --git a/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift b/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift
index 5cd8d0857..cb36b24ae 100644
--- a/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift
+++ b/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift
@@ -45,6 +45,76 @@ struct TokenAccountEnvironmentPrecedenceTests {
#expect(env[ZaiSettingsReader.apiTokenKey] != "config-token")
}
+ @Test
+ func ollamaTokenAccountSelectionForcesManualCookieSourceInCLISettingsSnapshot() throws {
+ let accounts = ProviderTokenAccountData(
+ version: 1,
+ accounts: [
+ ProviderTokenAccount(
+ id: UUID(),
+ label: "Primary",
+ token: "session=account-token",
+ addedAt: 0,
+ lastUsed: nil),
+ ],
+ activeIndex: 0)
+ let config = CodexBarConfig(
+ providers: [
+ ProviderConfig(
+ id: .ollama,
+ cookieSource: .auto,
+ tokenAccounts: accounts),
+ ])
+ let selection = TokenAccountCLISelection(label: nil, index: nil, allAccounts: false)
+ let tokenContext = try TokenAccountCLIContext(selection: selection, config: config, verbose: false)
+ let account = try #require(tokenContext.resolvedAccounts(for: .ollama).first)
+ let snapshot = try #require(tokenContext.settingsSnapshot(for: .ollama, account: account))
+ let ollamaSettings = try #require(snapshot.ollama)
+
+ #expect(ollamaSettings.cookieSource == .manual)
+ #expect(ollamaSettings.manualCookieHeader == "session=account-token")
+ }
+
+ @Test
+ func applyAccountLabelInAppPreservesSnapshotFields() {
+ let settings = Self.makeSettingsStore(suite: "TokenAccountEnvironmentPrecedenceTests-apply-app")
+ let store = Self.makeUsageStore(settings: settings)
+ let snapshot = Self.makeSnapshotWithAllFields(provider: .zai)
+ let account = ProviderTokenAccount(
+ id: UUID(),
+ label: "Team Account",
+ token: "account-token",
+ addedAt: 0,
+ lastUsed: nil)
+
+ let labeled = store.applyAccountLabel(snapshot, provider: .zai, account: account)
+
+ Self.expectSnapshotFieldsPreserved(before: snapshot, after: labeled)
+ #expect(labeled.identity?.providerID == .zai)
+ #expect(labeled.identity?.accountEmail == "Team Account")
+ }
+
+ @Test
+ func applyAccountLabelInCLIPreservesSnapshotFields() throws {
+ let context = try TokenAccountCLIContext(
+ selection: TokenAccountCLISelection(label: nil, index: nil, allAccounts: false),
+ config: CodexBarConfig(providers: []),
+ verbose: false)
+ let snapshot = Self.makeSnapshotWithAllFields(provider: .zai)
+ let account = ProviderTokenAccount(
+ id: UUID(),
+ label: "CLI Account",
+ token: "account-token",
+ addedAt: 0,
+ lastUsed: nil)
+
+ let labeled = context.applyAccountLabel(snapshot, provider: .zai, account: account)
+
+ Self.expectSnapshotFieldsPreserved(before: snapshot, after: labeled)
+ #expect(labeled.identity?.providerID == .zai)
+ #expect(labeled.identity?.accountEmail == "CLI Account")
+ }
+
private static func makeSettingsStore(suite: String) -> SettingsStore {
let defaults = UserDefaults(suiteName: suite)!
defaults.removePersistentDomain(forName: suite)
@@ -69,4 +139,85 @@ struct TokenAccountEnvironmentPrecedenceTests {
copilotTokenStore: InMemoryCopilotTokenStore(),
tokenAccountStore: InMemoryTokenAccountStore())
}
+
+ private static func makeUsageStore(settings: SettingsStore) -> UsageStore {
+ UsageStore(
+ fetcher: UsageFetcher(environment: [:]),
+ browserDetection: BrowserDetection(cacheTTL: 0),
+ settings: settings)
+ }
+
+ private static func makeSnapshotWithAllFields(provider: UsageProvider) -> UsageSnapshot {
+ let now = Date(timeIntervalSince1970: 1_700_000_000)
+ let reset = Date(timeIntervalSince1970: 1_700_003_600)
+ let tokenLimit = ZaiLimitEntry(
+ type: .tokensLimit,
+ unit: .hours,
+ number: 6,
+ usage: 200,
+ currentValue: 40,
+ remaining: 160,
+ percentage: 20,
+ usageDetails: [ZaiUsageDetail(modelCode: "glm-4", usage: 40)],
+ nextResetTime: reset)
+ let identity = ProviderIdentitySnapshot(
+ providerID: provider,
+ accountEmail: nil,
+ accountOrganization: "Org",
+ loginMethod: "Pro")
+
+ return UsageSnapshot(
+ primary: RateWindow(usedPercent: 21, windowMinutes: 60, resetsAt: reset, resetDescription: "primary"),
+ secondary: RateWindow(usedPercent: 42, windowMinutes: 1440, resetsAt: nil, resetDescription: "secondary"),
+ tertiary: RateWindow(usedPercent: 7, windowMinutes: nil, resetsAt: nil, resetDescription: "tertiary"),
+ providerCost: ProviderCostSnapshot(
+ used: 12.5,
+ limit: 25,
+ currencyCode: "USD",
+ period: "Monthly",
+ resetsAt: reset,
+ updatedAt: now),
+ zaiUsage: ZaiUsageSnapshot(
+ tokenLimit: tokenLimit,
+ timeLimit: nil,
+ planName: "Z.ai Pro",
+ updatedAt: now),
+ minimaxUsage: MiniMaxUsageSnapshot(
+ planName: "MiniMax",
+ availablePrompts: 500,
+ currentPrompts: 120,
+ remainingPrompts: 380,
+ windowMinutes: 1440,
+ usedPercent: 24,
+ resetsAt: reset,
+ updatedAt: now),
+ openRouterUsage: OpenRouterUsageSnapshot(
+ totalCredits: 50,
+ totalUsage: 10,
+ balance: 40,
+ usedPercent: 20,
+ rateLimit: nil,
+ updatedAt: now),
+ cursorRequests: CursorRequestUsage(used: 7, limit: 70),
+ updatedAt: now,
+ identity: identity)
+ }
+
+ private static func expectSnapshotFieldsPreserved(before: UsageSnapshot, after: UsageSnapshot) {
+ #expect(after.primary?.usedPercent == before.primary?.usedPercent)
+ #expect(after.secondary?.usedPercent == before.secondary?.usedPercent)
+ #expect(after.tertiary?.usedPercent == before.tertiary?.usedPercent)
+ #expect(after.providerCost?.used == before.providerCost?.used)
+ #expect(after.providerCost?.limit == before.providerCost?.limit)
+ #expect(after.providerCost?.currencyCode == before.providerCost?.currencyCode)
+ #expect(after.zaiUsage?.planName == before.zaiUsage?.planName)
+ #expect(after.zaiUsage?.tokenLimit?.usage == before.zaiUsage?.tokenLimit?.usage)
+ #expect(after.minimaxUsage?.planName == before.minimaxUsage?.planName)
+ #expect(after.minimaxUsage?.availablePrompts == before.minimaxUsage?.availablePrompts)
+ #expect(after.openRouterUsage?.balance == before.openRouterUsage?.balance)
+ #expect(after.openRouterUsage?.rateLimit?.requests == before.openRouterUsage?.rateLimit?.requests)
+ #expect(after.cursorRequests?.used == before.cursorRequests?.used)
+ #expect(after.cursorRequests?.limit == before.cursorRequests?.limit)
+ #expect(after.updatedAt == before.updatedAt)
+ }
}
diff --git a/Tests/CodexBarTests/UsageStoreCoverageTests.swift b/Tests/CodexBarTests/UsageStoreCoverageTests.swift
index 889ff4f20..32ea42851 100644
--- a/Tests/CodexBarTests/UsageStoreCoverageTests.swift
+++ b/Tests/CodexBarTests/UsageStoreCoverageTests.swift
@@ -71,6 +71,34 @@ struct UsageStoreCoverageTests {
#expect(label.contains("openai-web"))
}
+ @Test
+ func providerWithHighestUsagePrefersKimiRateLimitWindow() throws {
+ let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-kimi-highest")
+ let store = Self.makeUsageStore(settings: settings)
+ let metadata = ProviderRegistry.shared.metadata
+
+ try settings.setProviderEnabled(provider: .codex, metadata: #require(metadata[.codex]), enabled: true)
+ try settings.setProviderEnabled(provider: .kimi, metadata: #require(metadata[.kimi]), enabled: true)
+
+ let now = Date()
+ store._setSnapshotForTesting(
+ UsageSnapshot(
+ primary: RateWindow(usedPercent: 60, windowMinutes: nil, resetsAt: nil, resetDescription: nil),
+ secondary: nil,
+ updatedAt: now),
+ provider: .codex)
+ store._setSnapshotForTesting(
+ UsageSnapshot(
+ primary: RateWindow(usedPercent: 10, windowMinutes: nil, resetsAt: nil, resetDescription: nil),
+ secondary: RateWindow(usedPercent: 80, windowMinutes: 300, resetsAt: nil, resetDescription: nil),
+ updatedAt: now),
+ provider: .kimi)
+
+ let highest = store.providerWithHighestUsage()
+ #expect(highest?.provider == .kimi)
+ #expect(highest?.usedPercent == 80)
+ }
+
@Test
func providerAvailabilityAndSubscriptionDetection() {
let zaiStore = InMemoryZaiTokenStore(value: "zai-token")
diff --git a/Tests/CodexBarTests/WarpUsageFetcherTests.swift b/Tests/CodexBarTests/WarpUsageFetcherTests.swift
new file mode 100644
index 000000000..9fb74999c
--- /dev/null
+++ b/Tests/CodexBarTests/WarpUsageFetcherTests.swift
@@ -0,0 +1,231 @@
+import Foundation
+import Testing
+@testable import CodexBarCore
+
+@Suite
+struct WarpUsageFetcherTests {
+ @Test
+ func parsesSnapshotAndAggregatesBonusCredits() throws {
+ let json = """
+ {
+ "data": {
+ "user": {
+ "__typename": "UserOutput",
+ "user": {
+ "requestLimitInfo": {
+ "isUnlimited": false,
+ "nextRefreshTime": "2026-02-28T19:16:33.462988Z",
+ "requestLimit": 1500,
+ "requestsUsedSinceLastRefresh": 5
+ },
+ "bonusGrants": [
+ {
+ "requestCreditsGranted": 20,
+ "requestCreditsRemaining": 10,
+ "expiration": "2026-03-01T10:00:00Z"
+ }
+ ],
+ "workspaces": [
+ {
+ "bonusGrantsInfo": {
+ "grants": [
+ {
+ "requestCreditsGranted": "15",
+ "requestCreditsRemaining": "5",
+ "expiration": "2026-03-15T10:00:00Z"
+ }
+ ]
+ }
+ }
+ ]
+ }
+ }
+ }
+ }
+ """
+
+ let snapshot = try WarpUsageFetcher._parseResponseForTesting(Data(json.utf8))
+ let formatter = ISO8601DateFormatter()
+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+ let expectedRefresh = formatter.date(from: "2026-02-28T19:16:33.462988Z")
+ let expectedExpiry = ISO8601DateFormatter().date(from: "2026-03-01T10:00:00Z")
+
+ #expect(snapshot.requestLimit == 1500)
+ #expect(snapshot.requestsUsed == 5)
+ #expect(snapshot.isUnlimited == false)
+ #expect(snapshot.nextRefreshTime != nil)
+ #expect(abs((snapshot.nextRefreshTime?.timeIntervalSince1970 ?? 0) -
+ (expectedRefresh?.timeIntervalSince1970 ?? 0))
+ < 0.5)
+ #expect(snapshot.bonusCreditsTotal == 35)
+ #expect(snapshot.bonusCreditsRemaining == 15)
+ #expect(snapshot.bonusNextExpirationRemaining == 10)
+ #expect(abs((snapshot.bonusNextExpiration?.timeIntervalSince1970 ?? 0) -
+ (expectedExpiry?.timeIntervalSince1970 ?? 0))
+ < 0.5)
+ }
+
+ @Test
+ func graphQLErrorsThrowAPIError() {
+ let json = """
+ {
+ "errors": [
+ { "message": "Unauthorized" }
+ ]
+ }
+ """
+
+ #expect {
+ _ = try WarpUsageFetcher._parseResponseForTesting(Data(json.utf8))
+ } throws: { error in
+ guard case let WarpUsageError.apiError(code, message) = error else { return false }
+ return code == 200 && message.contains("Unauthorized")
+ }
+ }
+
+ @Test
+ func nullUnlimitedAndStringNumericsParseSafely() throws {
+ let json = """
+ {
+ "data": {
+ "user": {
+ "__typename": "UserOutput",
+ "user": {
+ "requestLimitInfo": {
+ "isUnlimited": null,
+ "nextRefreshTime": "2026-02-28T19:16:33Z",
+ "requestLimit": "1500",
+ "requestsUsedSinceLastRefresh": "5"
+ }
+ }
+ }
+ }
+ }
+ """
+
+ let snapshot = try WarpUsageFetcher._parseResponseForTesting(Data(json.utf8))
+
+ #expect(snapshot.isUnlimited == false)
+ #expect(snapshot.requestLimit == 1500)
+ #expect(snapshot.requestsUsed == 5)
+ #expect(snapshot.nextRefreshTime != nil)
+ }
+
+ @Test
+ func unexpectedTypenameReturnsParseError() {
+ let json = """
+ {
+ "data": {
+ "user": {
+ "__typename": "AuthError"
+ }
+ }
+ }
+ """
+
+ #expect {
+ _ = try WarpUsageFetcher._parseResponseForTesting(Data(json.utf8))
+ } throws: { error in
+ guard case let WarpUsageError.parseFailed(message) = error else { return false }
+ return message.contains("Unexpected user type")
+ }
+ }
+
+ @Test
+ func missingRequestLimitInfoReturnsParseError() {
+ let json = """
+ {
+ "data": {
+ "user": {
+ "__typename": "UserOutput",
+ "user": {}
+ }
+ }
+ }
+ """
+
+ #expect {
+ _ = try WarpUsageFetcher._parseResponseForTesting(Data(json.utf8))
+ } throws: { error in
+ guard case let WarpUsageError.parseFailed(message) = error else { return false }
+ return message.contains("requestLimitInfo")
+ }
+ }
+
+ @Test
+ func invalidRootReturnsParseError() {
+ let json = """
+ [{ "data": {} }]
+ """
+
+ #expect {
+ _ = try WarpUsageFetcher._parseResponseForTesting(Data(json.utf8))
+ } throws: { error in
+ guard case let WarpUsageError.parseFailed(message) = error else { return false }
+ return message == "Root JSON is not an object."
+ }
+ }
+
+ @Test
+ func toUsageSnapshotOmitsSecondaryWhenNoBonusCredits() {
+ let source = WarpUsageSnapshot(
+ requestLimit: 100,
+ requestsUsed: 10,
+ nextRefreshTime: Date().addingTimeInterval(3600),
+ isUnlimited: false,
+ updatedAt: Date(),
+ bonusCreditsRemaining: 0,
+ bonusCreditsTotal: 0,
+ bonusNextExpiration: nil,
+ bonusNextExpirationRemaining: 0)
+
+ let snapshot = source.toUsageSnapshot()
+ #expect(snapshot.secondary == nil)
+ }
+
+ @Test
+ func toUsageSnapshotKeepsBonusWindowWhenBonusExists() throws {
+ let source = WarpUsageSnapshot(
+ requestLimit: 100,
+ requestsUsed: 10,
+ nextRefreshTime: Date().addingTimeInterval(3600),
+ isUnlimited: false,
+ updatedAt: Date(),
+ bonusCreditsRemaining: 0,
+ bonusCreditsTotal: 20,
+ bonusNextExpiration: nil,
+ bonusNextExpirationRemaining: 0)
+
+ let snapshot = source.toUsageSnapshot()
+ let secondary = try #require(snapshot.secondary)
+ #expect(secondary.usedPercent == 100)
+ }
+
+ @Test
+ func toUsageSnapshotUnlimitedPrimaryDoesNotShowResetDate() throws {
+ let source = WarpUsageSnapshot(
+ requestLimit: 0,
+ requestsUsed: 0,
+ nextRefreshTime: Date().addingTimeInterval(3600),
+ isUnlimited: true,
+ updatedAt: Date(),
+ bonusCreditsRemaining: 0,
+ bonusCreditsTotal: 0,
+ bonusNextExpiration: nil,
+ bonusNextExpirationRemaining: 0)
+
+ let snapshot = source.toUsageSnapshot()
+ let primary = try #require(snapshot.primary)
+ #expect(primary.resetsAt == nil)
+ #expect(primary.resetDescription == "Unlimited")
+ }
+
+ @Test
+ func apiErrorSummaryIncludesPlainTextBodies() {
+ // Regression: Warp edge returns 429 with a non-JSON body ("Rate exceeded.") when User-Agent is missing/wrong.
+ let summary = WarpUsageFetcher._apiErrorSummaryForTesting(
+ statusCode: 429,
+ data: Data("Rate exceeded.".utf8))
+ #expect(summary.contains("Rate exceeded."))
+ }
+}
diff --git a/Tests/CodexBarTests/ZaiAvailabilityTests.swift b/Tests/CodexBarTests/ZaiAvailabilityTests.swift
index ec0d40d9f..6ce229589 100644
--- a/Tests/CodexBarTests/ZaiAvailabilityTests.swift
+++ b/Tests/CodexBarTests/ZaiAvailabilityTests.swift
@@ -29,6 +29,30 @@ struct ZaiAvailabilityTests {
#expect(store.isEnabled(.zai) == true)
#expect(settings.zaiAPIToken == "zai-test-token")
}
+
+ @Test
+ func enablesZaiWhenTokenExistsInTokenAccounts() throws {
+ let suite = "ZaiAvailabilityTests-token-accounts"
+ let defaults = try #require(UserDefaults(suiteName: suite))
+ defaults.removePersistentDomain(forName: suite)
+ let configStore = testConfigStore(suiteName: suite)
+
+ let settings = SettingsStore(
+ userDefaults: defaults,
+ configStore: configStore,
+ zaiTokenStore: NoopZaiTokenStore())
+ let store = UsageStore(
+ fetcher: UsageFetcher(environment: [:]),
+ browserDetection: BrowserDetection(cacheTTL: 0),
+ settings: settings)
+
+ settings.addTokenAccount(provider: .zai, label: "primary", token: "zai-token-account")
+
+ let metadata = try #require(ProviderRegistry.shared.metadata[.zai])
+ settings.setProviderEnabled(provider: .zai, metadata: metadata, enabled: true)
+
+ #expect(store.isEnabled(.zai) == true)
+ }
}
private struct StubZaiTokenStore: ZaiTokenStoring {
diff --git a/Tests/CodexBarTests/ZaiProviderTests.swift b/Tests/CodexBarTests/ZaiProviderTests.swift
index 8b094ba8b..c4184396a 100644
--- a/Tests/CodexBarTests/ZaiProviderTests.swift
+++ b/Tests/CodexBarTests/ZaiProviderTests.swift
@@ -71,10 +71,120 @@ struct ZaiUsageSnapshotTests {
#expect(usage.secondary?.resetDescription == "30 days window")
#expect(usage.zaiUsage?.tokenLimit?.usage == 100)
}
+
+ @Test
+ func mapsUsageSnapshotWindowsWithMissingFields() {
+ let reset = Date(timeIntervalSince1970: 123)
+ let tokenLimit = ZaiLimitEntry(
+ type: .tokensLimit,
+ unit: .hours,
+ number: 5,
+ usage: nil,
+ currentValue: nil,
+ remaining: nil,
+ percentage: 25,
+ usageDetails: [],
+ nextResetTime: reset)
+ let snapshot = ZaiUsageSnapshot(
+ tokenLimit: tokenLimit,
+ timeLimit: nil,
+ planName: nil,
+ updatedAt: reset)
+
+ let usage = snapshot.toUsageSnapshot()
+
+ #expect(usage.primary?.usedPercent == 25)
+ #expect(usage.primary?.windowMinutes == 300)
+ #expect(usage.primary?.resetsAt == reset)
+ #expect(usage.primary?.resetDescription == "5 hours window")
+ #expect(usage.zaiUsage?.tokenLimit?.usage == nil)
+ }
+
+ @Test
+ func mapsUsageSnapshotWindowsWithMissingRemainingUsesCurrentValue() {
+ let reset = Date(timeIntervalSince1970: 123)
+ let tokenLimit = ZaiLimitEntry(
+ type: .tokensLimit,
+ unit: .hours,
+ number: 5,
+ usage: 100,
+ currentValue: 20,
+ remaining: nil,
+ percentage: 25,
+ usageDetails: [],
+ nextResetTime: reset)
+ let snapshot = ZaiUsageSnapshot(
+ tokenLimit: tokenLimit,
+ timeLimit: nil,
+ planName: nil,
+ updatedAt: reset)
+
+ let usage = snapshot.toUsageSnapshot()
+
+ #expect(usage.primary?.usedPercent == 20)
+ }
+
+ @Test
+ func mapsUsageSnapshotWindowsWithMissingCurrentValueUsesRemaining() {
+ let reset = Date(timeIntervalSince1970: 123)
+ let tokenLimit = ZaiLimitEntry(
+ type: .tokensLimit,
+ unit: .hours,
+ number: 5,
+ usage: 100,
+ currentValue: nil,
+ remaining: 80,
+ percentage: 25,
+ usageDetails: [],
+ nextResetTime: reset)
+ let snapshot = ZaiUsageSnapshot(
+ tokenLimit: tokenLimit,
+ timeLimit: nil,
+ planName: nil,
+ updatedAt: reset)
+
+ let usage = snapshot.toUsageSnapshot()
+
+ #expect(usage.primary?.usedPercent == 20)
+ }
+
+ @Test
+ func mapsUsageSnapshotWindowsWithMissingRemainingAndCurrentValueFallsBackToPercentage() {
+ let reset = Date(timeIntervalSince1970: 123)
+ let tokenLimit = ZaiLimitEntry(
+ type: .tokensLimit,
+ unit: .hours,
+ number: 5,
+ usage: 100,
+ currentValue: nil,
+ remaining: nil,
+ percentage: 25,
+ usageDetails: [],
+ nextResetTime: reset)
+ let snapshot = ZaiUsageSnapshot(
+ tokenLimit: tokenLimit,
+ timeLimit: nil,
+ planName: nil,
+ updatedAt: reset)
+
+ let usage = snapshot.toUsageSnapshot()
+
+ #expect(usage.primary?.usedPercent == 25)
+ }
}
@Suite
struct ZaiUsageParsingTests {
+ @Test
+ func emptyBodyReturnsParseFailed() {
+ #expect {
+ _ = try ZaiUsageFetcher.parseUsageSnapshot(from: Data())
+ } throws: { error in
+ guard case let ZaiUsageError.parseFailed(message) = error else { return false }
+ return message == "Empty response body"
+ }
+ }
+
@Test
func parsesUsageResponse() throws {
let json = """
@@ -117,6 +227,7 @@ struct ZaiUsageParsingTests {
#expect(snapshot.planName == "Pro")
#expect(snapshot.tokenLimit?.usage == 40_000_000)
#expect(snapshot.timeLimit?.usageDetails.first?.modelCode == "search-prime")
+ #expect(snapshot.tokenLimit?.percentage == 34.0)
}
@Test
@@ -164,6 +275,52 @@ struct ZaiUsageParsingTests {
#expect(snapshot.tokenLimit == nil)
#expect(snapshot.timeLimit == nil)
}
+
+ @Test
+ func parsesNewSchemaWithMissingTokenLimitFields() throws {
+ let json = """
+ {
+ "code": 200,
+ "msg": "Operation successful",
+ "data": {
+ "limits": [
+ {
+ "type": "TIME_LIMIT",
+ "unit": 5,
+ "number": 1,
+ "usage": 100,
+ "currentValue": 0,
+ "remaining": 100,
+ "percentage": 0,
+ "usageDetails": [
+ { "modelCode": "search-prime", "usage": 0 },
+ { "modelCode": "web-reader", "usage": 1 },
+ { "modelCode": "zread", "usage": 0 }
+ ]
+ },
+ {
+ "type": "TOKENS_LIMIT",
+ "unit": 3,
+ "number": 5,
+ "percentage": 1,
+ "nextResetTime": 1770724088678
+ }
+ ]
+ },
+ "success": true
+ }
+ """
+
+ let snapshot = try ZaiUsageFetcher.parseUsageSnapshot(from: Data(json.utf8))
+
+ #expect(snapshot.tokenLimit?.percentage == 1.0)
+ #expect(snapshot.tokenLimit?.usage == nil)
+ #expect(snapshot.tokenLimit?.currentValue == nil)
+ #expect(snapshot.tokenLimit?.remaining == nil)
+ #expect(snapshot.tokenLimit?.usedPercent == 1.0)
+ #expect(snapshot.tokenLimit?.windowMinutes == 300)
+ #expect(snapshot.timeLimit?.usage == 100)
+ }
}
@Suite
diff --git a/appcast.xml b/appcast.xml
index 3f63a89e9..c7bc8f030 100644
--- a/appcast.xml
+++ b/appcast.xml
@@ -2,6 +2,68 @@
CodexBar
+ -
+ 0.18.0-beta.3
+ Fri, 13 Feb 2026 18:57:54 +0100
+ https://raw.githubusercontent.com/steipete/CodexBar/main/appcast.xml
+ 51
+ 0.18.0-beta.3
+ 14.0
+ CodexBar 0.18.0-beta.3
+
Highlights
+
+- Claude OAuth/keychain flows were reworked across a series of follow-up PRs to reduce prompt storms, stabilize background behavior, surface a setting to control prompt policy and make failure modes deterministic (#245, #305, #308, #309, #364). Thanks @manikv12!
+- Claude: harden Claude Code PTY capture for
/usage and /status (prompt automation, safer command palette confirmation, partial UTF-8 handling, and parsing guards against status-bar context meters) (#320).
+- New provider: Warp (credits + add-on credits) (#352). Thanks @Kathie-yu!
+- Provider correctness fixes landed for Cursor plan parsing and MiniMax region routing (#240, #234, #344). Thanks @robinebers and @theglove44!
+- Menu bar animation behavior was hardened in merged mode and fallback mode (#283, #291). Thanks @vignesh07 and @Ilakiancs!
+- CI/tooling reliability improved via pinned lint tools, deterministic macOS test execution, and PTY timing test stabilization plus Node 24-ready GitHub Actions upgrades (#292, #312, #290).
+
+Claude OAuth & Keychain
+
+- Claude OAuth creds are cached in CodexBar Keychain to reduce repeated prompts.
+- Prompts can still appear when Claude OAuth credentials are expired, invalid, or missing and re-auth is required.
+- In Auto mode, background refresh keeps prompts suppressed; interactive prompts are limited to user actions (menu open or manual refresh).
+- OAuth-only mode remains strict (no silent Web/CLI fallback); Auto mode may do one delegated CLI refresh + one OAuth retry before falling back.
+- Preferences now expose a Claude Keychain prompt policy (Never / Only on user action / Always allow prompts) under Providers → Claude; if global Keychain access is disabled in Advanced, this control remains visible but inactive.
+
+Provider & Usage Fixes
+
+- Warp: add Warp provider support (credits + add-on credits), configurable via Settings or
WARP_API_KEY/WARP_TOKEN (#352). Thanks @Kathie-yu!
+- Cursor: compute usage against
plan.limit rather than breakdown.total to avoid incorrect limit interpretation (#240). Thanks @robinebers!
+- MiniMax: correct API region URL selection to route requests to the expected regional endpoint (#234). Thanks @theglove44!
+- MiniMax: always show the API region picker and retry the China endpoint when the global host rejects the token to avoid upgrade regressions for users without a persisted region (#344). Thanks @apoorvdarshan!
+- Claude: add Opus 4.6 pricing so token cost scanning tracks USD consumed correctly (#348). Thanks @arandaschimpf!
+- z.ai: handle quota responses with missing token-limit fields, avoid incorrect used-percent calculations, and harden empty-response behavior with safer logging (#346). Thanks @MohamedMohana and @halilertekin!
+- z.ai: fix provider visibility in the menu when enabled with token-account credentials (availability now considers the effective fetch environment).
+- Amp: detect login redirects during usage fetch and fail fast when the session is invalid (#339). Thanks @JosephDoUrden!
+- Resource loading: fix app bundle lookup path to avoid "could not load resource bundle" startup failures (#223). Thanks @validatedev!
+- OpenAI Web dashboard: keep WebView instances cached for reuse to reduce repeated network fetch overhead; tests were updated to avoid network-dependent flakes (#284). Thanks @vignesh07!
+- Token-account precedence: selected token account env injection now correctly overrides provider config
apiKey values in app and CLI environments. Thanks @arvindcr4!
+- Claude: make Claude CLI probing more resilient by scoping auto-input to the active subcommand and trimming to the latest Usage panel before parsing to avoid false matches from earlier screen fragments (#320).
+
+Menu Bar & UI Behavior
+
+- Prevent fallback-provider loading animation loops (battery/CPU drain when no providers are enabled) (#283). Thanks @vignesh07!
+- Prevent status overlay rendering for disabled providers while in merged mode (#291). Thanks @Ilakiancs!
+
+CI, Tooling & Test Stability
+
+- Pin SwiftFormat/SwiftLint versions and harden lint installer behavior (version drift + temp-file leak fixes) (#292).
+- Use more deterministic macOS CI test settings (including non-parallel paths where needed) and align runner/toolchain behavior for stability (#292).
+- Stabilize PTY command timing tests to reduce CI flakiness (#312).
+- Upgrade
actions/checkout to v6 and actions/github-script to v8 for Node 24 compatibility in upstream-monitor.yml (#290). Thanks @salmanmkc!
+- Tests: add TaskLocal-based keychain/cache overrides so keychain gating and KeychainCacheStore test stores do not leak across concurrent test execution (#320).
+
+Docs & Maintenance
+
+- Update docs for Claude data fetch behavior and keychain troubleshooting notes.
+- Update MIT license year.
+
+View full changelog
+]]>
+
+
-
0.18.0-beta.2
Wed, 21 Jan 2026 08:42:37 +0000
diff --git a/docs/claude.md b/docs/claude.md
index 564672dda..22737efd9 100644
--- a/docs/claude.md
+++ b/docs/claude.md
@@ -15,12 +15,22 @@ Claude supports three usage data paths plus local cost usage. Source selection i
### Default selection (debug menu disabled)
1) OAuth API (if Claude CLI credentials include `user:profile` scope).
-2) Web API (browser cookies, `sessionKey`), if OAuth missing.
-3) CLI PTY (`claude`), if no OAuth and no web session.
+2) CLI PTY (`claude`), if OAuth is unavailable or fails.
+3) Web API (browser cookies, `sessionKey`), if OAuth + CLI are unavailable or fail.
Usage source picker:
- Preferences → Providers → Claude → Usage source (Auto/OAuth/Web/CLI).
+## Keychain prompt policy (Claude OAuth)
+- Preferences → Providers → Claude → Keychain prompt policy.
+- Options:
+ - `Never prompt`: never attempts interactive Claude OAuth Keychain prompts.
+ - `Only on user action` (default): interactive prompts are reserved for user-initiated repair flows.
+ - `Always allow prompts`: allows interactive prompts in both user and background flows.
+- This setting only affects Claude OAuth Keychain prompting behavior; it does not switch your Claude usage source.
+- If Preferences → Advanced → Disable Keychain access is enabled, this policy remains visible but inactive until
+ Keychain access is re-enabled.
+
### Debug selection (debug menu enabled)
- The Debug pane can force OAuth / Web / CLI.
- Web extras are internal-only (not exposed in the Providers pane).
diff --git a/docs/configuration.md b/docs/configuration.md
index 020293718..467207988 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -43,7 +43,7 @@ All provider fields are optional unless noted.
- `enabled`: enable/disable provider (defaults to provider default).
- `source`: preferred source mode.
- `auto|web|cli|oauth|api`
- - `auto` uses web where possible, with CLI fallback.
+ - `auto` uses provider-specific fallback order (see `docs/providers.md`).
- `api` uses provider API key flow (when supported).
- `apiKey`: raw API token for providers that support direct API usage.
- `cookieSource`: cookie selection policy.
diff --git a/docs/ollama.md b/docs/ollama.md
new file mode 100644
index 000000000..5be112746
--- /dev/null
+++ b/docs/ollama.md
@@ -0,0 +1,53 @@
+---
+summary: "Ollama provider notes: settings scrape, cookie auth, and Cloud Usage parsing."
+read_when:
+ - Adding or modifying the Ollama provider
+ - Debugging Ollama cookie import or settings parsing
+ - Adjusting Ollama menu labels or usage mapping
+---
+
+# Ollama Provider
+
+The Ollama provider scrapes the **Plan & Billing** page to extract Cloud Usage limits for session and weekly windows.
+
+## Features
+
+- **Plan badge**: Reads the plan tier (Free/Pro/Max) from the Cloud Usage header.
+- **Session + weekly usage**: Parses the percent-used values shown in the usage bars.
+- **Reset timestamps**: Uses the `data-time` attribute on the “Resets in …” elements.
+- **Browser cookie auth**: No API keys required.
+
+## Setup
+
+1. Open **Settings → Providers**.
+2. Enable **Ollama**.
+3. Leave **Cookie source** on **Auto** (recommended, imports Chrome cookies by default).
+
+### Manual cookie import (optional)
+
+1. Open `https://ollama.com/settings` in your browser.
+2. Copy a `Cookie:` header from the Network tab.
+3. Paste it into **Ollama → Cookie source → Manual**.
+
+## How it works
+
+- Fetches `https://ollama.com/settings` using browser cookies.
+- Parses:
+ - Plan badge under **Cloud Usage**.
+ - **Session usage** and **Weekly usage** percentages.
+ - `data-time` ISO timestamps for reset times.
+
+## Troubleshooting
+
+### “No Ollama session cookie found”
+
+Log in to `https://ollama.com/settings` in Chrome, then refresh in CodexBar.
+If your active session is only in Safari (or another browser), use **Cookie source → Manual** and paste a cookie header.
+
+### “Ollama session cookie expired”
+
+Sign out and back in at `https://ollama.com/settings`, then refresh.
+
+### “Could not parse Ollama usage”
+
+The settings page HTML may have changed. Capture the latest page HTML and update `OllamaUsageParser`.
diff --git a/docs/openrouter.md b/docs/openrouter.md
new file mode 100644
index 000000000..a0d7985e3
--- /dev/null
+++ b/docs/openrouter.md
@@ -0,0 +1,56 @@
+# OpenRouter Provider
+
+[OpenRouter](https://openrouter.ai) is a unified API that provides access to multiple AI models from different providers (OpenAI, Anthropic, Google, Meta, and more) through a single endpoint.
+
+## Authentication
+
+OpenRouter uses API key authentication. Get your API key from [OpenRouter Settings](https://openrouter.ai/settings/keys).
+
+### Environment Variable
+
+Set the `OPENROUTER_API_KEY` environment variable:
+
+```bash
+export OPENROUTER_API_KEY="sk-or-v1-..."
+```
+
+### Settings
+
+You can also configure the API key in CodexBar Settings → Providers → OpenRouter.
+
+## Data Source
+
+The OpenRouter provider fetches usage data from two API endpoints:
+
+1. **Credits API** (`/api/v1/credits`): Returns total credits purchased and total usage. The balance is calculated as `total_credits - total_usage`.
+
+2. **Key API** (`/api/v1/key`): Returns rate limit information for your API key.
+
+## Display
+
+The OpenRouter menu card shows:
+
+- **Primary meter**: Credit usage percentage (how much of your purchased credits have been used)
+- **Balance**: Displayed in the identity section as "Balance: $X.XX"
+
+## CLI Usage
+
+```bash
+codexbar --provider openrouter
+codexbar -p or # alias
+```
+
+## Environment Variables
+
+| Variable | Description |
+|----------|-------------|
+| `OPENROUTER_API_KEY` | Your OpenRouter API key (required) |
+| `OPENROUTER_API_URL` | Override the base API URL (optional, defaults to `https://openrouter.ai/api/v1`) |
+| `OPENROUTER_HTTP_REFERER` | Optional client referer sent as `HTTP-Referer` header |
+| `OPENROUTER_X_TITLE` | Optional client title sent as `X-Title` header (defaults to `CodexBar`) |
+
+## Notes
+
+- Credit values are cached on OpenRouter's side and may be up to 60 seconds stale
+- OpenRouter uses a credit-based billing system where you pre-purchase credits
+- Rate limits depend on your credit balance (10+ credits = 1000 free model requests/day)
diff --git a/docs/providers.md b/docs/providers.md
index d25dfb887..5b6126847 100644
--- a/docs/providers.md
+++ b/docs/providers.md
@@ -1,5 +1,5 @@
---
-summary: "Provider data sources and parsing overview (Codex, Claude, Gemini, Antigravity, Cursor, Droid/Factory, z.ai, Copilot, Kimi, Kimi K2, Kiro, Vertex AI, Augment, Amp, JetBrains AI)."
+summary: "Provider data sources and parsing overview (Codex, Claude, Gemini, Antigravity, Cursor, Droid/Factory, z.ai, Copilot, Kimi, Kimi K2, Kiro, Warp, Vertex AI, Augment, Amp, Ollama, JetBrains AI, OpenRouter)."
read_when:
- Adding or modifying provider fetch/parsing
- Adjusting provider labels, toggles, or metadata
@@ -19,7 +19,7 @@ until the session is invalid, to avoid repeated Keychain prompts.
| Provider | Strategies (ordered for auto) |
| --- | --- |
| Codex | Web dashboard (`openai-web`) → CLI RPC/PTy (`codex-cli`); app uses CLI usage + optional dashboard scrape. |
-| Claude | OAuth API (`oauth`) → Web API (`web`) → CLI PTY (`claude`). |
+| Claude | App Auto: OAuth API (`oauth`) → CLI PTY (`claude`) → Web API (`web`). CLI Auto: Web API (`web`) → CLI PTY (`claude`). |
| Gemini | OAuth API via Gemini CLI credentials (`api`). |
| Antigravity | Local LSP/HTTP probe (`local`). |
| Cursor | Web API via cookies → stored WebKit session (`web`). |
@@ -34,6 +34,9 @@ until the session is invalid, to avoid repeated Keychain prompts.
| Vertex AI | Google ADC OAuth (gcloud) → Cloud Monitoring quota usage (`oauth`). |
| JetBrains AI | Local XML quota file (`local`). |
| Amp | Web settings page via browser cookies (`web`). |
+| Warp | API token (config/env) → GraphQL request limits (`api`). |
+| Ollama | Web settings page via browser cookies (`web`). |
+| OpenRouter | API token (config, overrides env) → credits API (`api`). |
## Codex
- Web dashboard (when enabled): `https://chatgpt.com/codex/settings/usage` via WebView + browser cookies.
@@ -44,9 +47,8 @@ until the session is invalid, to avoid repeated Keychain prompts.
- Details: `docs/codex.md`.
## Claude
-- OAuth API (preferred when CLI credentials exist).
-- Web API (browser cookies) fallback when OAuth missing.
-- CLI PTY fallback when OAuth + web are unavailable.
+- App Auto: OAuth API (`oauth`) → CLI PTY (`claude`) → Web API (`web`).
+- CLI Auto: Web API (`web`) → CLI PTY (`claude`).
- Local cost usage: scans `~/.config/claude/projects/**/*.jsonl` (last 30 days).
- Status: Statuspage.io (Anthropic).
- Details: `docs/claude.md`.
@@ -121,6 +123,13 @@ until the session is invalid, to avoid repeated Keychain prompts.
- Status: AWS Health Dashboard (manual link, no auto-polling).
- Details: `docs/kiro.md`.
+## Warp
+- API token from Settings or `WARP_API_KEY` / `WARP_TOKEN` env var.
+- GraphQL credit limits: `https://app.warp.dev/graphql/v2?op=GetRequestLimitInfo`.
+- Shows monthly credits usage and next refresh time.
+- Status: none yet.
+- Details: `docs/warp.md`.
+
## Vertex AI
- OAuth credentials from `gcloud auth application-default login` (ADC).
- Quota usage via Cloud Monitoring `consumer_quota` metrics for `aiplatform.googleapis.com`.
@@ -139,4 +148,19 @@ until the session is invalid, to avoid repeated Keychain prompts.
- Parses Amp Free usage from the settings HTML.
- Status: none yet.
- Details: `docs/amp.md`.
+
+## Ollama
+- Web settings page (`https://ollama.com/settings`) via browser cookies.
+- Parses Cloud Usage plan badge, session/weekly usage, and reset timestamps.
+- Status: none yet.
+- Details: `docs/ollama.md`.
+
+## OpenRouter
+- API token from `~/.codexbar/config.json` (`providerConfig.openrouter.apiKey`) or `OPENROUTER_API_KEY` env var.
+- Credits endpoint: `https://openrouter.ai/api/v1/credits` (returns total credits purchased and usage).
+- Key info endpoint: `https://openrouter.ai/api/v1/key` (returns rate limit info).
+- Override base URL with `OPENROUTER_API_URL` env var.
+- Status: `https://status.openrouter.ai` (link only, no auto-polling yet).
+- Details: `docs/openrouter.md`.
+
See also: `docs/provider.md` for architecture notes.
diff --git a/docs/ui.md b/docs/ui.md
index 73f6fc57a..9c3d13475 100644
--- a/docs/ui.md
+++ b/docs/ui.md
@@ -26,6 +26,10 @@ read_when:
## Preferences notes
- Advanced: “Disable Keychain access” turns off browser cookie import; paste Cookie headers manually in Providers.
+- Providers → Claude: “Keychain prompt policy” controls Claude OAuth prompt behavior (Never / Only on user action /
+ Always allow prompts).
+- When “Disable Keychain access” is enabled in Advanced, the Claude keychain prompt policy remains visible but is
+ inactive.
## Widgets (high level)
- Widget entries mirror the menu card; detailed pipeline in `docs/widgets.md`.
diff --git a/docs/warp.md b/docs/warp.md
new file mode 100644
index 000000000..eeb66145e
--- /dev/null
+++ b/docs/warp.md
@@ -0,0 +1,49 @@
+---
+summary: "Warp provider notes: API token setup and request limit parsing."
+read_when:
+ - Adding or modifying the Warp provider
+ - Debugging Warp API tokens or request limits
+ - Adjusting Warp usage labels or reset behavior
+---
+
+# Warp Provider
+
+The Warp provider reads credit limits from Warp's GraphQL API using an API token.
+
+## Features
+
+- **Monthly credits usage**: Shows credits used vs. plan limit.
+- **Reset timing**: Displays the next refresh time when available.
+- **Token-based auth**: Uses API key stored in Settings or env vars.
+
+## Setup
+
+1. Open **Settings → Providers**
+2. Enable **Warp**
+3. In Warp, open your profile menu → **Settings → Platform → API Keys**, then create a key.
+4. Enter the created `wk-...` key in CodexBar.
+
+Reference guide: `https://docs.warp.dev/reference/cli/api-keys`
+
+### Environment variables (optional)
+
+- `WARP_API_KEY`
+- `WARP_TOKEN`
+
+## How it works
+
+- Endpoint: `https://app.warp.dev/graphql/v2?op=GetRequestLimitInfo`
+- Query: `GetRequestLimitInfo`
+- Fields used: `isUnlimited`, `nextRefreshTime`, `requestLimit`, `requestsUsedSinceLastRefresh` (API uses request-named fields for credits)
+
+If `isUnlimited` is true, the UI shows “Unlimited” and a full remaining bar.
+
+## Troubleshooting
+
+### “Missing Warp API key”
+
+Add a key in **Settings → Providers → Warp**, or set `WARP_API_KEY`.
+
+### “Warp API error”
+
+Confirm the token is valid and that your network can reach `app.warp.dev`.
diff --git a/version.env b/version.env
index cd2fd9f2f..4bad6b853 100644
--- a/version.env
+++ b/version.env
@@ -1,2 +1,2 @@
-MARKETING_VERSION=0.18.0-beta.2-jl.2
+MARKETING_VERSION=0.18.0-beta.3-jl.3
BUILD_NUMBER=51