diff --git a/CHANGELOG.md b/CHANGELOG.md index 14eb5ce58..5fe610dab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ on Columbus Labs QuotaKit releases and product-facing changes. ### Changed +- Synced reviewed CodexBar development through `c9e7f4df5`, adopting Claude cookie-refresh safety, OpenCodex pricing performance, Gemini consumer-shutdown detection, and merged Warp icon correctness while preserving QuotaKit identity, provider/mobile contracts, and release/build ownership. - Synced reviewed CodexBar development through `100deb6fa`, preserving QuotaKit identity, release ownership, provider/mobile contracts, and build numbers; upstream credit-only changelog bookkeeping and an incompatible Grok period fallback were reviewed but not imported. - Synced reviewed CodexBar development through `4b14ed9c5`, adopting eleven product/test commits while excluding upstream credit-only and release bookkeeping, and preserving QuotaKit identity, all 69 providers, Mac-to-iPhone contracts, and release/build ownership. - Synced reviewed CodexBar development through `7c64d280f`, adopting provider, authentication, dashboard, widget, presentation, and Mac lifecycle work while preserving QuotaKit identity, Columbus Labs release ownership, redacted-by-default serving, the full provider registry, CloudKit/App Group and typed iPhone contracts, and all release/build numbers. @@ -31,6 +32,10 @@ on Columbus Labs QuotaKit releases and product-facing changes. ### Fixed +- Claude: prefer Chrome without dropping fallback browsers, stop after the first usable cookie source, and keep ad-hoc browser-cookie caches process-local without moving OAuth credentials out of Keychain. +- Gemini: detect Google's live consumer-tier shutdown response, keep Workspace and licensed accounts eligible, and warn before an explicit account-switch flow clears credentials. +- Usage & Spend: price each OpenCodex entry once with shared catalog and custom-pricing context while preserving overflow-safe aggregation and compatible local caches. +- Menu bar: preserve Warp's bonus lane in merged icons when unused while continuing to distinguish a genuinely exhausted bonus. - Providers: restore Qwen Cloud Brave cookie import, use the latest completed UTC day for OpenRouter activity, and improve Antigravity offline discovery without attributing machine-global data to an OAuth account or iCloud record. - Usage & Spend: read compatible local Cursor and Antigravity activity caches, preserve refresh cancellation, refresh all changed dashboard detail, and keep calendar bucketing consistent across heatmap labels and selection. - Mac: report non-writable CLI path conflicts and let a single meaningful quota fill the menu icon while retaining Claude's explicit missing-secondary lane. diff --git a/Sources/CodexBar/GeminiLoginRunner.swift b/Sources/CodexBar/GeminiLoginRunner.swift index 028f6135b..8cb099dc8 100644 --- a/Sources/CodexBar/GeminiLoginRunner.swift +++ b/Sources/CodexBar/GeminiLoginRunner.swift @@ -21,13 +21,22 @@ enum GeminiLoginRunner { case success case missingBinary case launchFailed(String) + /// Google's consumer-tier shutdown was observed: launching Gemini CLI would only fail its + /// OAuth step, so keep the stored credentials and steer the user to Antigravity instead. + case consumerTierDeprecated } let outcome: Outcome } - static func run(onCredentialsCreated: (@Sendable () -> Void)? = nil) async -> Result { - await Task(priority: .userInitiated) { + static func run( + consumerTierDeprecationObserved: Bool = false, + onCredentialsCreated: (@Sendable () -> Void)? = nil) async -> Result + { + guard !consumerTierDeprecationObserved else { + return Result(outcome: .consumerTierDeprecated) + } + return await Task(priority: .userInitiated) { let env = ProcessInfo.processInfo.environment guard let binary = BinaryLocator.resolveGeminiBinary( env: env, diff --git a/Sources/CodexBar/IconRemainingResolver.swift b/Sources/CodexBar/IconRemainingResolver.swift index 59ceed8bc..fdea0339b 100644 --- a/Sources/CodexBar/IconRemainingResolver.swift +++ b/Sources/CodexBar/IconRemainingResolver.swift @@ -2,7 +2,9 @@ import CodexBarCore import Foundation enum IconRemainingResolver { - private static let visibleZeroPercent = 0.0001 + /// The renderer caches percentages in tenths. This is the smallest robust value that has a distinct cache + /// key from zero while still rounding to a zero-pixel fill in the 30-pixel meter. + private static let visibleZeroPercent = 0.1 static func resolvedWindows( snapshot: UsageSnapshot, @@ -43,7 +45,6 @@ enum IconRemainingResolver { snapshot: UsageSnapshot, style: IconStyle, showUsed: Bool, - renderingStyle: IconStyle? = nil, secondaryOverrideWindowID: String? = nil, now: Date = Date()) -> (primary: Double?, secondary: Double?) @@ -56,13 +57,13 @@ enum IconRemainingResolver { var percents = ( primary: showUsed ? windows.primary?.usedPercent : windows.primary?.remainingPercent, secondary: showUsed ? windows.secondary?.usedPercent : windows.secondary?.remainingPercent) - // Provider style chooses the usage lanes; rendering style controls renderer-specific layout sentinels. - // Merged icons still resolve Warp's lanes, but render as `.combined` and must keep the real percentage. + // Provider style chooses both the usage lanes and provider-specific layout sentinels. This must also + // apply when the visual rendering style is `.combined`, because the renderer receives provider policy + // separately from its visual style. let presentation = UsageProvider(rawValue: style.rawValue) .map { ProviderDescriptorRegistry.descriptor(for: $0).presentation } if showUsed, presentation?.treatsExhaustedSecondaryIconWindowAsMissing == true, - (renderingStyle ?? style) == style, let secondary = windows.secondary { if secondary.remainingPercent <= 0 { diff --git a/Sources/CodexBar/Providers/Gemini/GeminiLoginFlow.swift b/Sources/CodexBar/Providers/Gemini/GeminiLoginFlow.swift index c03344328..6716b65d0 100644 --- a/Sources/CodexBar/Providers/Gemini/GeminiLoginFlow.swift +++ b/Sources/CodexBar/Providers/Gemini/GeminiLoginFlow.swift @@ -4,15 +4,28 @@ import CodexBarCore extension StatusItemController { func runGeminiLoginFlow() async { let store = self.store - let result = await GeminiLoginRunner.run { + let onCredentialsCreated: @Sendable () -> Void = { Task { @MainActor in await store.refresh() CodexBarLog.logger(LogCategories.login).info("Auto-refreshed after Gemini auth") } } + + var result = await GeminiLoginRunner.run( + consumerTierDeprecationObserved: store.geminiObservedGoogleConsumerTierShutdown, + onCredentialsCreated: onCredentialsCreated) guard !Task.isCancelled else { return } self.loginPhase = .idle - self.presentGeminiLoginResult(result) + + if self.presentGeminiLoginResult(result) { + // The alert warned that continuing clears the stored credentials; the user asked to switch to + // an account Google still serves, so run the ordinary flow without the shutdown guard. + self.loginLogger.info("Gemini login", metadata: ["outcome": "consumerTierDeprecatedOverride"]) + result = await GeminiLoginRunner.run(onCredentialsCreated: onCredentialsCreated) + guard !Task.isCancelled else { return } + self.presentGeminiLoginResult(result) + } + let outcome = self.describe(result.outcome) self.loginLogger.info("Gemini login", metadata: ["outcome": outcome]) } diff --git a/Sources/CodexBar/StatusItemController+Actions.swift b/Sources/CodexBar/StatusItemController+Actions.swift index aa32844da..e35f67a1d 100644 --- a/Sources/CodexBar/StatusItemController+Actions.swift +++ b/Sources/CodexBar/StatusItemController+Actions.swift @@ -795,6 +795,7 @@ extension StatusItemController: StatusItemMenuPersistentActionDelegate { case .success: "success" case .missingBinary: "missingBinary" case let .launchFailed(message): "launchFailed(\(message))" + case .consumerTierDeprecated: "consumerTierDeprecated" } } @@ -813,9 +814,18 @@ extension StatusItemController: StatusItemMenuPersistentActionDelegate { } } - func presentGeminiLoginResult(_ result: GeminiLoginRunner.Result) { - guard let info = Self.geminiLoginAlertInfo(for: result) else { return } - self.presentLoginAlert(title: info.title, message: info.message) + /// Returns `true` when the alert offered a recovery action and the user chose it. + @discardableResult + func presentGeminiLoginResult(_ result: GeminiLoginRunner.Result) -> Bool { + guard let info = Self.geminiLoginAlertInfo(for: result) else { return false } + guard let confirmButtonTitle = info.confirmButtonTitle else { + self.presentLoginAlert(title: info.title, message: info.message) + return false + } + return self.presentLoginConfirmation( + title: info.title, + message: info.message, + confirmButtonTitle: confirmButtonTitle) } func presentAntigravityLoginResult(_ result: AntigravityLoginRunner.Result) { @@ -826,6 +836,8 @@ extension StatusItemController: StatusItemMenuPersistentActionDelegate { struct LoginAlertInfo: Equatable { let title: String let message: String + /// When set, the alert offers this action alongside Cancel and reports whether it was chosen. + var confirmButtonTitle: String? } nonisolated static func geminiLoginAlertInfo(for result: GeminiLoginRunner.Result) -> LoginAlertInfo? { @@ -838,6 +850,12 @@ extension StatusItemController: StatusItemMenuPersistentActionDelegate { message: L("Install the Gemini CLI (npm i -g @google/gemini-cli) and try again.")) case let .launchFailed(message): LoginAlertInfo(title: L("Could not open Terminal for Gemini"), message: message) + case .consumerTierDeprecated: + LoginAlertInfo( + title: L("Gemini CLI login is no longer supported"), + message: GeminiConsumerTierMigration.deprecationError + "\n\n" + + GeminiConsumerTierMigration.loginSwitchAccountPrompt, + confirmButtonTitle: L("Switch Account…")) } } @@ -858,6 +876,18 @@ extension StatusItemController: StatusItemMenuPersistentActionDelegate { } } + /// Cancel is the default button on purpose: confirming clears stored provider credentials, so a + /// stray Return keypress must not destroy them. + func presentLoginConfirmation(title: String, message: String, confirmButtonTitle: String) -> Bool { + let alert = NSAlert() + alert.messageText = L(title) + alert.informativeText = L(message) + alert.alertStyle = .warning + alert.addButton(withTitle: L("Cancel")) + alert.addButton(withTitle: confirmButtonTitle) + return alert.runModal() == .alertSecondButtonReturn + } + func presentLoginAlert(title: String, message: String) { let alert = NSAlert() alert.messageText = L(title) diff --git a/Sources/CodexBar/StatusItemController+Animation.swift b/Sources/CodexBar/StatusItemController+Animation.swift index 3f7c8c747..7ee8ca6c6 100644 --- a/Sources/CodexBar/StatusItemController+Animation.swift +++ b/Sources/CodexBar/StatusItemController+Animation.swift @@ -305,8 +305,7 @@ extension StatusItemController { provider: primaryProvider, snapshot: snapshot, style: resolverStyle, - showUsed: showUsed, - renderingStyle: style) + showUsed: showUsed) var primary = resolved?.primary var weekly = resolved?.secondary var credits = self.menuBarCreditsRemainingForIcon(provider: primaryProvider, snapshot: snapshot) @@ -739,8 +738,7 @@ extension StatusItemController { provider: UsageProvider, snapshot: UsageSnapshot?, style: IconStyle, - showUsed: Bool, - renderingStyle: IconStyle? = nil) + showUsed: Bool) -> (primary: Double?, secondary: Double?)? { guard let snapshot else { return nil } @@ -765,7 +763,6 @@ extension StatusItemController { snapshot: snapshot, style: style, showUsed: showUsed, - renderingStyle: renderingStyle, secondaryOverrideWindowID: self.settings.copilotIconSecondaryWindowOverrideID(snapshot: snapshot)) } diff --git a/Sources/CodexBar/UsageStore+GeminiMigration.swift b/Sources/CodexBar/UsageStore+GeminiMigration.swift index 14a9ef83e..53c2821c1 100644 --- a/Sources/CodexBar/UsageStore+GeminiMigration.swift +++ b/Sources/CodexBar/UsageStore+GeminiMigration.swift @@ -1,6 +1,28 @@ import CodexBarCore +/// Which Gemini migration sentinel the last refresh produced. +enum GeminiMigrationObservation { + case none + /// QuotaKit could not read OAuth client credentials from the local Gemini CLI while Antigravity is + /// installed. A local tooling problem: reinstalling or relaunching Gemini CLI is still the fix. + case localAntigravityHandoff + /// Google itself answered with the consumer-tier shutdown. Gemini CLI sign-in cannot succeed. + case googleConsumerTierShutdown +} + extension UsageStore { + /// Either sentinel: drives the "Enable Antigravity provider" settings action. + var geminiObservedConsumerTierDeprecation: Bool { + self.geminiMigrationObservation != .none + } + + /// Only Google's own shutdown response. Narrower than `geminiObservedConsumerTierDeprecation` so the + /// login guard cannot block a Workspace user whose local Gemini CLI install merely failed to yield + /// OAuth client credentials. + var geminiObservedGoogleConsumerTierShutdown: Bool { + self.geminiMigrationObservation == .googleConsumerTierShutdown + } + static func isGeminiConsumerTierDeprecationError(_ error: Error?) -> Bool { switch error as? GeminiStatusProbeError { case .consumerTierDeprecated, .oauthCredentialsUnavailableWithAntigravity: @@ -11,11 +33,21 @@ extension UsageStore { } func observeGeminiConsumerTierDeprecation(from error: Error) { - guard Self.isGeminiConsumerTierDeprecationError(error) else { return } - self.geminiObservedConsumerTierDeprecation = true + switch error as? GeminiStatusProbeError { + case .consumerTierDeprecated: + self.geminiMigrationObservation = .googleConsumerTierShutdown + case .oauthCredentialsUnavailableWithAntigravity: + // Never downgrade a shutdown already seen this session: Google's response is the stronger + // signal, and a later local-tooling failure must not re-arm the destructive login path. + if self.geminiMigrationObservation != .googleConsumerTierShutdown { + self.geminiMigrationObservation = .localAntigravityHandoff + } + default: + return + } } func clearGeminiConsumerTierDeprecationObservation() { - self.geminiObservedConsumerTierDeprecation = false + self.geminiMigrationObservation = .none } } diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 7b3581511..f6c251444 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -169,7 +169,7 @@ final class UsageStore { var snapshots: [ProviderInstanceID: UsageSnapshot] = [:] var errors: [ProviderInstanceID: String] = [:] var diagnostics: [ProviderInstanceID: String] = [:] - var geminiObservedConsumerTierDeprecation = false + var geminiMigrationObservation: GeminiMigrationObservation = .none var knownLimitsAvailabilityByProvider: [ProviderInstanceID: UsageLimitsAvailability] = [:] var lastSourceLabels: [ProviderInstanceID: String] = [:] var lastFetchAttempts: [ProviderInstanceID: [ProviderFetchAttempt]] = [:] diff --git a/Sources/CodexBarCore/BrowserCookieAccessGate.swift b/Sources/CodexBarCore/BrowserCookieAccessGate.swift index e6e732cb5..80d19846c 100644 --- a/Sources/CodexBarCore/BrowserCookieAccessGate.swift +++ b/Sources/CodexBarCore/BrowserCookieAccessGate.swift @@ -60,6 +60,9 @@ public enum BrowserCookieAccessGate { private static let log = CodexBarLog.logger(LogCategories.browserCookieGate) @TaskLocal private static var explicitRetryScope: ExplicitRetryScope? @TaskLocal private static var deniedBrowsersForTesting: [Browser]? + #if DEBUG + @TaskLocal private static var shouldAttemptOverrideForTesting: Bool? + #endif static let allowTestCookieAccessEnvironmentKey = "CODEXBAR_ALLOW_TEST_BROWSER_COOKIE_ACCESS" @@ -84,6 +87,11 @@ public enum BrowserCookieAccessGate { } public static func shouldAttempt(_ browser: Browser, now: Date = Date()) -> Bool { + #if DEBUG + if let shouldAttemptOverrideForTesting { + return shouldAttemptOverrideForTesting + } + #endif guard browser.usesKeychainForCookieDecryption else { return true } guard !KeychainAccessGate.isDisabled else { return false } guard ProviderInteractionContext.current == .userInitiated else { @@ -167,6 +175,17 @@ public enum BrowserCookieAccessGate { } } + #if DEBUG + static func withShouldAttemptOverrideForTesting( + _ result: Bool?, + operation: () throws -> T) rethrows -> T + { + try self.$shouldAttemptOverrideForTesting.withValue(result) { + try operation() + } + } + #endif + static func operationPreservingAccessContext( _ operation: @escaping @Sendable () throws -> T) -> @Sendable () throws -> T { diff --git a/Sources/CodexBarCore/BrowserCookieImportOrder.swift b/Sources/CodexBarCore/BrowserCookieImportOrder.swift index 7419dffbd..3ff6a465d 100644 --- a/Sources/CodexBarCore/BrowserCookieImportOrder.swift +++ b/Sources/CodexBarCore/BrowserCookieImportOrder.swift @@ -15,13 +15,17 @@ extension [Browser] { /// /// This is intentionally stricter than "app installed": it aims to avoid unnecessary Keychain prompts. public func cookieImportCandidates(using detection: BrowserDetection) -> [Browser] { - let candidates = self.filter { browser in + Array(self.lazyCookieImportCandidates(using: detection)) + } + + /// Lazily filters browser sources so callers can stop after the first successful cookie import. + func lazyCookieImportCandidates(using detection: BrowserDetection) -> some Sequence { + self.lazy.filter { browser in if KeychainAccessGate.isDisabled, browser.usesKeychainForCookieDecryption { return false } - return detection.isCookieSourceAvailable(browser) + return detection.isCookieSourceAvailable(browser) && BrowserCookieAccessGate.shouldAttempt(browser) } - return candidates.filter { BrowserCookieAccessGate.shouldAttempt($0) } } /// Filters a browser list to sources with usable profile data on disk. diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index b3aa6d8e7..6f49ffbfb 100644 --- a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift +++ b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift @@ -1,5 +1,5 @@ // Generated by Scripts/regenerate-codex-parser-hash.sh. Do not edit by hand. enum CodexParserHash { - static let value = "be0bb04e9e92b697" + static let value = "f22371c47d2e006f" } diff --git a/Sources/CodexBarCore/KeychainAccessGate.swift b/Sources/CodexBarCore/KeychainAccessGate.swift index 3fa2c505a..ffd6462d2 100644 --- a/Sources/CodexBarCore/KeychainAccessGate.swift +++ b/Sources/CodexBarCore/KeychainAccessGate.swift @@ -7,6 +7,9 @@ public enum KeychainAccessGate { private static let flagKey = "debugDisableKeychainAccess" static let disableAccessEnvironmentKey = "CODEXBAR_DISABLE_KEYCHAIN_ACCESS" @TaskLocal private static var taskOverrideValue: Bool? + #if DEBUG + @TaskLocal private static var storedOverrideForTesting: Bool? + #endif // All mutable gate state and mirror writes share this lock. Resolve the effective value with // `isDisabledLocked()` instead of recursively entering through the public getter. private static let stateLock = NSLock() @@ -38,6 +41,9 @@ public enum KeychainAccessGate { if Self.forcesDisabledUnderTests { return true } #endif if self.processForceDisabledReason != nil { return true } + #if DEBUG + if let storedOverrideForTesting { return storedOverrideForTesting } + #endif if let overrideValue { return overrideValue } if UserDefaults.standard.bool(forKey: Self.flagKey) { return true } if let shared = AppGroupSupport.sharedDefaults(), shared.bool(forKey: Self.flagKey) { return true } @@ -55,6 +61,9 @@ public enum KeychainAccessGate { if let taskOverrideValue { return taskOverrideValue } if self.isDisabledByEnvironment() { return true } if self.processForceDisabledReason != nil { return true } + #if DEBUG + if let storedOverrideForTesting { return storedOverrideForTesting } + #endif if let overrideValue { return overrideValue } if UserDefaults.standard.bool(forKey: Self.flagKey) { return true } if let shared = AppGroupSupport.sharedDefaults(), shared.bool(forKey: Self.flagKey) { return true } @@ -115,8 +124,25 @@ public enum KeychainAccessGate { } } + #if DEBUG + static func withStoredOverrideForTesting( + _ disabled: Bool?, + operation: () throws -> T) rethrows -> T + { + try self.$storedOverrideForTesting.withValue(disabled) { + try operation() + } + } + #endif + static var currentOverrideForTesting: Bool? { + #if DEBUG + self.taskOverrideValue + ?? self.storedOverrideForTesting + ?? self.stateLock.withLock { self.overrideValue } + #else self.taskOverrideValue ?? self.stateLock.withLock { self.overrideValue } + #endif } #if DEBUG diff --git a/Sources/CodexBarCore/KeychainAccessPreflight.swift b/Sources/CodexBarCore/KeychainAccessPreflight.swift index f9c78fa1d..577da059e 100644 --- a/Sources/CodexBarCore/KeychainAccessPreflight.swift +++ b/Sources/CodexBarCore/KeychainAccessPreflight.swift @@ -1,6 +1,7 @@ +import Foundation + #if os(macOS) import Darwin -import Foundation import LocalAuthentication import Security #endif @@ -90,7 +91,32 @@ public enum KeychainAccessPreflight { case failure(Int) } + private struct GenericPasswordKey: Hashable { + let service: String + let account: String? + } + + private final class GenericPasswordCheckMemo: @unchecked Sendable { + private let lock = NSLock() + private var outcomes: [GenericPasswordKey: Outcome] = [:] + + func outcome( + for key: GenericPasswordKey, + check: () -> Outcome) -> Outcome + { + self.lock.lock() + defer { self.lock.unlock() } + if let outcome = self.outcomes[key] { + return outcome + } + let outcome = check() + self.outcomes[key] = outcome + return outcome + } + } + private static let log = CodexBarLog.logger(LogCategories.keychainPreflight) + @TaskLocal private static var genericPasswordCheckMemo: GenericPasswordCheckMemo? #if DEBUG final class CheckGenericPasswordOverrideStore: @unchecked Sendable { @@ -131,7 +157,27 @@ public enum KeychainAccessPreflight { } #endif + /// Reuses identical no-UI generic-password preflights within one synchronous operation. + /// The scope is deliberately short-lived because Keychain items and their ACLs can change. + public static func withMemoizedGenericPasswordChecks( + _ operation: () throws -> T) rethrows -> T + { + try self.$genericPasswordCheckMemo.withValue(GenericPasswordCheckMemo()) { + try operation() + } + } + public static func checkGenericPassword(service: String, account: String?) -> Outcome { + let key = GenericPasswordKey(service: service, account: account) + if let memo = self.genericPasswordCheckMemo { + return memo.outcome(for: key) { + self.checkGenericPasswordUncached(service: service, account: account) + } + } + return self.checkGenericPasswordUncached(service: service, account: account) + } + + private static func checkGenericPasswordUncached(service: String, account: String?) -> Outcome { #if os(macOS) #if DEBUG if let override = self.taskCheckGenericPasswordOverrideStore { diff --git a/Sources/CodexBarCore/KeychainCacheStore+ApplicationPaths.swift b/Sources/CodexBarCore/KeychainCacheStore+ApplicationPaths.swift index 248a79b0c..aa5fd241c 100644 --- a/Sources/CodexBarCore/KeychainCacheStore+ApplicationPaths.swift +++ b/Sources/CodexBarCore/KeychainCacheStore+ApplicationPaths.swift @@ -1,6 +1,47 @@ import Foundation +#if os(macOS) +import Security +#endif extension KeychainCacheStore { + #if DEBUG + @TaskLocal static var bundledAdHocProcessOverrideForTesting: Bool? + #endif + + /// Ad-hoc app rebuilds cannot satisfy legacy Keychain ACLs created by an earlier executable identity. + /// Keep cookie caches process-local for those builds; certificate-signed development and release apps + /// retain the persistent Keychain cache. + static var isBundledAdHocProcess: Bool { + #if DEBUG + if let override = self.bundledAdHocProcessOverrideForTesting { + return override + } + #endif + return self.detectedBundledAdHocProcess + } + + private static let detectedBundledAdHocProcess: Bool = { + #if os(macOS) + guard let appBundle = Self.appBundleURL(containing: Bundle.main.bundleURL) + ?? Bundle.main.executableURL.flatMap(Self.appBundleURL(containing:)) + else { return false } + return !Self.hasCodeSigningCertificate(at: appBundle) + #else + return false + #endif + }() + + #if DEBUG + static func withBundledAdHocProcessForTesting( + _ enabled: Bool, + operation: () throws -> T) rethrows -> T + { + try self.$bundledAdHocProcessOverrideForTesting.withValue(enabled) { + try operation() + } + } + #endif + static func trustedApplicationPathsForCacheAccess( bundleURL: URL = Bundle.main.bundleURL, executableURL: URL? = Bundle.main.executableURL, @@ -49,4 +90,26 @@ extension KeychainCacheStore { } return nil } + + static func hasCodeSigningCertificate(at bundleURL: URL) -> Bool { + #if os(macOS) + var staticCode: SecStaticCode? + guard SecStaticCodeCreateWithPath(bundleURL as CFURL, SecCSFlags(), &staticCode) == errSecSuccess, + let staticCode + else { return false } + + var information: CFDictionary? + guard SecCodeCopySigningInformation( + staticCode, + SecCSFlags(rawValue: kSecCSSigningInformation), + &information) == errSecSuccess, + let values = information as? [String: Any], + let certificates = values[kSecCodeInfoCertificates as String] as? [SecCertificate] + else { return false } + return !certificates.isEmpty + #else + _ = bundleURL + return false + #endif + } } diff --git a/Sources/CodexBarCore/KeychainCacheStore.swift b/Sources/CodexBarCore/KeychainCacheStore.swift index 6488e80dc..d4d73f683 100644 --- a/Sources/CodexBarCore/KeychainCacheStore.swift +++ b/Sources/CodexBarCore/KeychainCacheStore.swift @@ -513,12 +513,14 @@ public enum KeychainCacheStore { !KeychainAccessGate.isDisabled } - /// When the user disables Keychain access, keep an in-process cache so cookie/session + /// When persistent cookie-cache access is unavailable, keep an in-process cache so session /// reconciliation can still succeed without treating every refresh as a session change. /// Unit tests keep using the isolated test stores instead, unless a test explicitly opts in. private static func shouldUseDisabledAccessMemoryStore(for category: String) -> Bool { #if DEBUG - if self.disabledAccessMemoryStoreEnabledForTesting == true { + if self.disabledAccessMemoryStoreEnabledForTesting == true || + self.bundledAdHocProcessOverrideForTesting == true + { return category == "cookie" } if KeychainTestSafety.isRunningUnderTests( @@ -538,7 +540,7 @@ public enum KeychainCacheStore { return true } guard category == "cookie" else { return false } - return KeychainAccessGate.isExplicitlyDisabled + return KeychainAccessGate.isExplicitlyDisabled || self.isBundledAdHocProcess } /// True when the running executable has no `.app` bundle ancestor. @@ -587,7 +589,7 @@ public enum KeychainCacheStore { } #endif - /// Drops the in-process fallback used while Keychain access is explicitly disabled. + /// Drops the in-process fallback used when persistent cookie-cache access is unavailable. static func clearDisabledAccessMemoryStore() { self.disabledAccessMemoryLock.lock() self.disabledAccessMemoryStore.removeAll() @@ -599,7 +601,8 @@ public enum KeychainCacheStore { private static var prefersDisabledAccessMemoryStoreOverTestStore: Bool { #if DEBUG - self.disabledAccessMemoryStoreEnabledForTesting == true + self.disabledAccessMemoryStoreEnabledForTesting == true || + self.bundledAdHocProcessOverrideForTesting == true #else false #endif @@ -846,7 +849,7 @@ public enum KeychainCacheStore { defer { self.disabledAccessMemoryLock.unlock() } let memoryKey = TestStoreKey(service: self.serviceName, account: key.account) self.disabledAccessMemoryStore[memoryKey] = data - self.log.debug("Keychain cache stored in memory (Keychain access disabled)", metadata: [ + self.log.debug("Cookie cache stored in process memory", metadata: [ "account": key.account, ]) return true diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift index ab9e801ad..e5d36f318 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift @@ -1,7 +1,16 @@ import Foundation +import SweetCookieKit public enum ClaudeProviderDescriptor { public static let descriptor: ProviderDescriptor = Self.makeDescriptor() + private static var browserCookieOrder: BrowserCookieImportOrder? { + #if os(macOS) + [.chrome] + Browser.defaultImportOrder.filter { $0 != .chrome } + #else + nil + #endif + } + private static let ttyLaunch = ProviderTTYLaunchConfig( executableOverrideEnvironmentKey: "CLAUDE_CLI_PATH", bundledWatchdogHelperName: "QuotaKitClaudeWatchdog", @@ -123,7 +132,7 @@ public enum ClaudeProviderDescriptor { probeLogOrder: 1, notificationSimulationOrder: 1, errorSimulationOrder: 1), - browserCookieOrder: ProviderBrowserCookieDefaults.defaultImportOrder, + browserCookieOrder: self.browserCookieOrder, dashboardURL: "https://console.anthropic.com/settings/billing", subscriptionDashboardURL: "https://claude.ai/settings/usage", changelogURL: "https://github.com/anthropics/claude-code/releases", diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebAPIFetcher.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebAPIFetcher.swift index f8cad8be3..8047008a9 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebAPIFetcher.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebAPIFetcher.swift @@ -37,6 +37,8 @@ enum ClaudeWebPrepaidCreditsRequest { enum ClaudeWebSessionKeyImport { #if DEBUG @TaskLocal static var overrideForTesting: ClaudeWebAPIFetcher.SessionKeyInfo? + @TaskLocal static var browserOverrideForTesting: + (@Sendable (Browser) throws -> ClaudeWebAPIFetcher.SessionKeyInfo?)? #endif static var currentOverride: ClaudeWebAPIFetcher.SessionKeyInfo? { @@ -46,6 +48,16 @@ enum ClaudeWebSessionKeyImport { nil #endif } + + static var currentBrowserOverride: + (@Sendable (Browser) throws -> ClaudeWebAPIFetcher.SessionKeyInfo?)? + { + #if DEBUG + self.browserOverrideForTesting + #else + nil + #endif + } } private actor ClaudeWebBrowserFetchGate { @@ -535,33 +547,42 @@ extension ClaudeWebAPIFetcher { let cookieDomains = ["claude.ai"] - // Filter to cookie-eligible browsers to avoid unnecessary keychain prompts - let installedBrowsers = Self.cookieImportOrder.cookieImportCandidates(using: browserDetection) - for browserSource in installedBrowsers { - do { - let query = BrowserCookieQuery(domains: cookieDomains) - let sources = try Self.cookieClient.codexBarRecords( - matching: query, - in: browserSource, - logger: log) - for source in sources { - if let sessionKey = findSessionKey(in: source.records.map { record in - (name: record.name, value: record.value) - }) { - log("Found sessionKey in \(source.label)") - return SessionKeyInfo( - key: sessionKey, - sourceLabel: source.label, - cookieCount: source.records.count) + return try KeychainAccessPreflight.withMemoizedGenericPasswordChecks { + // Evaluate sources on demand so a successful preferred browser avoids later Keychain preflights. + let installedBrowsers = Self.cookieImportOrder.lazyCookieImportCandidates(using: browserDetection) + for browserSource in installedBrowsers { + do { + if let override = ClaudeWebSessionKeyImport.currentBrowserOverride { + if let sessionInfo = try override(browserSource) { + log("Found sessionKey in \(sessionInfo.sourceLabel)") + return sessionInfo + } + continue } + let query = BrowserCookieQuery(domains: cookieDomains) + let sources = try Self.cookieClient.codexBarRecords( + matching: query, + in: browserSource, + logger: log) + for source in sources { + if let sessionKey = findSessionKey(in: source.records.map { record in + (name: record.name, value: record.value) + }) { + log("Found sessionKey in \(source.label)") + return SessionKeyInfo( + key: sessionKey, + sourceLabel: source.label, + cookieCount: source.records.count) + } + } + } catch { + BrowserCookieAccessGate.recordIfNeeded(error) + log("\(browserSource.displayName) cookie load failed: \(error.localizedDescription)") } - } catch { - BrowserCookieAccessGate.recordIfNeeded(error) - log("\(browserSource.displayName) cookie load failed: \(error.localizedDescription)") } - } - throw FetchError.noSessionKeyFound + throw FetchError.noSessionKeyFound + } } private static func findSessionKey(in cookies: [(name: String, value: String)]) -> String? { diff --git a/Sources/CodexBarCore/Providers/Gemini/GeminiConsumerTierMigration.swift b/Sources/CodexBarCore/Providers/Gemini/GeminiConsumerTierMigration.swift index 6ad0dd382..96116a42b 100644 --- a/Sources/CodexBarCore/Providers/Gemini/GeminiConsumerTierMigration.swift +++ b/Sources/CodexBarCore/Providers/Gemini/GeminiConsumerTierMigration.swift @@ -7,6 +7,13 @@ public enum GeminiConsumerTierMigration { Enable QuotaKit's Antigravity provider, sign in to Antigravity or run `agy`, then refresh. """ + /// Explains the one reason to continue into a Gemini CLI sign-in after the shutdown was detected: + /// moving to an account Google still serves. Signing in clears the stored credentials first. + public static let loginSwitchAccountPrompt = """ + Signing in again only helps if you are switching to a Workspace, education, or Code Assist \ + Standard/Enterprise account. Continuing clears the stored Gemini credentials and opens Gemini CLI. + """ + public static let oauthRecoveryError = """ Could not refresh Gemini OAuth credentials. Reinstall or update Gemini CLI. Advanced \ users launching QuotaKit from a configured environment can set GEMINI_OAUTH_CLIENT_ID \ diff --git a/Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe.swift b/Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe.swift index b9653ff6b..e5b5fef74 100644 --- a/Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe.swift @@ -321,6 +321,7 @@ public struct GeminiStatusProbe: Sendable { let caStatus = try await Self.loadCodeAssistStatus( accessToken: accessToken, timeout: timeout, + hostedDomain: claims.hostedDomain, dataLoader: dataLoader) // Determine the project ID to use for quota fetching. @@ -365,6 +366,16 @@ public struct GeminiStatusProbe: Sendable { guard httpResponse.statusCode == 200 else { try GeminiStatusProbeError.throwIfConsumerTierDeprecated(data: data) + // The quota 403 (`SUBSCRIPTION_REQUIRED`) carries no migration wording; only treat it as the + // consumer shutdown when loadCodeAssist flagged this client as unsupported AND the account is + // not on a licensed tier. Standard/Enterprise subscriptions are outside the shutdown, so their + // 403s stay generic even if Google lists the consumer tier as ineligible for every CLI caller. + if httpResponse.statusCode == 403, + caStatus.isConsumerClientUnsupported, + caStatus.tier != .standard + { + throw GeminiStatusProbeError.consumerTierDeprecated + } throw GeminiStatusProbeError.apiError("HTTP \(httpResponse.statusCode)") } @@ -426,17 +437,24 @@ public struct GeminiStatusProbe: Sendable { return nil } - private struct CodeAssistStatus { + fileprivate struct CodeAssistStatus { let tier: GeminiUserTierId? let projectId: String? let paidTierName: String? - - static let empty = CodeAssistStatus(tier: nil, projectId: nil, paidTierName: nil) + /// Google listed the consumer tier under `ineligibleTiers` with `UNSUPPORTED_CLIENT`. + let isConsumerClientUnsupported: Bool + + static let empty = CodeAssistStatus( + tier: nil, + projectId: nil, + paidTierName: nil, + isConsumerClientUnsupported: false) } private static func loadCodeAssistStatus( accessToken: String, timeout: TimeInterval, + hostedDomain: String?, dataLoader: @escaping @Sendable (URLRequest) async throws -> (Data, URLResponse)) async throws -> CodeAssistStatus { @@ -482,47 +500,7 @@ public struct GeminiStatusProbe: Sendable { return .empty } - let rawProjectId: String? = { - if let project = json["cloudaicompanionProject"] as? String { - return project - } - if let project = json["cloudaicompanionProject"] as? [String: Any] { - if let projectId = project["id"] as? String { - return projectId - } - if let projectId = project["projectId"] as? String { - return projectId - } - } - return nil - }() - let trimmedProjectId = rawProjectId?.trimmingCharacters(in: .whitespacesAndNewlines) - let projectId = trimmedProjectId?.isEmpty == true ? nil : trimmedProjectId - if let projectId { - Self.log.info("loadCodeAssist: project detected", metadata: ["projectId": projectId]) - } - - let tierId = (json["currentTier"] as? [String: Any])?["id"] as? String - let paidTierName = Self.parsePaidTierName(from: json) - - guard let tierId else { - Self.log.warning("loadCodeAssist: no currentTier.id in response", metadata: [ - "json": "\(json)", - ]) - return CodeAssistStatus(tier: nil, projectId: projectId, paidTierName: paidTierName) - } - - guard let tier = GeminiUserTierId(rawValue: tierId) else { - Self.log.warning("loadCodeAssist: unknown tier ID", metadata: ["tierId": tierId]) - return CodeAssistStatus(tier: nil, projectId: projectId, paidTierName: paidTierName) - } - - Self.log.info("loadCodeAssist: success", metadata: [ - "tier": tierId, - "projectId": projectId ?? "nil", - "paidTierName": paidTierName ?? "nil", - ]) - return CodeAssistStatus(tier: tier, projectId: projectId, paidTierName: paidTierName) + return try Self.makeCodeAssistStatus(from: json, hostedDomain: hostedDomain) } private struct OAuthCredentials { @@ -1330,8 +1308,83 @@ extension GeminiStatusProbe { return nil } } +} + +// MARK: - loadCodeAssist response parsing + +extension GeminiStatusProbe { + /// Turns a successful `loadCodeAssist` body into a `CodeAssistStatus`, throwing when Google's response + /// says this client can no longer serve the account. + fileprivate static func makeCodeAssistStatus( + from json: [String: Any], + hostedDomain: String?) throws -> CodeAssistStatus + { + let rawProjectId: String? = { + if let project = json["cloudaicompanionProject"] as? String { + return project + } + if let project = json["cloudaicompanionProject"] as? [String: Any] { + if let projectId = project["id"] as? String { + return projectId + } + if let projectId = project["projectId"] as? String { + return projectId + } + } + return nil + }() + let trimmedProjectId = rawProjectId?.trimmingCharacters(in: .whitespacesAndNewlines) + let projectId = trimmedProjectId?.isEmpty == true ? nil : trimmedProjectId + if let projectId { + Self.log.info("loadCodeAssist: project detected", metadata: ["projectId": projectId]) + } + + let tierId = (json["currentTier"] as? [String: Any])?["id"] as? String + let paidTierName = Self.parsePaidTierName(from: json) + let isConsumerClientUnsupported = Self.isConsumerClientUnsupported( + in: json, + paidTierName: paidTierName, + hostedDomain: hostedDomain) + + guard let tierId else { + // Google answers the consumer shutdown with HTTP 200: no `currentTier`, and the consumer tier + // listed under `ineligibleTiers` with `UNSUPPORTED_CLIENT`. + if isConsumerClientUnsupported { + Self.log.info("loadCodeAssist: consumer client unsupported, no current tier") + throw GeminiStatusProbeError.consumerTierDeprecated + } + Self.log.warning("loadCodeAssist: no currentTier.id in response", metadata: [ + "json": "\(json)", + ]) + return CodeAssistStatus( + tier: nil, + projectId: projectId, + paidTierName: paidTierName, + isConsumerClientUnsupported: false) + } + + guard let tier = GeminiUserTierId(rawValue: tierId) else { + Self.log.warning("loadCodeAssist: unknown tier ID", metadata: ["tierId": tierId]) + return CodeAssistStatus( + tier: nil, + projectId: projectId, + paidTierName: paidTierName, + isConsumerClientUnsupported: isConsumerClientUnsupported) + } + + Self.log.info("loadCodeAssist: success", metadata: [ + "tier": tierId, + "projectId": projectId ?? "nil", + "paidTierName": paidTierName ?? "nil", + ]) + return CodeAssistStatus( + tier: tier, + projectId: projectId, + paidTierName: paidTierName, + isConsumerClientUnsupported: isConsumerClientUnsupported) + } - private static func parsePaidTierName(from json: [String: Any]) -> String? { + fileprivate static func parsePaidTierName(from json: [String: Any]) -> String? { guard let paidTier = json["paidTier"] as? [String: Any], let rawName = paidTier["name"] as? String else { @@ -1340,6 +1393,36 @@ extension GeminiStatusProbe { let trimmed = rawName.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? nil : trimmed } + + /// Whether Google's `loadCodeAssist` response says this client can no longer serve the account. + /// + /// Two signals outrank the ineligible-tier listing, and gating them here keeps both the deprecation + /// throw and the quota-403 mapping off accounts the June 2026 shutdown does not cover: + /// - A named paid tier: `resolveAccountPlan` treats `paidTier.name` as authoritative even without + /// `currentTier`, and Google's consumer shutdown response carries no `paidTier` at all. + /// - An `hd` claim: Workspace and education accounts stay on Gemini, and `resolveAccountPlan` reads + /// `free-tier` plus a hosted domain as Workspace — a mapping this earlier branch would otherwise + /// pre-empt without ever seeing the claim. + fileprivate static func isConsumerClientUnsupported( + in json: [String: Any], + paidTierName: String?, + hostedDomain: String?) -> Bool + { + paidTierName == nil && hostedDomain == nil && self.hasUnsupportedClientIneligibleTier(in: json) + } + + /// `ineligibleTiers[].reasonCode == "UNSUPPORTED_CLIENT"` (or its message) is Google's explicit + /// consumer-tier shutdown signal inside an otherwise successful `loadCodeAssist` response. + private static func hasUnsupportedClientIneligibleTier(in json: [String: Any]) -> Bool { + guard let ineligibleTiers = json["ineligibleTiers"] as? [[String: Any]] else { return false } + // `tierId` is intentionally ignored: any UNSUPPORTED_CLIENT entry means *this client* is + // unsupported, whichever tier Google attached the reason to. + return ineligibleTiers.contains { entry in + [entry["reasonCode"], entry["reasonMessage"]] + .compactMap { $0 as? String } + .contains(where: GeminiStatusProbeError.isConsumerTierDeprecationSignal) + } + } } extension GeminiStatusProbe { diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore.swift index bedc40005..a78e063c0 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore.swift @@ -89,6 +89,7 @@ actor CostUsageStore { "47144baa8daccf52", // This branch changes only scan scheduling, discovery, and persistence bookkeeping. "2d17f4981b78d07f", // Persisted priority-turn cursor; parser and persisted row shape unchanged. "1ad1e41af7f25b3e", // Trace-priority ownership evidence fix; parser and persisted row shape unchanged. + "be0bb04e9e92b697", // QuotaKit pre-OpenCodex optimization producer; persisted row shape unchanged. ] nonisolated static func defaultCacheRoot() -> URL { diff --git a/Sources/CodexBarCore/Vendored/CostUsage/ModelsDevPricing.swift b/Sources/CodexBarCore/Vendored/CostUsage/ModelsDevPricing.swift index 691508629..eb807591b 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/ModelsDevPricing.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/ModelsDevPricing.swift @@ -1,4 +1,9 @@ import Foundation +#if canImport(Darwin) +import Darwin +#elseif canImport(Glibc) +import Glibc +#endif #if canImport(FoundationNetworking) import FoundationNetworking #endif @@ -427,14 +432,71 @@ enum ModelsDevCache { static let ttlSeconds: TimeInterval = 24 * 60 * 60 private static let memo = ModelsDevCacheMemo() + /// Test-only instrumentation: counts `fileMetadata(at:)` reads (one per `load`) so tests can prove callers + /// resolve the catalog once instead of per pricing call. Task-local, so concurrent tests do not see each other's + /// counts, and unset (zero cost) in production. + @TaskLocal private static var metadataReadRecorder: MetadataReadRecorder? + + final class MetadataReadRecorder: @unchecked Sendable { + private let lock = NSLock() + private var count = 0 + + func record() { + self.lock.lock() + self.count += 1 + self.lock.unlock() + } + + func snapshot() -> Int { + self.lock.lock() + defer { self.lock.unlock() } + return self.count + } + } + + static func withMetadataReadRecorderForTesting( + _ recorder: MetadataReadRecorder, + operation: () throws -> T) rethrows -> T + { + try self.$metadataReadRecorder.withValue(recorder) { + try operation() + } + } + static func withMetadataReadRecorderForTesting( + _ recorder: MetadataReadRecorder, + operation: () async throws -> T) async rethrows -> T + { + try await self.$metadataReadRecorder.withValue(recorder) { + try await operation() + } + } + + /// Cheap POSIX stat for the (mtime, size) memo key. `attributesOfItem` also reads xattrs. + /// `stat(2)` follows a terminal symlink (matching what `Data(contentsOf:)` later reads) whereas + /// `attributesOfItem` did not. private static func fileMetadata(at url: URL) -> (modificationDate: Date?, size: Int?) { - guard let attributes = try? FileManager.default.attributesOfItem(atPath: url.path) else { - return (nil, nil) + self.metadataReadRecorder?.record() + + return url.withUnsafeFileSystemRepresentation { pointer in + guard let pointer else { return (nil, nil) } + var status = stat() + guard stat(pointer, &status) == 0 else { + return (nil, nil) + } + return (Self.modificationDate(from: status), Int(status.st_size)) } - let modificationDate = attributes[.modificationDate] as? Date - let size = (attributes[.size] as? NSNumber)?.intValue - return (modificationDate, size) + } + + private static func modificationDate(from status: stat) -> Date { + #if canImport(Darwin) + let seconds = TimeInterval(status.st_mtimespec.tv_sec) + let nanoseconds = TimeInterval(status.st_mtimespec.tv_nsec) + #else + let seconds = TimeInterval(status.st_mtim.tv_sec) + let nanoseconds = TimeInterval(status.st_mtim.tv_nsec) + #endif + return Date(timeIntervalSince1970: seconds + nanoseconds / 1_000_000_000) } private static func defaultCacheRoot() -> URL { diff --git a/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageAggregator.swift b/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageAggregator.swift index c2ee077cb..08488fc0b 100644 --- a/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageAggregator.swift +++ b/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageAggregator.swift @@ -54,12 +54,21 @@ enum OpenCodexUsageAggregator { var sawCost = false } + /// Aggregates OpenCodex usage entries into a per-window token/cost snapshot. + /// + /// Pricing context is resolved once per call and shared by every entry: `modelsDevCatalog` is the models.dev + /// catalog and `customPricingOverlay` the app-level custom-pricing overlay file. Callers that aggregate several + /// providers (see `OpenCodexUsageFanOut`) resolve both once and pass them in; when either is nil it is resolved + /// here once. Each windowed entry is priced exactly once and that price feeds the day, session, hour and model + /// accumulators, so output is identical to pricing inside each merge — without a catalog/overlay lookup per call. static func snapshot( entries: [OpenCodexUsageEntry], now: Date, historyDays: Int, calendar: Calendar, - customPricing: CostUsageCustomPricing = .empty) -> CostUsageTokenSnapshot + customPricing: CostUsageCustomPricing = .empty, + modelsDevCatalog: ModelsDevCatalog? = nil, + customPricingOverlay: CostUsageCustomPricing? = nil) -> CostUsageTokenSnapshot { let days = max(1, min(365, historyDays)) let today = calendar.startOfDay(for: now) @@ -76,25 +85,50 @@ enum OpenCodexUsageAggregator { return lhs.requestID < rhs.requestID } + // Resolve the pricing context once for the whole snapshot. A missing models.dev catalog becomes an EMPTY + // catalog on purpose: `codexCostUSD` treats a nil catalog as "resolve it yourself" and would fall back to + // `ModelsDevCache.load` (a stat per pricing target) for every entry, whereas an empty catalog yields the same + // nil lookups without any file access. Nothing is resolved when the window is empty. + let catalog: ModelsDevCatalog + let overlay: CostUsageCustomPricing + if windowed.isEmpty { + catalog = ModelsDevCatalog(providers: [:]) + overlay = .empty + } else { + catalog = modelsDevCatalog + ?? CostUsagePricing.modelsDevCatalog() + ?? ModelsDevCatalog(providers: [:]) + overlay = customPricingOverlay ?? CostUsagePricing.customPricingOverlay() + } + var daysByKey: [String: DayAccumulator] = [:] var sessions: [String: SessionAccumulator] = [:] var hoursByStart: [Date: HourAccumulator] = [:] + // `windowed` is sorted by timestamp, so the day/hour memos hit on almost every entry; a miss only costs one + // Calendar interval lookup. Price once per entry and reuse it for the day, session and hour merges. + var dayMemo = LocalDayKeyMemo() + var hourMemo = HourStartMemo() for entry in windowed { - let dayKey = CostUsageLocalDay.key(from: entry.timestamp, calendar: calendar) + let cost = Self.listPriceUSD( + entry: entry, + customPricing: customPricing, + modelsDevCatalog: catalog, + customPricingOverlay: overlay) + let dayKey = dayMemo.key(for: entry.timestamp, calendar: calendar) var day = daysByKey[dayKey] ?? DayAccumulator() - Self.merge(entry, into: &day, customPricing: customPricing) + Self.merge(entry, cost: cost, into: &day) daysByKey[dayKey] = day let sessionID = entry.conversationID ?? entry.requestID var session = sessions[sessionID] ?? SessionAccumulator() session.lastActivity = max(session.lastActivity, entry.timestamp) session.requests = Self.saturatingAdd(session.requests, 1) - Self.merge(entry, into: &session, customPricing: customPricing) + Self.merge(entry, cost: cost, into: &session) sessions[sessionID] = session - let hour = calendar.dateInterval(of: .hour, for: entry.timestamp)?.start ?? entry.timestamp + let hour = hourMemo.start(for: entry.timestamp, calendar: calendar) var hourBucket = hoursByStart[hour] ?? HourAccumulator() - Self.merge(entry, into: &hourBucket, customPricing: customPricing) + Self.merge(entry, cost: cost, into: &hourBucket) hoursByStart[hour] = hourBucket } @@ -164,8 +198,8 @@ enum OpenCodexUsageAggregator { private static func merge( _ entry: OpenCodexUsageEntry, - into day: inout DayAccumulator, - customPricing: CostUsageCustomPricing) + cost: Double?, + into day: inout DayAccumulator) { let usage = entry.usage if let input = usage?.inputTokens { @@ -197,7 +231,6 @@ enum OpenCodexUsageAggregator { day.unmetered = Self.saturatingAdd(day.unmetered, entry.usageStatus == .unsupported ? 1 : 0) day.unpriced = Self.saturatingAdd(day.unpriced, entry.usageStatus == .unreported ? 1 : 0) - let cost = Self.listPriceUSD(entry: entry, customPricing: customPricing) if let cost { day.cost += cost day.sawCost = true @@ -220,15 +253,14 @@ enum OpenCodexUsageAggregator { private static func merge( _ entry: OpenCodexUsageEntry, - into session: inout SessionAccumulator, - customPricing: CostUsageCustomPricing) + cost: Double?, + into session: inout SessionAccumulator) { session.input = self.add(session.input, entry.usage?.inputTokens) session.output = self.add(session.output, entry.usage?.outputTokens) session.cacheRead = self.add(session.cacheRead, entry.usage?.cacheReadTokens) session.reasoning = self.add(session.reasoning, entry.usage?.reasoningOutputTokens) session.tokens = self.add(session.tokens, entry.resolvedTotalTokens) - let cost = self.listPriceUSD(entry: entry, customPricing: customPricing) session.cost = self.add(session.cost, cost) var model = session.models[entry.model] ?? ModelAccumulator() self.merge(entry, cost: cost, into: &model) @@ -237,14 +269,14 @@ enum OpenCodexUsageAggregator { private static func merge( _ entry: OpenCodexUsageEntry, - into hour: inout HourAccumulator, - customPricing: CostUsageCustomPricing) + cost: Double?, + into hour: inout HourAccumulator) { if let tokens = entry.resolvedTotalTokens { hour.tokens = self.saturatingAdd(hour.tokens, tokens) hour.sawTokens = true } - if let cost = self.listPriceUSD(entry: entry, customPricing: customPricing) { + if let cost { hour.cost += cost hour.sawCost = true } @@ -303,9 +335,16 @@ enum OpenCodexUsageAggregator { } } + /// List-price estimate for one entry. Precedence is unchanged from the per-merge pricing it replaces: + /// 1. `customPricing` — the snapshot's own overlay (provider-scoped rates passed by the caller); + /// 2. `CostUsagePricing.codexCostUSD` with the pre-resolved `customPricingOverlay` (the app-level overlay file, + /// which `codexCostUSD` would otherwise re-load per call) and the pre-resolved models.dev `modelsDevCatalog` + /// (otherwise `ModelsDevCache.load` per call), then the bundled/historical tables. private static func listPriceUSD( entry: OpenCodexUsageEntry, - customPricing: CostUsageCustomPricing) -> Double? + customPricing: CostUsageCustomPricing, + modelsDevCatalog: ModelsDevCatalog, + customPricingOverlay: CostUsageCustomPricing) -> Double? { guard entry.usageStatus == .reported || entry.usageStatus == .estimated else { return nil } let usage = entry.usage @@ -335,7 +374,9 @@ enum OpenCodexUsageAggregator { cachedInputTokens: cacheRead, outputTokens: output, cacheWriteInputTokens: cacheWrite, - pricingDate: entry.timestamp) + pricingDate: entry.timestamp, + modelsDevCatalog: modelsDevCatalog, + customPricing: customPricingOverlay) } private static func add(_ lhs: Int?, _ rhs: Int?) -> Int? { @@ -365,3 +406,51 @@ enum OpenCodexUsageAggregator { } } } + +extension OpenCodexUsageAggregator { + /// Reuses the calendar's `[start, next)` day interval while timestamps stay inside it. + /// Day keys still come from `CostUsageLocalDay` so DST and non-Gregorian calendars stay aligned: the key derives + /// y-m-d from the same Gregorian-in-timezone calendar whose `.day` interval is cached here, so the memo can never + /// disagree with computing the key per entry (DST days are simply 23 h / 25 h intervals). + private struct LocalDayKeyMemo { + var start = Date.distantPast + var end = Date.distantPast + var key = "" + + mutating func key(for timestamp: Date, calendar: Calendar) -> String { + if timestamp >= self.start, timestamp < self.end { + return self.key + } + let dayCalendar = CostUsageLocalDay.gregorianCalendar(matching: calendar) + guard let interval = dayCalendar.dateInterval(of: .day, for: timestamp) else { + self.start = Date.distantPast + self.end = Date.distantPast + return CostUsageLocalDay.key(from: timestamp, calendar: calendar) + } + self.start = interval.start + self.end = interval.end + self.key = CostUsageLocalDay.key(from: timestamp, calendar: calendar) + return self.key + } + } + + /// Reuses the calendar's hour interval while timestamps stay inside `[start, end)`. + private struct HourStartMemo { + var start = Date.distantPast + var end = Date.distantPast + + mutating func start(for timestamp: Date, calendar: Calendar) -> Date { + if timestamp >= self.start, timestamp < self.end { + return self.start + } + guard let interval = calendar.dateInterval(of: .hour, for: timestamp) else { + self.start = Date.distantPast + self.end = Date.distantPast + return timestamp + } + self.start = interval.start + self.end = interval.end + return self.start + } + } +} diff --git a/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageFanOut.swift b/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageFanOut.swift index 8bec20f31..a060ff8df 100644 --- a/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageFanOut.swift +++ b/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageFanOut.swift @@ -18,13 +18,21 @@ public enum OpenCodexUsageFanOut { } grouped[provider, default: []].append(entry) } + guard !grouped.isEmpty else { return [:] } + // Resolve the models.dev catalog and the custom-pricing overlay once for all providers; each snapshot then + // prices its entries against this shared context instead of re-reading both per pricing call (see + // `OpenCodexUsageAggregator.snapshot`). A missing catalog is passed as an empty one for the same reason. + let catalog = CostUsagePricing.modelsDevCatalog() ?? ModelsDevCatalog(providers: [:]) + let overlay = CostUsagePricing.customPricingOverlay() return grouped.mapValues { providerEntries in OpenCodexUsageAggregator.snapshot( entries: providerEntries, now: now, historyDays: historyDays, calendar: calendar, - customPricing: customPricing) + customPricing: customPricing, + modelsDevCatalog: catalog, + customPricingOverlay: overlay) } } diff --git a/Tests/CodexBarTests/BrowserCookieOrderLabelTests.swift b/Tests/CodexBarTests/BrowserCookieOrderLabelTests.swift index 9081a0ac2..1a0cc684d 100644 --- a/Tests/CodexBarTests/BrowserCookieOrderLabelTests.swift +++ b/Tests/CodexBarTests/BrowserCookieOrderLabelTests.swift @@ -16,6 +16,15 @@ struct BrowserCookieOrderStatusStringTests { #expect(Browser.defaultImportOrder.contains(.yandex)) } + @Test + func `claude cookie import prefers chrome without dropping fallback browsers`() throws { + let order = try #require(ProviderDefaults.metadata[.claude]?.browserCookieOrder) + + #expect(order.first == .chrome) + #expect(order.count == Browser.defaultImportOrder.count) + #expect(Set(order) == Set(Browser.defaultImportOrder)) + } + @Test func `cursor no session includes browser login hint`() { let order = ProviderDefaults.metadata[.cursor]?.browserCookieOrder ?? Browser.defaultImportOrder diff --git a/Tests/CodexBarTests/BrowserDetectionTests.swift b/Tests/CodexBarTests/BrowserDetectionTests.swift index d192d11b8..ca7152ebb 100644 --- a/Tests/CodexBarTests/BrowserDetectionTests.swift +++ b/Tests/CodexBarTests/BrowserDetectionTests.swift @@ -166,6 +166,57 @@ struct BrowserDetectionTests { #expect(browsers.cookieImportCandidates(using: detection) == [.firefox, .safari]) } + @Test + func `lazy cookie candidates stop before probing later browsers`() throws { + BrowserCookieAccessGate.resetForTesting() + defer { BrowserCookieAccessGate.resetForTesting() } + + let temp = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let chromeCookies = temp + .appendingPathComponent("Library/Application Support/Google/Chrome/Default/Network/Cookies") + try FileManager.default.createDirectory( + at: chromeCookies.deletingLastPathComponent(), + withIntermediateDirectories: true) + FileManager.default.createFile(atPath: chromeCookies.path, contents: Data()) + defer { try? FileManager.default.removeItem(at: temp) } + + let edgeProbeCount = OSAllocatedUnfairLock(initialState: 0) + let detection = BrowserDetection( + homeDirectory: temp.path, + cacheTTL: 0, + fileExists: { path in + if path == "/Applications/Google Chrome.app" { + return true + } + if path == "/Applications/Microsoft Edge.app" { + edgeProbeCount.withLock { $0 += 1 } + return true + } + return FileManager.default.fileExists(atPath: path) + }, + directoryContents: { path in + try? FileManager.default.contentsOfDirectory(atPath: path) + }) + var preflightCount = 0 + + let firstCandidate = KeychainAccessGate.withTaskOverrideForTesting(false) { + KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting { _, _ in + preflightCount += 1 + return .allowed + } operation: { + ProviderInteractionContext.$current.withValue(.background) { + Array([Browser.chrome, .edge] + .lazyCookieImportCandidates(using: detection) + .prefix(1)) + } + } + } + + #expect(firstCandidate == [.chrome]) + #expect(preflightCount == 1) + #expect(edgeProbeCount.withLock { $0 } == 0) + } + @Test func `chrome requires profile data`() throws { let temp = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) @@ -213,10 +264,6 @@ struct BrowserDetectionTests { @Test func `process filters chromium candidates despite false global keychain override`() throws { guard ProcessInfo.processInfo.environment["CODEXBAR_ALLOW_TEST_KEYCHAIN_ACCESS"] != "1" else { return } - KeychainAccessGate.resetOverrideForTesting() - defer { KeychainAccessGate.resetOverrideForTesting() } - - KeychainAccessGate.isDisabled = false let temp = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) try FileManager.default.createDirectory(at: temp, withIntermediateDirectories: true) @@ -235,7 +282,10 @@ struct BrowserDetectionTests { let detection = self.detection(homeDirectory: temp.path, installedBrowsers: [.chrome]) let browsers: [Browser] = [.chrome, .safari] - #expect(browsers.cookieImportCandidates(using: detection) == [.safari]) + let candidates = KeychainAccessGate.withStoredOverrideForTesting(false) { + browsers.cookieImportCandidates(using: detection) + } + #expect(candidates == [.safari]) } @Test diff --git a/Tests/CodexBarTests/ClaudeWebBrowserFallbackTests.swift b/Tests/CodexBarTests/ClaudeWebBrowserFallbackTests.swift new file mode 100644 index 000000000..f664ca250 --- /dev/null +++ b/Tests/CodexBarTests/ClaudeWebBrowserFallbackTests.swift @@ -0,0 +1,61 @@ +import Foundation +import os.lock +import SweetCookieKit +import Testing +@testable import CodexBarCore + +#if os(macOS) +@Suite(.serialized) +struct ClaudeWebBrowserFallbackTests { + @Test + func `chrome without a Claude session falls through to Safari`() throws { + let temp = FileManager.default.temporaryDirectory + .appendingPathComponent("claude-browser-fallback-\(UUID().uuidString)", isDirectory: true) + let chromeCookies = temp + .appendingPathComponent("Library/Application Support/Google/Chrome/Default/Network/Cookies") + try FileManager.default.createDirectory( + at: chromeCookies.deletingLastPathComponent(), + withIntermediateDirectories: true) + FileManager.default.createFile(atPath: chromeCookies.path, contents: Data()) + defer { try? FileManager.default.removeItem(at: temp) } + + let detection = BrowserDetection( + homeDirectory: temp.path, + cacheTTL: 0, + fileExists: { path in + if path == "/Applications/Google Chrome.app" { + return true + } + return FileManager.default.fileExists(atPath: path) + }, + directoryContents: { path in + try? FileManager.default.contentsOfDirectory(atPath: path) + }) + let attempts = OSAllocatedUnfairLock(initialState: [Browser]()) + let browserOverride: @Sendable (Browser) throws -> ClaudeWebAPIFetcher.SessionKeyInfo? = { browser in + attempts.withLock { $0.append(browser) } + guard browser == .safari else { return nil } + return ClaudeWebAPIFetcher.SessionKeyInfo( + key: "sk-ant-safari-fallback", + sourceLabel: "Safari", + cookieCount: 1) + } + + let sessionInfo = try BrowserCookieAccessGate.withShouldAttemptOverrideForTesting(true) { + try KeychainAccessGate.withTaskOverrideForTesting(false) { + try KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting { _, _ in .allowed } operation: { + try ProviderInteractionContext.$current.withValue(.background) { + try ClaudeWebSessionKeyImport.$browserOverrideForTesting.withValue(browserOverride) { + try ClaudeWebAPIFetcher.sessionKeyInfo(browserDetection: detection) + } + } + } + } + } + + #expect(attempts.withLock { $0 } == [.chrome, .safari]) + #expect(sessionInfo.key == "sk-ant-safari-fallback") + #expect(sessionInfo.sourceLabel == "Safari") + } +} +#endif diff --git a/Tests/CodexBarTests/CodexbarTests.swift b/Tests/CodexBarTests/CodexbarTests.swift index 6264422b0..8d72f3196 100644 --- a/Tests/CodexBarTests/CodexbarTests.swift +++ b/Tests/CodexBarTests/CodexbarTests.swift @@ -643,12 +643,11 @@ struct CodexBarTests { showUsed: true) #expect(percents.primary == 10) - #expect(percents.secondary != nil) - #expect(percents.secondary ?? 1 < 0.01) + #expect(percents.secondary == 0.1) } @Test - func `merged icon keeps exhausted warp bonus fully used`() { + func `merged icon preserves exhausted warp bonus layout`() { let snapshot = UsageSnapshot( primary: RateWindow(usedPercent: 10, windowMinutes: nil, resetsAt: nil, resetDescription: nil), secondary: RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: nil), @@ -657,11 +656,26 @@ struct CodexBarTests { let percents = IconRemainingResolver.resolvedPercents( snapshot: snapshot, style: .warp, - showUsed: true, - renderingStyle: .combined) + showUsed: true) + + #expect(percents.primary == 10) + #expect(percents.secondary == 0) + } + + @Test + func `merged icon keeps unused warp bonus lane visible`() { + 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()) + + let percents = IconRemainingResolver.resolvedPercents( + snapshot: snapshot, + style: .warp, + showUsed: true) #expect(percents.primary == 10) - #expect(percents.secondary == 100) + #expect(percents.secondary == 0.1) } @Test diff --git a/Tests/CodexBarTests/CostUsageStoreTests.swift b/Tests/CodexBarTests/CostUsageStoreTests.swift index ff3a9490a..f7f2f28f0 100644 --- a/Tests/CodexBarTests/CostUsageStoreTests.swift +++ b/Tests/CodexBarTests/CostUsageStoreTests.swift @@ -1048,6 +1048,7 @@ extension CostUsageStoreTests { "47144baa8daccf52", "2d17f4981b78d07f", "1ad1e41af7f25b3e", + "be0bb04e9e92b697", ]) let predecessorHash = "295616a4e7dcfc3f" let predecessorVersion = CostUsageStore.combinedSchemaVersion( diff --git a/Tests/CodexBarTests/GeminiAPITestHelpers.swift b/Tests/CodexBarTests/GeminiAPITestHelpers.swift index 19843ea18..7f31e15d7 100644 --- a/Tests/CodexBarTests/GeminiAPITestHelpers.swift +++ b/Tests/CodexBarTests/GeminiAPITestHelpers.swift @@ -125,6 +125,7 @@ enum GeminiAPITestHelpers { self.loadCodeAssistResponse(tierId: "legacy-tier") } + /// Synthetic error-body variant of the migration signal (token refresh / non-200 paths). static func consumerTierDeprecationResponse() -> Data { self.jsonData([ "error": [ @@ -138,4 +139,64 @@ enum GeminiAPITestHelpers { ], ]) } + + /// Mirrors the real `loadCodeAssist` HTTP 200 body Google returns to consumer accounts after the + /// June 2026 shutdown: the free tier is listed under `ineligibleTiers` with `UNSUPPORTED_CLIENT`, + /// `currentTier` is absent unless the account still holds a tier. + static func loadCodeAssistUnsupportedClientResponse( + currentTierId: String? = nil, + paidTierName: String? = nil) -> Data + { + var payload: [String: Any] = [ + "allowedTiers": [ + [ + "id": "standard-tier", + "name": "Gemini Code Assist", + "userDefinedCloudaicompanionProject": true, + "isDefault": true, + ], + ], + "ineligibleTiers": [ + [ + "reasonCode": "UNSUPPORTED_CLIENT", + "reasonMessage": """ + This client is no longer supported for Gemini Code Assist for individuals. \ + To continue using Gemini, please migrate to the Antigravity suite of products: \ + https://antigravity.google + """, + "tierId": "free-tier", + "tierName": "Gemini Code Assist for individuals", + ], + ], + ] + if let currentTierId { + payload["currentTier"] = ["id": currentTierId, "name": currentTierId] + } + if let paidTierName { + payload["paidTier"] = ["name": paidTierName] + } + return self.jsonData(payload) + } + + /// Mirrors the real `retrieveUserQuota` HTTP 403 body for an account without a Code Assist license. + /// Note: it carries no migration wording, so text matching alone cannot classify it. + static func quotaSubscriptionRequiredResponse() -> Data { + self.jsonData([ + "error": [ + "code": 403, + "message": """ + You do not have a valid license of this product. Please contact your administrator \ + to request a license. (#3501) + """, + "status": "PERMISSION_DENIED", + "details": [ + [ + "@type": "type.googleapis.com/google.rpc.ErrorInfo", + "reason": "SUBSCRIPTION_REQUIRED", + "domain": "cloudaicompanion.googleapis.com", + ], + ], + ], + ]) + } } diff --git a/Tests/CodexBarTests/GeminiConsumerTierMigrationTests.swift b/Tests/CodexBarTests/GeminiConsumerTierMigrationTests.swift index a223f49ee..0d25e64f1 100644 --- a/Tests/CodexBarTests/GeminiConsumerTierMigrationTests.swift +++ b/Tests/CodexBarTests/GeminiConsumerTierMigrationTests.swift @@ -149,6 +149,229 @@ struct GeminiConsumerTierMigrationTests { } } + @Test + func `reports consumer tier deprecation from loadCodeAssist 200 ineligible tiers`() async throws { + let env = try GeminiTestEnvironment() + defer { env.cleanup() } + try env.writeCredentials( + accessToken: "token", + refreshToken: nil, + expiry: Date().addingTimeInterval(3600), + idToken: nil) + + let dataLoader = Self.cloudCodeLoader( + loadCodeAssist: (200, GeminiAPITestHelpers.loadCodeAssistUnsupportedClientResponse()), + quota: (403, GeminiAPITestHelpers.quotaSubscriptionRequiredResponse())) + + let probe = GeminiStatusProbe(timeout: 1, homeDirectory: env.homeURL.path, dataLoader: dataLoader) + await Self.expectError(.consumerTierDeprecated) { + _ = try await probe.fetch() + } + } + + @Test + func `maps quota 403 to consumer tier deprecation after unsupported client signal`() async throws { + let env = try GeminiTestEnvironment() + defer { env.cleanup() } + try env.writeCredentials( + accessToken: "token", + refreshToken: nil, + expiry: Date().addingTimeInterval(3600), + idToken: nil) + + let dataLoader = Self.cloudCodeLoader( + loadCodeAssist: ( + 200, + GeminiAPITestHelpers.loadCodeAssistUnsupportedClientResponse(currentTierId: "free-tier")), + quota: (403, GeminiAPITestHelpers.quotaSubscriptionRequiredResponse())) + + let probe = GeminiStatusProbe(timeout: 1, homeDirectory: env.homeURL.path, dataLoader: dataLoader) + await Self.expectError(.consumerTierDeprecated) { + _ = try await probe.fetch() + } + } + + @Test + func `keeps licensed tier accounts despite ineligible free tier listing`() async throws { + let env = try GeminiTestEnvironment() + defer { env.cleanup() } + try env.writeCredentials( + accessToken: "token", + refreshToken: nil, + expiry: Date().addingTimeInterval(3600), + idToken: GeminiAPITestHelpers.makeIDToken(email: "dev@example.com", hostedDomain: "example.com")) + + let dataLoader = Self.cloudCodeLoader( + loadCodeAssist: ( + 200, + GeminiAPITestHelpers.loadCodeAssistUnsupportedClientResponse(currentTierId: "standard-tier")), + quota: (200, GeminiAPITestHelpers.sampleQuotaResponse())) + + let probe = GeminiStatusProbe(timeout: 1, homeDirectory: env.homeURL.path, dataLoader: dataLoader) + let snapshot = try await probe.fetch() + #expect(snapshot.accountPlan == "Paid") + } + + @Test + func `keeps plain http 403 error without unsupported client signal`() async throws { + let env = try GeminiTestEnvironment() + defer { env.cleanup() } + try env.writeCredentials( + accessToken: "token", + refreshToken: nil, + expiry: Date().addingTimeInterval(3600), + idToken: nil) + + let dataLoader = Self.cloudCodeLoader( + loadCodeAssist: (200, GeminiAPITestHelpers.loadCodeAssistStandardTierResponse()), + quota: (403, GeminiAPITestHelpers.quotaSubscriptionRequiredResponse())) + + let probe = GeminiStatusProbe(timeout: 1, homeDirectory: env.homeURL.path, dataLoader: dataLoader) + await Self.expectError(.apiError("HTTP 403")) { + _ = try await probe.fetch() + } + } + + @Test + func `keeps http 403 for licensed tier despite unsupported client listing`() async throws { + let env = try GeminiTestEnvironment() + defer { env.cleanup() } + try env.writeCredentials( + accessToken: "token", + refreshToken: nil, + expiry: Date().addingTimeInterval(3600), + idToken: GeminiAPITestHelpers.makeIDToken(email: "dev@example.com", hostedDomain: "example.com")) + + let dataLoader = Self.cloudCodeLoader( + loadCodeAssist: ( + 200, + GeminiAPITestHelpers.loadCodeAssistUnsupportedClientResponse(currentTierId: "standard-tier")), + quota: (403, GeminiAPITestHelpers.quotaSubscriptionRequiredResponse())) + + let probe = GeminiStatusProbe(timeout: 1, homeDirectory: env.homeURL.path, dataLoader: dataLoader) + await Self.expectError(.apiError("HTTP 403")) { + _ = try await probe.fetch() + } + } + + @Test + func `keeps a named paid tier without current tier out of the shutdown path`() async throws { + let env = try GeminiTestEnvironment() + defer { env.cleanup() } + try env.writeCredentials( + accessToken: "token", + refreshToken: nil, + expiry: Date().addingTimeInterval(3600), + idToken: nil) + + let dataLoader = Self.cloudCodeLoader( + loadCodeAssist: ( + 200, + GeminiAPITestHelpers.loadCodeAssistUnsupportedClientResponse(paidTierName: "Plus")), + quota: (200, GeminiAPITestHelpers.sampleQuotaResponse())) + + let probe = GeminiStatusProbe(timeout: 1, homeDirectory: env.homeURL.path, dataLoader: dataLoader) + let snapshot = try await probe.fetch() + #expect(snapshot.accountPlan == "Plus") + } + + @Test + func `keeps http 403 for a named paid tier without current tier`() async throws { + let env = try GeminiTestEnvironment() + defer { env.cleanup() } + try env.writeCredentials( + accessToken: "token", + refreshToken: nil, + expiry: Date().addingTimeInterval(3600), + idToken: nil) + + let dataLoader = Self.cloudCodeLoader( + loadCodeAssist: ( + 200, + GeminiAPITestHelpers.loadCodeAssistUnsupportedClientResponse(paidTierName: "Plus")), + quota: (403, GeminiAPITestHelpers.quotaSubscriptionRequiredResponse())) + + let probe = GeminiStatusProbe(timeout: 1, homeDirectory: env.homeURL.path, dataLoader: dataLoader) + await Self.expectError(.apiError("HTTP 403")) { + _ = try await probe.fetch() + } + } + + @Test + func `keeps a workspace account without current tier out of the shutdown path`() async throws { + let env = try GeminiTestEnvironment() + defer { env.cleanup() } + try env.writeCredentials( + accessToken: "token", + refreshToken: nil, + expiry: Date().addingTimeInterval(3600), + idToken: GeminiAPITestHelpers.makeIDToken(email: "dev@example.com", hostedDomain: "example.com")) + + let dataLoader = Self.cloudCodeLoader( + loadCodeAssist: (200, GeminiAPITestHelpers.loadCodeAssistUnsupportedClientResponse()), + quota: (200, GeminiAPITestHelpers.sampleQuotaResponse())) + + let probe = GeminiStatusProbe(timeout: 1, homeDirectory: env.homeURL.path, dataLoader: dataLoader) + let snapshot = try await probe.fetch() + #expect(!snapshot.modelQuotas.isEmpty) + } + + @Test + func `keeps http 403 for a workspace account on the free tier`() async throws { + let env = try GeminiTestEnvironment() + defer { env.cleanup() } + try env.writeCredentials( + accessToken: "token", + refreshToken: nil, + expiry: Date().addingTimeInterval(3600), + idToken: GeminiAPITestHelpers.makeIDToken(email: "dev@example.com", hostedDomain: "example.com")) + + let dataLoader = Self.cloudCodeLoader( + loadCodeAssist: ( + 200, + GeminiAPITestHelpers.loadCodeAssistUnsupportedClientResponse(currentTierId: "free-tier")), + quota: (403, GeminiAPITestHelpers.quotaSubscriptionRequiredResponse())) + + let probe = GeminiStatusProbe(timeout: 1, homeDirectory: env.homeURL.path, dataLoader: dataLoader) + await Self.expectError(.apiError("HTTP 403")) { + _ = try await probe.fetch() + } + } + + private static func cloudCodeLoader( + loadCodeAssist: (status: Int, body: Data), + quota: (status: Int, body: Data)) -> @Sendable (URLRequest) async throws -> (Data, URLResponse) + { + GeminiAPITestHelpers.dataLoader { request in + guard let url = request.url, let host = url.host else { + throw URLError(.badURL) + } + switch host { + case "cloudresourcemanager.googleapis.com": + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.jsonData(["projects": []])) + case "cloudcode-pa.googleapis.com": + if url.path == "/v1internal:loadCodeAssist" { + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: loadCodeAssist.status, + body: loadCodeAssist.body) + } + if url.path == "/v1internal:retrieveUserQuota" { + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: quota.status, + body: quota.body) + } + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data()) + default: + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data()) + } + } + } + private static func expectError( _ expected: GeminiStatusProbeError, operation: () async throws -> Void) async diff --git a/Tests/CodexBarTests/GeminiLoginAlertTests.swift b/Tests/CodexBarTests/GeminiLoginAlertTests.swift index 5905904b6..434aa8fbe 100644 --- a/Tests/CodexBarTests/GeminiLoginAlertTests.swift +++ b/Tests/CodexBarTests/GeminiLoginAlertTests.swift @@ -1,3 +1,4 @@ +import CodexBarCore import Testing @testable import CodexBar @@ -18,6 +19,30 @@ struct GeminiLoginAlertTests { #expect(info?.message == "Boom") } + @Test + func `returns antigravity guidance when consumer tier is deprecated`() { + let result = GeminiLoginRunner.Result(outcome: .consumerTierDeprecated) + let info = StatusItemController.geminiLoginAlertInfo(for: result) + #expect(info?.title == "Gemini CLI login is no longer supported") + #expect(info?.message.hasPrefix(GeminiConsumerTierMigration.deprecationError) == true) + } + + @Test + func `offers an account switch recovery when consumer tier is deprecated`() { + let result = GeminiLoginRunner.Result(outcome: .consumerTierDeprecated) + let info = StatusItemController.geminiLoginAlertInfo(for: result) + #expect(info?.confirmButtonTitle == "Switch Account…") + #expect(info?.message.contains(GeminiConsumerTierMigration.loginSwitchAccountPrompt) == true) + } + + @Test + func `plain login failures offer no recovery button`() { + let missing = StatusItemController.geminiLoginAlertInfo(for: .init(outcome: .missingBinary)) + let failed = StatusItemController.geminiLoginAlertInfo(for: .init(outcome: .launchFailed("Boom"))) + #expect(missing?.confirmButtonTitle == nil) + #expect(failed?.confirmButtonTitle == nil) + } + @Test func `returns nil on success`() { let result = GeminiLoginRunner.Result(outcome: .success) diff --git a/Tests/CodexBarTests/GeminiLoginRunnerTests.swift b/Tests/CodexBarTests/GeminiLoginRunnerTests.swift new file mode 100644 index 000000000..3bc043783 --- /dev/null +++ b/Tests/CodexBarTests/GeminiLoginRunnerTests.swift @@ -0,0 +1,15 @@ +import Testing +@testable import CodexBar + +struct GeminiLoginRunnerTests { + @Test + func `skips the Gemini CLI when the consumer tier deprecation was observed`() async { + let result = await GeminiLoginRunner.run(consumerTierDeprecationObserved: true) { + Issue.record("Credentials watcher must not be armed when the Gemini CLI is skipped") + } + guard case .consumerTierDeprecated = result.outcome else { + Issue.record("Expected consumerTierDeprecated outcome, got \(result.outcome)") + return + } + } +} diff --git a/Tests/CodexBarTests/GeminiProviderMigrationSettingsTests.swift b/Tests/CodexBarTests/GeminiProviderMigrationSettingsTests.swift index 711ea7cb9..973b65e19 100644 --- a/Tests/CodexBarTests/GeminiProviderMigrationSettingsTests.swift +++ b/Tests/CodexBarTests/GeminiProviderMigrationSettingsTests.swift @@ -85,6 +85,52 @@ struct GeminiProviderMigrationSettingsTests { #expect(settings.isProviderEnabled(provider: .antigravity, metadata: antigravity) == wasEnabled) } + @Test + func `google shutdown observation arms the login guard`() { + let settings = self.makeSettings() + let store = self.makeStore(settings: settings) + + store.observeGeminiConsumerTierDeprecation(from: GeminiStatusProbeError.consumerTierDeprecated) + + #expect(store.geminiObservedConsumerTierDeprecation) + #expect(store.geminiObservedGoogleConsumerTierShutdown) + } + + @Test + func `local antigravity handoff does not arm the login guard`() { + let settings = self.makeSettings() + let store = self.makeStore(settings: settings) + + store.observeGeminiConsumerTierDeprecation( + from: GeminiStatusProbeError.oauthCredentialsUnavailableWithAntigravity) + + #expect(store.geminiObservedConsumerTierDeprecation) + #expect(!store.geminiObservedGoogleConsumerTierShutdown) + } + + @Test + func `local handoff after a google shutdown keeps the login guard armed`() { + let settings = self.makeSettings() + let store = self.makeStore(settings: settings) + store.observeGeminiConsumerTierDeprecation(from: GeminiStatusProbeError.consumerTierDeprecated) + + store.observeGeminiConsumerTierDeprecation( + from: GeminiStatusProbeError.oauthCredentialsUnavailableWithAntigravity) + + #expect(store.geminiObservedGoogleConsumerTierShutdown) + } + + @Test + func `clearing the observation disarms the login guard`() { + let settings = self.makeSettings() + let store = self.makeStore(settings: settings) + store.observeGeminiConsumerTierDeprecation(from: GeminiStatusProbeError.consumerTierDeprecated) + + store.clearGeminiConsumerTierDeprecationObservation() + + #expect(!store.geminiObservedGoogleConsumerTierShutdown) + } + @Test func `settings action appears when local antigravity handoff was observed`() { let settings = self.makeSettings() diff --git a/Tests/CodexBarTests/KeychainCacheStoreTests.swift b/Tests/CodexBarTests/KeychainCacheStoreTests.swift index 6ad835e51..aeab6d511 100644 --- a/Tests/CodexBarTests/KeychainCacheStoreTests.swift +++ b/Tests/CodexBarTests/KeychainCacheStoreTests.swift @@ -324,6 +324,90 @@ struct KeychainCacheStoreTests { } } + @Test + func `bundled ad hoc builds keep cookie cache in process memory`() { + KeychainCacheStore.resetDisabledAccessMemoryStoreForTesting() + defer { KeychainCacheStore.resetDisabledAccessMemoryStoreForTesting() } + + let service = "adhoc-memory-\(UUID().uuidString)" + let key = KeychainCacheStore.Key.cookie(provider: .claude) + let entry = TestEntry(value: "sessionKey=memory", storedAt: Date(timeIntervalSince1970: 4)) + + KeychainCacheStore.withBundledAdHocProcessForTesting(true) { + KeychainCacheStore.withServiceOverrideForTesting(service) { + #expect(KeychainCacheStore.storeResult(key: key, entry: entry)) + #expect(self.loadedEntry(for: key) == entry) + #expect(KeychainCacheStore.keys(category: "cookie") == [key]) + #expect(KeychainCacheStore.clearResult(key: key) == .removed) + #expect(self.loadedEntry(for: key) == nil) + } + } + } + + @Test + func `certificate signed builds keep cookie cache in persistent store`() { + KeychainCacheStore.resetDisabledAccessMemoryStoreForTesting() + KeychainCacheStore.setTestStoreForTesting(true) + defer { + KeychainCacheStore.setTestStoreForTesting(false) + KeychainCacheStore.resetDisabledAccessMemoryStoreForTesting() + } + + let service = "signed-persistent-\(UUID().uuidString)" + let key = KeychainCacheStore.Key.cookie(provider: .claude) + let entry = TestEntry(value: "sessionKey=persistent", storedAt: Date(timeIntervalSince1970: 5)) + + KeychainCacheStore.withBundledAdHocProcessForTesting(false) { + KeychainCacheStore.withServiceOverrideForTesting(service) { + #expect(KeychainCacheStore.storeResult(key: key, entry: entry)) + #expect(self.loadedEntry(for: key) == entry) + } + } + + KeychainCacheStore.withBundledAdHocProcessForTesting(true) { + KeychainCacheStore.withServiceOverrideForTesting(service) { + #expect(self.loadedEntry(for: key) == nil) + } + } + + KeychainCacheStore.withBundledAdHocProcessForTesting(false) { + KeychainCacheStore.withServiceOverrideForTesting(service) { + #expect(self.loadedEntry(for: key) == entry) + #expect(KeychainCacheStore.clearResult(key: key) == .removed) + } + } + } + + @Test + func `code signing certificate detection recognizes signed system code`() { + #expect(KeychainCacheStore.hasCodeSigningCertificate(at: URL(fileURLWithPath: "/usr/bin/security"))) + #expect(!KeychainCacheStore.hasCodeSigningCertificate( + at: URL(fileURLWithPath: "/path/that/does/not/exist"))) + } + + @Test + func `bundled ad hoc builds do not move OAuth credentials into memory`() { + KeychainCacheStore.resetDisabledAccessMemoryStoreForTesting() + defer { + KeychainCacheStore.resetDisabledAccessMemoryStoreForTesting() + KeychainAccessGate.resetOverrideForTesting() + } + + let service = "adhoc-memory-oauth-\(UUID().uuidString)" + let key = KeychainCacheStore.Key.oauth(provider: .claude) + let entry = TestEntry(value: "synthetic-oauth-credential", storedAt: Date(timeIntervalSince1970: 5)) + + KeychainAccessGate.withTaskOverrideForTesting(true) { + KeychainCacheStore.withBundledAdHocProcessForTesting(true) { + KeychainCacheStore.withServiceOverrideForTesting(service) { + #expect(!KeychainCacheStore.storeResult(key: key, entry: entry)) + #expect(self.loadedEntry(for: key) == nil) + #expect(KeychainCacheStore.keysResult(category: "oauth") == .failed) + } + } + } + } + @Test func `disabled keychain access does not retain OAuth entries in memory`() { KeychainCacheStore.resetDisabledAccessMemoryStoreForTesting() diff --git a/Tests/CodexBarTests/KeychainNoUIQueryTests.swift b/Tests/CodexBarTests/KeychainNoUIQueryTests.swift index 8ec878f01..f430ad08d 100644 --- a/Tests/CodexBarTests/KeychainNoUIQueryTests.swift +++ b/Tests/CodexBarTests/KeychainNoUIQueryTests.swift @@ -50,6 +50,25 @@ struct KeychainNoUIQueryTests { #expect((query[kSecUseAuthenticationUI as String] as? String) == self.resolveSecurityUIFailValue()) } + @Test + func `generic password preflight memo is scoped to one operation`() { + var checkCount = 0 + + KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting { _, _ in + checkCount += 1 + return .allowed + } operation: { + KeychainAccessPreflight.withMemoizedGenericPasswordChecks { + _ = KeychainAccessPreflight.checkGenericPassword(service: "Chrome Safe Storage", account: "Chrome") + _ = KeychainAccessPreflight.checkGenericPassword(service: "Chrome Safe Storage", account: "Chrome") + _ = KeychainAccessPreflight.checkGenericPassword(service: "Chrome Safe Storage", account: "Canary") + } + _ = KeychainAccessPreflight.checkGenericPassword(service: "Chrome Safe Storage", account: "Chrome") + } + + #expect(checkCount == 3) + } + @Test func `decrypt ACL requires successful code signature validation without a prompt selector`() { #expect(KeychainAccessPreflight.decryptACLAllowsCurrentProcess( diff --git a/Tests/CodexBarTests/MergedWarpIconRenderingTests.swift b/Tests/CodexBarTests/MergedWarpIconRenderingTests.swift new file mode 100644 index 000000000..5995b5f14 --- /dev/null +++ b/Tests/CodexBarTests/MergedWarpIconRenderingTests.swift @@ -0,0 +1,80 @@ +import AppKit +import CodexBarCore +import Testing +@testable import CodexBar + +@MainActor +@Suite(.serialized) +struct MergedWarpIconRenderingTests { + @Test + func `merged warp bonus lane is preserved in show used mode when bonus is unused`() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "MergedWarpIconRenderingTests-unused-bonus"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .warp + 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: AccountInfo(email: nil, plan: nil), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + defer { controller.releaseStatusItemsForTesting() } + + let exhaustedSnapshot = 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(exhaustedSnapshot, provider: .warp) + store._setErrorForTesting(nil, provider: .warp) + + controller.applyIcon(phase: nil) + let exhaustedSignature = controller.lastAppliedMergedIconRenderSignature + let exhaustedImage = controller.statusItem.button?.image?.tiffRepresentation + + let unusedSnapshot = 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(unusedSnapshot, provider: .warp) + + controller.applyIcon(phase: nil) + let unusedSignature = controller.lastAppliedMergedIconRenderSignature + + #expect(exhaustedSignature != nil) + #expect(unusedSignature != nil) + #expect(exhaustedSignature != unusedSignature) + #expect(exhaustedSignature?.contains("weekly=0.000") == true) + #expect(unusedSignature?.contains("weekly=0.100") == true) + + guard let image = controller.statusItem.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 } + + // The exhausted and unused values must reach different renderer cache entries. The latter preserves a + // normal-strength empty bottom lane instead of Warp's dimmed missing-secondary lane. + #expect(exhaustedImage != image.tiffRepresentation) + let alpha = (rep.colorAt(x: 18, y: 9) ?? .clear).alphaComponent + #expect(alpha > 0.2) + } +} diff --git a/Tests/CodexBarTests/ModelsDevPricingTests.swift b/Tests/CodexBarTests/ModelsDevPricingTests.swift index a0da2f637..9d9c156de 100644 --- a/Tests/CodexBarTests/ModelsDevPricingTests.swift +++ b/Tests/CodexBarTests/ModelsDevPricingTests.swift @@ -1262,6 +1262,60 @@ extension ModelsDevPricingTests { #expect(reloaded.artifact == nil) } + @Test + func `memo invalidates when cache file size changes`() throws { + let root = try Self.cacheRoot() + try ModelsDevCache.save(catalog: Self.fixtureCatalog(), fetchedAt: Date(), cacheRoot: root) + let url = ModelsDevCache.cacheFileURL(cacheRoot: root) + let pinnedDate = Date(timeIntervalSince1970: 1_700_000_000) + try FileManager.default.setAttributes([.modificationDate: pinnedDate], ofItemAtPath: url.path) + let primed = try #require(ModelsDevCache.load(cacheRoot: root).artifact) + let originalSize = try #require( + try (FileManager.default.attributesOfItem(atPath: url.path)[.size]) as? NSNumber).intValue + + // Same pinned mtime, larger invalid payload: a memo keyed only on mtime would still hit. + try Data(repeating: 0x7B, count: originalSize + 16).write(to: url) + try FileManager.default.setAttributes([.modificationDate: pinnedDate], ofItemAtPath: url.path) + + let reloaded = ModelsDevCache.load(cacheRoot: root) + #expect(reloaded.artifact != primed) + #expect(reloaded.error == .invalidJSON) + } + + @Test + func `memo invalidates when cache file mtime changes`() throws { + let root = try Self.cacheRoot() + try ModelsDevCache.save(catalog: Self.fixtureCatalog(), fetchedAt: Date(), cacheRoot: root) + let url = ModelsDevCache.cacheFileURL(cacheRoot: root) + let pinnedDate = Date(timeIntervalSince1970: 1_700_000_000) + try FileManager.default.setAttributes([.modificationDate: pinnedDate], ofItemAtPath: url.path) + let primed = try #require(ModelsDevCache.load(cacheRoot: root).artifact) + + let size = try #require( + try (FileManager.default.attributesOfItem(atPath: url.path)[.size]) as? NSNumber).intValue + try Data(repeating: 0, count: size).write(to: url) + try FileManager.default.setAttributes( + [.modificationDate: Date(timeIntervalSince1970: 1_700_000_100)], + ofItemAtPath: url.path) + + let reloaded = ModelsDevCache.load(cacheRoot: root) + #expect(reloaded.artifact != primed) + #expect(reloaded.error == .invalidJSON) + } + + @Test + func `load metadata check is one stat per load`() throws { + let root = try Self.cacheRoot() + try ModelsDevCache.save(catalog: Self.fixtureCatalog(), fetchedAt: Date(), cacheRoot: root) + let recorder = ModelsDevCache.MetadataReadRecorder() + ModelsDevCache.withMetadataReadRecorderForTesting(recorder) { + _ = ModelsDevCache.load(cacheRoot: root) + #expect(recorder.snapshot() == 1) + _ = ModelsDevCache.load(cacheRoot: root) + #expect(recorder.snapshot() == 2) + } + } + @Test func `client fetches with mock transport`() async throws { let data = try Self.fixtureData() diff --git a/Tests/CodexBarTests/OpenCodexUsageFanOutTests.swift b/Tests/CodexBarTests/OpenCodexUsageFanOutTests.swift index 51f893cf9..1d6f47661 100644 --- a/Tests/CodexBarTests/OpenCodexUsageFanOutTests.swift +++ b/Tests/CodexBarTests/OpenCodexUsageFanOutTests.swift @@ -1,7 +1,7 @@ -import CodexBarCore import Foundation import Testing @testable import CodexBar +@testable import CodexBarCore struct OpenCodexUsageFanOutTests { @Test func `snapshotsBySubscription routes openai spend into codex`() throws { @@ -168,4 +168,680 @@ struct OpenCodexUsageFanOutTests { let result = SpendDashboardSource.mergingOpenCodexInputs([dummy], request: request) #expect(!result.contains(where: { $0.id == SpendDashboardModel.openCodexSourceID })) } + + @Test + func `snapshot matches a naive per-entry reference across DST and overlays`() throws { + let calendar = try Self.losAngelesCalendar() + let now = try Self.date(calendar, DateComponents(year: 2026, month: 11, day: 2, hour: 12, minute: 0)) + let customPricing = CostUsageCustomPricing.parse(Data(""" + { + "openai/gpt-5.4": { "input": 1.5, "output": 6, "cacheRead": 0.15, "cacheWrite": 1.875 } + } + """.utf8)) + let firstFallBack = try Self.date(calendar, DateComponents(year: 2026, month: 11, day: 1, hour: 1, minute: 30)) + let entries = try [ + OpenCodexUsageEntry( + requestID: "dup", + timestamp: Self.date(calendar, DateComponents(year: 2026, month: 11, day: 1, hour: 10, minute: 0)), + provider: "openai", + model: "gpt-5.4", + usageStatus: .reported, + conversationID: "chat-dup", + usage: OpenCodexTokenUsage(inputTokens: 1, outputTokens: 1, totalTokens: 2), + totalTokens: 2), + OpenCodexUsageEntry( + requestID: "dup", + timestamp: Self.date(calendar, DateComponents(year: 2026, month: 11, day: 1, hour: 10, minute: 5)), + provider: "openai", + model: "gpt-5.4", + usageStatus: .reported, + conversationID: "chat-dup", + usage: OpenCodexTokenUsage( + inputTokens: 80, + outputTokens: 20, + cacheReadInputTokens: 10, + cacheCreationInputTokens: 5, + totalTokens: 100), + totalTokens: 100), + OpenCodexUsageEntry( + requestID: "spring", + timestamp: Self.date(calendar, DateComponents(year: 2026, month: 3, day: 8, hour: 1, minute: 30)), + provider: "openai", + model: "gpt-5.4", + usageStatus: .reported, + conversationID: "chat-dst", + usage: OpenCodexTokenUsage(inputTokens: 40, outputTokens: 10, totalTokens: 50), + totalTokens: 50), + OpenCodexUsageEntry( + requestID: "spring-after", + timestamp: Self.date(calendar, DateComponents(year: 2026, month: 3, day: 8, hour: 3, minute: 0)), + provider: "openai", + model: "gpt-5.4", + usageStatus: .estimated, + conversationID: "chat-dst", + usage: OpenCodexTokenUsage(inputTokens: 20, outputTokens: 4, totalTokens: 24), + totalTokens: 24), + OpenCodexUsageEntry( + requestID: "fallback-first", + timestamp: firstFallBack, + provider: "opencode-go", + model: "opencode-go/gpt-5.2", + usageStatus: .reported, + conversationID: "chat-fallback", + usage: OpenCodexTokenUsage(inputTokens: 12, outputTokens: 3, totalTokens: 15), + totalTokens: 15), + OpenCodexUsageEntry( + requestID: "fallback-second", + timestamp: firstFallBack.addingTimeInterval(3600), + provider: "opencode-go", + model: "opencode-go/gpt-5.2", + usageStatus: .reported, + conversationID: "chat-fallback", + usage: OpenCodexTokenUsage(inputTokens: 8, outputTokens: 2, totalTokens: 10), + totalTokens: 10), + OpenCodexUsageEntry( + requestID: "unreported", + timestamp: Self.date(calendar, DateComponents(year: 2026, month: 11, day: 2, hour: 9, minute: 0)), + provider: "openai", + model: "gpt-5.4", + usageStatus: .unreported, + conversationID: "chat-today"), + OpenCodexUsageEntry( + requestID: "unsupported", + timestamp: Self.date(calendar, DateComponents(year: 2026, month: 11, day: 2, hour: 9, minute: 15)), + provider: "openai", + model: "gpt-5.4", + usageStatus: .unsupported, + conversationID: "chat-today", + usage: OpenCodexTokenUsage(inputTokens: 4, outputTokens: 1, totalTokens: 5), + totalTokens: 5), + OpenCodexUsageEntry( + requestID: "unknown-estimated", + timestamp: Self.date(calendar, DateComponents(year: 2026, month: 11, day: 2, hour: 9, minute: 30)), + provider: "openai", + model: "not-a-priced-model-xyz", + usageStatus: .estimated, + conversationID: "chat-today", + usage: OpenCodexTokenUsage(inputTokens: 6, outputTokens: 1, totalTokens: 7), + totalTokens: 7), + OpenCodexUsageEntry( + requestID: "outside-window", + timestamp: Self.date(calendar, DateComponents(year: 2024, month: 1, day: 1, hour: 12, minute: 0)), + provider: "openai", + model: "gpt-5.4", + usageStatus: .reported, + usage: OpenCodexTokenUsage(inputTokens: 999, outputTokens: 1, totalTokens: 1000), + totalTokens: 1000), + OpenCodexUsageEntry( + requestID: "catalog-priced", + timestamp: now, + provider: "openai", + model: "gpt-5.2", + usageStatus: .reported, + conversationID: "chat-catalog", + usage: OpenCodexTokenUsage(inputTokens: 1000, outputTokens: 1000, totalTokens: 2000), + totalTokens: 2000), + ] + + let root = try Self.modelsDevCacheRoot() + defer { try? FileManager.default.removeItem(at: root) } + let fixtureCatalog = Self.fixturePricingCatalog() + #expect(ModelsDevCache.save(catalog: fixtureCatalog, fetchedAt: now, cacheRoot: root)) + let loadedCatalog = try #require(ModelsDevCache.load(cacheRoot: root).artifact?.catalog) + + let snapshot = OpenCodexUsageAggregator.snapshot( + entries: entries, + now: now, + historyDays: 365, + calendar: calendar, + customPricing: customPricing, + modelsDevCatalog: loadedCatalog) + let reference = OpenCodexUsageSnapshotReference.snapshot( + entries: entries, + now: now, + historyDays: 365, + calendar: calendar, + customPricing: customPricing, + modelsDevCacheRoot: root) + + #expect(snapshot == reference) + #expect(snapshot.daily.count >= 3) + #expect(snapshot.hourly.count >= 5) + #expect(snapshot.sessions.contains { $0.sessionID == "chat-dup" && $0.requestCount == 1 }) + + let catalogPriced = try #require( + snapshot.daily.flatMap { $0.modelBreakdowns ?? [] }.first { $0.modelName == "gpt-5.2" }) + let catalogCost = try #require(catalogPriced.costUSD) + let bundledCost = CostUsagePricing.codexCostUSD( + model: "gpt-5.2", + inputTokens: 1000, + cachedInputTokens: 0, + outputTokens: 1000, + cacheWriteInputTokens: 0, + pricingDate: now, + modelsDevCatalog: ModelsDevCatalog(providers: [:]), + customPricing: .empty) + #expect(catalogCost != bundledCost) + } + + @Test + func `snapshot resolves the models.dev catalog once for many entries`() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(secondsFromGMT: 0)) + let now = Date(timeIntervalSince1970: 1_784_179_200) + let entryCount = 60 + let entries = (0.. Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(identifier: "America/Los_Angeles")) + return calendar + } + + private static func santiagoCalendar() throws -> Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(identifier: "America/Santiago")) + return calendar + } + + private static func date(_ calendar: Calendar, _ components: DateComponents) throws -> Date { + try #require(calendar.date(from: components)) + } + + private static func assertDayAndHourMemos( + calendar: Calendar, + now: Date, + samples: [(id: String, timestamp: Date, tokens: Int)]) throws + { + let entries = samples.map { sample in + OpenCodexUsageEntry( + requestID: sample.id, + timestamp: sample.timestamp, + provider: "openai", + model: "gpt-5.4", + usageStatus: .reported, + usage: OpenCodexTokenUsage( + inputTokens: sample.tokens, + outputTokens: 0, + totalTokens: sample.tokens), + totalTokens: sample.tokens) + } + let snapshot = OpenCodexUsageAggregator.snapshot( + entries: entries, + now: now, + historyDays: 365, + calendar: calendar) + for sample in samples { + let expectedDay = CostUsageLocalDay.key(from: sample.timestamp, calendar: calendar) + let expectedHour = calendar.dateInterval(of: .hour, for: sample.timestamp)?.start + ?? sample.timestamp + let day = try #require(snapshot.daily.first { $0.date == expectedDay }) + let hour = try #require(snapshot.hourly.first { $0.hour == expectedHour }) + #expect(day.totalTokens ?? 0 >= sample.tokens) + #expect(hour.totalTokens == sample.tokens) + #expect(hour.hour == expectedHour) + } + } + + private static func modelsDevCacheRoot() throws -> URL { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-opencodex-modelsdev-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + return root + } + + private static func fixturePricingCatalog() -> ModelsDevCatalog { + ModelsDevCatalog(providers: [ + "openai": ModelsDevProvider( + id: "openai", + name: "OpenAI", + models: [ + "gpt-5.2": ModelsDevModel( + id: "gpt-5.2", + name: nil, + cost: ModelsDevCost(input: 99, output: 199), + limit: nil), + "gpt-5.4": ModelsDevModel( + id: "gpt-5.4", + name: nil, + cost: ModelsDevCost(input: 88, output: 188), + limit: nil), + ]), + "opencode-go": ModelsDevProvider( + id: "opencode-go", + name: nil, + models: [ + "gpt-5.2": ModelsDevModel( + id: "gpt-5.2", + name: nil, + cost: ModelsDevCost(input: 50, output: 80), + limit: nil), + ]), + ]) + } +} + +private enum OpenCodexUsageSnapshotReference { + static func snapshot( + entries: [OpenCodexUsageEntry], + now: Date, + historyDays: Int, + calendar: Calendar, + customPricing: CostUsageCustomPricing, + modelsDevCacheRoot: URL? = nil) -> CostUsageTokenSnapshot + { + let days = max(1, min(365, historyDays)) + let today = calendar.startOfDay(for: now) + let windowStart = calendar.date(byAdding: .day, value: -(days - 1), to: today) ?? today + var unique: [String: OpenCodexUsageEntry] = [:] + for entry in entries { + unique[entry.requestID] = entry + } + let windowed = unique.values.filter { $0.timestamp >= windowStart && $0.timestamp <= now } + .sorted { lhs, rhs in + if lhs.timestamp != rhs.timestamp { + return lhs.timestamp < rhs.timestamp + } + return lhs.requestID < rhs.requestID + } + + var daysByKey: [String: OpenCodexUsageAggregator.DayAccumulator] = [:] + var sessions: [String: OpenCodexUsageAggregator.SessionAccumulator] = [:] + var hoursByStart: [Date: OpenCodexUsageAggregator.HourAccumulator] = [:] + for entry in windowed { + let cost = Self.listPriceUSD( + entry: entry, + customPricing: customPricing, + modelsDevCacheRoot: modelsDevCacheRoot) + let dayKey = CostUsageLocalDay.key(from: entry.timestamp, calendar: calendar) + var day = daysByKey[dayKey] ?? OpenCodexUsageAggregator.DayAccumulator() + Self.merge(entry, cost: cost, into: &day) + daysByKey[dayKey] = day + + let sessionID = entry.conversationID ?? entry.requestID + var session = sessions[sessionID] ?? OpenCodexUsageAggregator.SessionAccumulator() + session.lastActivity = max(session.lastActivity, entry.timestamp) + session.requests += 1 + Self.merge(entry, cost: cost, into: &session) + sessions[sessionID] = session + + let hour = calendar.dateInterval(of: .hour, for: entry.timestamp)?.start ?? entry.timestamp + var hourBucket = hoursByStart[hour] ?? OpenCodexUsageAggregator.HourAccumulator() + Self.merge(entry, cost: cost, into: &hourBucket) + hoursByStart[hour] = hourBucket + } + + let daily = daysByKey.keys.sorted().compactMap { key -> CostUsageDailyReport.Entry? in + guard let day = daysByKey[key] else { return nil } + return Self.entry(dayKey: key, day: day) + } + let sessionRows = sessions.keys.sorted().compactMap { key -> CostUsageSessionBreakdown? in + guard let session = sessions[key] else { return nil } + return CostUsageSessionBreakdown( + sessionID: key, + lastActivity: session.lastActivity, + inputTokens: session.input, + cachedInputTokens: session.cacheRead, + outputTokens: session.output, + reasoningTokens: session.reasoning, + totalTokens: session.tokens, + requestCount: session.requests, + costUSD: session.cost, + modelBreakdowns: Self.modelBreakdowns(session.models)) + } + .sorted { lhs, rhs in + if lhs.lastActivity != rhs.lastActivity { + return lhs.lastActivity > rhs.lastActivity + } + return lhs.sessionID < rhs.sessionID + } + let hourly = hoursByStart.keys.sorted().map { hour in + let bucket = hoursByStart[hour] ?? OpenCodexUsageAggregator.HourAccumulator() + return CostUsageHourlyEntry( + hour: hour, + totalTokens: bucket.sawTokens ? bucket.tokens : nil, + costUSD: bucket.sawCost ? bucket.cost : nil) + } + let todayEntry = CostUsageTokenSnapshot.entry( + in: daily, + forLocalDayContaining: now, + calendar: calendar) + let windowSummary = CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: nil, + last30DaysCostUSD: nil, + historyDays: days, + daily: daily, + sessions: Array(sessionRows.prefix(64)), + updatedAt: now) + .summary(forLastDays: min(30, days), calendar: calendar) + return CostUsageTokenSnapshot( + sessionTokens: todayEntry?.totalTokens ?? (daily.isEmpty ? nil : 0), + sessionCostUSD: todayEntry?.costUSD ?? (daily.isEmpty ? nil : 0), + sessionRequests: todayEntry?.requestCount ?? (daily.isEmpty ? nil : 0), + last30DaysTokens: windowSummary.totalTokens, + last30DaysCostUSD: windowSummary.totalCostUSD, + last30DaysRequests: windowSummary.totalRequests, + historyDays: days, + historyLabel: "OpenCodex usage.jsonl", + costProvenance: .listPriceEstimate, + daily: daily, + sessions: Array(sessionRows.prefix(64)), + hourly: hourly, + updatedAt: now) + } + + private static func merge( + _ entry: OpenCodexUsageEntry, + cost: Double?, + into day: inout OpenCodexUsageAggregator.DayAccumulator) + { + let usage = entry.usage + if let input = usage?.inputTokens { + day.input += input + day.sawInput = true + } + if let output = usage?.outputTokens { + day.output += output + day.sawOutput = true + } + if let cacheRead = usage?.cacheReadTokens { + day.cacheRead += cacheRead + day.sawCacheRead = true + } + if let cacheCreation = usage?.cacheCreationInputTokens { + day.cacheCreation += cacheCreation + day.sawCacheCreation = true + } + if let reasoning = usage?.reasoningOutputTokens { + day.reasoning += reasoning + day.sawReasoning = true + } + if let tokens = entry.resolvedTotalTokens { + day.tokens += tokens + day.sawTokens = true + } + day.priced += entry.usageStatus == .reported ? 1 : 0 + day.estimated += entry.usageStatus == .estimated ? 1 : 0 + day.unmetered += entry.usageStatus == .unsupported ? 1 : 0 + day.unpriced += entry.usageStatus == .unreported ? 1 : 0 + if let cost { + day.cost += cost + day.sawCost = true + } else if entry.usageStatus == .reported { + day.unpriced += 1 + if day.priced > 0 { + day.priced -= 1 + } + } else if entry.usageStatus == .estimated { + day.unpriced += 1 + if day.estimated > 0 { + day.estimated -= 1 + } + } + var model = day.models[entry.model] ?? OpenCodexUsageAggregator.ModelAccumulator() + Self.merge(entry, cost: cost, into: &model) + day.models[entry.model] = model + } + + private static func merge( + _ entry: OpenCodexUsageEntry, + cost: Double?, + into session: inout OpenCodexUsageAggregator.SessionAccumulator) + { + session.input = self.add(session.input, entry.usage?.inputTokens) + session.output = self.add(session.output, entry.usage?.outputTokens) + session.cacheRead = self.add(session.cacheRead, entry.usage?.cacheReadTokens) + session.reasoning = self.add(session.reasoning, entry.usage?.reasoningOutputTokens) + session.tokens = self.add(session.tokens, entry.resolvedTotalTokens) + session.cost = self.add(session.cost, cost) + var model = session.models[entry.model] ?? OpenCodexUsageAggregator.ModelAccumulator() + Self.merge(entry, cost: cost, into: &model) + session.models[entry.model] = model + } + + private static func merge( + _ entry: OpenCodexUsageEntry, + cost: Double?, + into hour: inout OpenCodexUsageAggregator.HourAccumulator) + { + if let tokens = entry.resolvedTotalTokens { + hour.tokens += tokens + hour.sawTokens = true + } + if let cost { + hour.cost += cost + hour.sawCost = true + } + } + + private static func merge( + _ entry: OpenCodexUsageEntry, + cost: Double?, + into model: inout OpenCodexUsageAggregator.ModelAccumulator) + { + model.input = self.add(model.input, entry.usage?.inputTokens) + model.output = self.add(model.output, entry.usage?.outputTokens) + model.cacheRead = self.add(model.cacheRead, entry.usage?.cacheReadTokens) + model.cacheCreation = self.add(model.cacheCreation, entry.usage?.cacheCreationInputTokens) + model.reasoning = self.add(model.reasoning, entry.usage?.reasoningOutputTokens) + if let tokens = entry.resolvedTotalTokens { + model.tokens += tokens + model.sawTokens = true + } + if let cost { + model.cost += cost + model.sawCost = true + } + } + + private static func entry( + dayKey: String, + day: OpenCodexUsageAggregator.DayAccumulator) -> CostUsageDailyReport.Entry + { + CostUsageDailyReport.Entry( + date: dayKey, + inputTokens: day.sawInput ? day.input : nil, + outputTokens: day.sawOutput ? day.output : nil, + cacheReadTokens: day.sawCacheRead ? day.cacheRead : nil, + cacheCreationTokens: day.sawCacheCreation ? day.cacheCreation : nil, + reasoningTokens: day.sawReasoning ? day.reasoning : nil, + totalTokens: day.sawTokens ? day.tokens : nil, + requestCount: day.priced + day.unpriced + day.unmetered + day.estimated, + costUSD: day.sawCost ? day.cost : nil, + modelsUsed: day.models.keys.sorted(), + modelBreakdowns: self.modelBreakdowns(day.models), + unpricedRequestCount: day.unpriced, + unmeteredRequestCount: day.unmetered, + estimatedRequestCount: day.estimated) + } + + private static func modelBreakdowns( + _ models: [String: OpenCodexUsageAggregator.ModelAccumulator]) -> [CostUsageDailyReport.ModelBreakdown] + { + models.keys.sorted().map { name in + let model = models[name] ?? OpenCodexUsageAggregator.ModelAccumulator() + return CostUsageDailyReport.ModelBreakdown( + modelName: name, + costUSD: model.sawCost ? model.cost : nil, + totalTokens: model.sawTokens ? model.tokens : nil, + inputTokens: model.input, + outputTokens: model.output, + cacheReadTokens: model.cacheRead, + cacheCreationTokens: model.cacheCreation, + reasoningTokens: model.reasoning) + } + } + + private static func listPriceUSD( + entry: OpenCodexUsageEntry, + customPricing: CostUsageCustomPricing, + modelsDevCacheRoot: URL?) -> Double? + { + guard entry.usageStatus == .reported || entry.usageStatus == .estimated else { return nil } + let usage = entry.usage + let hasTokenData = entry.resolvedTotalTokens != nil + || usage?.inputTokens != nil + || usage?.outputTokens != nil + || usage?.cacheReadTokens != nil + || usage?.cacheCreationInputTokens != nil + guard hasTokenData else { return nil } + let input = usage?.inputTokens ?? 0 + let output = usage?.outputTokens ?? 0 + let cacheRead = usage?.cacheReadTokens ?? 0 + let cacheWrite = usage?.cacheCreationInputTokens ?? 0 + if let overlay = customPricing.costUSD( + providerID: entry.provider, + model: entry.model, + inputTokens: input, + outputTokens: output, + cacheReadTokens: cacheRead, + cacheWriteTokens: cacheWrite) + { + return overlay + } + return CostUsagePricing.codexCostUSD( + model: entry.model, + inputTokens: input, + cachedInputTokens: cacheRead, + outputTokens: output, + cacheWriteInputTokens: cacheWrite, + pricingDate: entry.timestamp, + modelsDevCacheRoot: modelsDevCacheRoot) + } + + private static func add(_ lhs: Int?, _ rhs: Int?) -> Int? { + switch (lhs, rhs) { + case let (left?, right?): left + right + case let (left?, nil): left + case let (nil, right?): right + case (nil, nil): nil + } + } + + private static func add(_ lhs: Double?, _ rhs: Double?) -> Double? { + switch (lhs, rhs) { + case let (left?, right?): left + right + case let (left?, nil): left + case let (nil, right?): right + case (nil, nil): nil + } + } } diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index eb6a7a094..046c62b74 100644 --- a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift +++ b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift @@ -2377,7 +2377,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/StatusItemController+Animation.swift", - line: 651, + line: 650, anchor: "guard isLoading, style == .warp, let phase else {", expectedProviderIDs: ["warp"], expectedReferenceCount: 1, @@ -2385,7 +2385,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/StatusItemController+Animation.swift", - line: 1051, + line: 1048, anchor: "if provider == .kiro {", expectedProviderIDs: ["cursor", "kiro"], expectedReferenceCount: 2, @@ -3306,7 +3306,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."), AllowedProviderConstruct( path: "Sources/CodexBarCore/Vendored/CostUsage/ModelsDevPricing.swift", - line: 75, + line: 80, anchor: "[\"anthropic\", \"openai\"].allSatisfy { providerID in", expectedProviderIDs: ["openai"], expectedReferenceCount: 1, diff --git a/docs/DEVELOPMENT_SETUP.md b/docs/DEVELOPMENT_SETUP.md index 2a2da9d86..5be32b465 100644 --- a/docs/DEVELOPMENT_SETUP.md +++ b/docs/DEVELOPMENT_SETUP.md @@ -105,14 +105,13 @@ This script: 6. Verifies it stays running Launching an unbundled `CodexBar` executable, including SwiftPM builds using `.build` or a custom scratch path, disables -Keychain access for that process to avoid repeated password prompts. Use the packaged `CodexBar.app` when local -validation needs browser cookies or stored credentials; packaged app bundles keep their normal Keychain behavior -regardless of signing mode. - -When the script falls back to ad-hoc signing, it preserves CodexBar-owned keychain state by default. -That means you may still see keychain prompts for existing CodexBar cache entries, but allowing those prompts keeps the -cached browser/OAuth state available across normal rebuilds. -If you want a clean reset of CodexBar-owned keychain state for an ad-hoc build, run +Keychain access for that process to avoid repeated password prompts. Use the packaged `QuotaKit.app` when local +validation needs browser cookies or stored credentials. + +When the script falls back to ad-hoc signing, QuotaKit keeps browser-cookie cache entries in process memory because a +new ad-hoc executable identity cannot satisfy the previous build's Keychain ACL. OAuth credentials and other persistent +QuotaKit-owned Keychain state stay in Keychain, while certificate-signed development and release apps continue using +the persistent browser-cookie cache. If you want a clean reset of QuotaKit-owned Keychain state for an ad-hoc build, run `./Scripts/compile_and_run.sh --clear-adhoc-keychain` before relaunching. Third-party keychain items still need stable signing if you want macOS to remember **Always Allow** across rebuilds. diff --git a/docs/gemini.md b/docs/gemini.md index 796fbb3db..bc43b60a0 100644 --- a/docs/gemini.md +++ b/docs/gemini.md @@ -89,6 +89,30 @@ Gemini uses the Gemini CLI OAuth credentials and private quota APIs. No browser - When quota, `loadCodeAssist`, or token-refresh responses include Google's unsupported-client migration signal (`UNSUPPORTED_CLIENT`, `IneligibleTierError`, or Antigravity migration copy), QuotaKit surfaces `consumerTierDeprecated` with guidance to use the Antigravity provider. +- Google's live shape is an HTTP **200** `loadCodeAssist` body with no `currentTier` and the consumer tier + listed under `ineligibleTiers[].reasonCode == "UNSUPPORTED_CLIENT"`; the follow-up `retrieveUserQuota` + call then fails with HTTP 403 `SUBSCRIPTION_REQUIRED` and no migration wording. QuotaKit reads the + 200 body's `ineligibleTiers` directly, and maps that 403 to `consumerTierDeprecated` only when the same + fetch saw the unsupported-client flag **and** the account is not on `standard-tier` — a licensed + account's 403 stays `HTTP 403`. +- The unsupported-client flag itself is suppressed for accounts the shutdown does not cover: a named + `paidTier.name` (authoritative even without `currentTier`) and an `hd` claim (Workspace/education, which + `resolveAccountPlan` reads as Workspace when paired with `free-tier`). Both would otherwise be pre-empted + by the earlier `loadCodeAssist` branch, which runs before the plan resolver. +- `UsageStore.geminiMigrationObservation` records which sentinel the last refresh produced + (`none` / `localAntigravityHandoff` / `googleConsumerTierShutdown`); a later local-tooling failure never + downgrades a shutdown already seen. `geminiObservedConsumerTierDeprecation` (either sentinel) drives the + settings action; the narrower `geminiObservedGoogleConsumerTierShutdown` drives the login guard. While + the narrow one is set **for this session**, + the Gemini login action stops clearing `~/.gemini/oauth_creds.json` and launching Gemini CLI — whose + OAuth step fails with the same message — and shows the Antigravity guidance instead. The local + `oauthCredentialsUnavailableWithAntigravity` handoff deliberately does not guard login: there, + reinstalling or relaunching Gemini CLI is the fix, and Workspace accounts must keep that path. +- The guard warns rather than blocks: its alert offers **Switch Account…** next to Cancel (Cancel is the + default, since confirming clears credentials). Confirming re-runs the ordinary login without the guard, + which is how a user moves from a shut-down consumer account to a Workspace, education, or Code Assist + Standard/Enterprise one. The observation is not cleared by confirming, so the settings action stays put + and the next attempt warns again. - Settings shows an **Enable Antigravity provider** action only after QuotaKit observes `consumerTierDeprecated` during a Gemini refresh (typed sentinel state, not user-facing text matching). - The action is explicit: QuotaKit never automatically enables Antigravity or falls back to it. diff --git a/version.env b/version.env index c5b0a926f..f65b192df 100644 --- a/version.env +++ b/version.env @@ -10,4 +10,4 @@ UPSTREAM_SYNC_DATE=2026-08-08 # Advance this when an upstream sync PR lands. It is independent of shipped # release tracking above, so the monitor does not reopen stale issues while a # merged upstream sync has not yet shipped to users. -UPSTREAM_MONITOR_BASE=100deb6faeeaa97f179845ef0591cb9b1102639e +UPSTREAM_MONITOR_BASE=c9e7f4df556d914e4652e21fcb30ee9f3845a0b2