diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f59a09d08..862a68323b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Added - Usage & Spend: add an All time range alongside 7d/30d, backed by 365 days of local history with dedicated Claude and Cursor spend snapshot slots (#3009). Thanks @Yuxin-Qiao! - General settings: turn Low Power Mode into an Off/On/Automatic preference, with Automatic following the system Low Power Mode state (#2995). Thanks @elijahfriedman! +- Grok: add an Auto / Grok CLI / SuperGrok OAuth / Browser cookies source picker, support pasted SuperGrok bearers and grok.com cookies in token accounts, and open `~/.grok/auth.json` from Open token file (#3010). Thanks @oakimov! ### Fixed - OpenCode Go: surface expired selected session tokens instead of silently replacing failed server usage with local quota estimates (#2993). Thanks @Niclassslua! diff --git a/Sources/CodexBar/PreferencesProvidersPane.swift b/Sources/CodexBar/PreferencesProvidersPane.swift index 138b2e5a12..317fc775ba 100644 --- a/Sources/CodexBar/PreferencesProvidersPane.swift +++ b/Sources/CodexBar/PreferencesProvidersPane.swift @@ -465,6 +465,9 @@ struct ProvidersPane: View { } } }, openConfigFile: { + if implementation?.openTokenFile(context: context) == true { + return + } self.settings.openTokenAccountsFile() }, reloadFromDisk: { diff --git a/Sources/CodexBar/ProviderRegistry.swift b/Sources/CodexBar/ProviderRegistry.swift index 769cb752ea..e4472d027b 100644 --- a/Sources/CodexBar/ProviderRegistry.swift +++ b/Sources/CodexBar/ProviderRegistry.swift @@ -41,9 +41,11 @@ struct ProviderRegistry { provider: provider, settings: settings, override: nil) - let sourceMode = ProviderCatalog.implementation(for: provider)? - .sourceMode(context: ProviderSourceModeContext(provider: provider, settings: settings)) - ?? .auto + let sourceMode = Self.resolvedSourceMode( + provider: provider, + settings: settings, + account: account) + let snapshot = Self.makeSettingsSnapshot(settings: settings, tokenOverride: nil) let env = Self.makeEnvironment( base: environmentBase, @@ -56,7 +58,8 @@ struct ProviderRegistry { runtime: .app, sourceMode: sourceMode, includeCredits: false, - includeOptionalUsage: ProviderTokenAccountSelection.shouldIncludeOptionalUsage( + includeOptionalUsage: + ProviderTokenAccountSelection.shouldIncludeOptionalUsage( provider: provider, settings: settings, override: nil), @@ -88,7 +91,8 @@ struct ProviderRegistry { costUsageHistoryDays: settings.costUsageHistoryDays, persistsCLISessions: true, persistentCLISessionIdleWindow: Self.persistentCLISessionIdleWindow( - refreshInterval: Self.nominalRefreshInterval(for: settings.refreshFrequency))) + refreshInterval: Self.nominalRefreshInterval( + for: settings.refreshFrequency))) }) specs[provider] = spec } @@ -105,7 +109,23 @@ struct ProviderRegistry { /// when specs are built, so `.adaptive` maps to the policy's nominal interval instead of a /// live decision; `.manual` stays nil. static func nominalRefreshInterval(for frequency: RefreshFrequency) -> TimeInterval? { - frequency.usesAdaptivePolicy ? AdaptiveRefreshPolicy.nominalIntervalForHeuristics : frequency.seconds + frequency.usesAdaptivePolicy + ? AdaptiveRefreshPolicy.nominalIntervalForHeuristics : frequency.seconds + } + + @MainActor + static func resolvedSourceMode( + provider: UsageProvider, + settings: SettingsStore, + account: ProviderTokenAccount?) -> ProviderSourceMode + { + let base = + ProviderCatalog.implementation(for: provider)? + .sourceMode(context: ProviderSourceModeContext(provider: provider, settings: settings)) + ?? .auto + let config = settings.configSnapshot.providerConfig(for: provider.instanceID) + return ProviderDescriptorRegistry.descriptor(for: provider).credentials? + .selectedAccountSourceMode(base: base, account: account, config: config) ?? base } @MainActor @@ -123,12 +143,15 @@ struct ProviderRegistry { tokenOverride: tokenOverride, codexActiveSourceOverride: codexActiveSourceOverride) for implementation in ProviderCatalog.all { - let registration = ProviderDescriptorRegistry.descriptor(for: implementation.id).settingsSection + let registration = ProviderDescriptorRegistry.descriptor(for: implementation.id) + .settingsSection guard let contribution = implementation.settingsSnapshot(context: context) else { - preconditionFailure("Missing settings snapshot section for provider '\(implementation.id.rawValue)'") + preconditionFailure( + "Missing settings snapshot section for provider '\(implementation.id.rawValue)'") } guard registration.accepts(contribution) else { - preconditionFailure("Mismatched settings snapshot section for provider '\(implementation.id.rawValue)'") + preconditionFailure( + "Mismatched settings snapshot section for provider '\(implementation.id.rawValue)'") } builder.apply(contribution) } @@ -159,18 +182,26 @@ struct ProviderRegistry { // Provider-specific by design: managed Codex account selection scopes the fetcher's CODEX_HOME. if provider == .codex { let codexActiveSource = codexActiveSourceOverride ?? settings.codexResolvedActiveSource - if let managedHomePath = settings.managedCodexRemoteHomePath(forActiveSource: codexActiveSource) { + if let managedHomePath = settings.managedCodexRemoteHomePath( + forActiveSource: codexActiveSource) + { env = CodexHomeScope.scopedEnvironment(base: env, codexHome: managedHomePath) - } else if let liveHomePath = settings.liveSystemCodexHomePath(forActiveSource: codexActiveSource) { + } else if let liveHomePath = settings.liveSystemCodexHomePath( + forActiveSource: codexActiveSource) + { env = CodexHomeScope.scopedEnvironment(base: env, codexHome: liveHomePath) - } else if let profileHomePath = settings.profileCodexHomePath(forActiveSource: codexActiveSource) { + } else if let profileHomePath = settings.profileCodexHomePath( + forActiveSource: codexActiveSource) + { env = CodexHomeScope.scopedEnvironment(base: env, codexHome: profileHomePath) } } return env } - static func makeFetcher(base: UsageFetcher, provider: UsageProvider, env: [String: String]) -> UsageFetcher { + static func makeFetcher(base: UsageFetcher, provider: UsageProvider, env: [String: String]) + -> UsageFetcher + { // Provider-specific by design: a Codex account scope needs a fetcher rebuilt with its selected CODEX_HOME. guard provider == .codex else { return base } return UsageFetcher(environment: env) diff --git a/Sources/CodexBar/Providers/Grok/GrokProviderImplementation.swift b/Sources/CodexBar/Providers/Grok/GrokProviderImplementation.swift index 1d53f20a08..89163fe294 100644 --- a/Sources/CodexBar/Providers/Grok/GrokProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Grok/GrokProviderImplementation.swift @@ -1,6 +1,127 @@ +import AppKit import CodexBarCore import Foundation +import SwiftUI struct GrokProviderImplementation: ProviderImplementation { let id: UsageProvider = .grok + + @MainActor + func observeSettings(_ settings: SettingsStore) { + _ = settings.grokUsageDataSource + _ = settings.grokCookieSource + _ = settings.grokCookieHeader + } + + @MainActor + func sourceMode(context: ProviderSourceModeContext) -> ProviderSourceMode { + context.settings.grokUsageDataSource + } + + @MainActor + func openTokenFile(context _: ProviderSettingsContext) -> Bool { + let url = GrokCredentialsStore.tokenFileURLToOpen() + try? FileManager.default.createDirectory( + at: GrokCredentialsStore.grokHomeURL(), + withIntermediateDirectories: true) + NSWorkspace.shared.open(url) + return true + } + + @MainActor + func settingsSnapshot(context: ProviderSettingsSnapshotContext) + -> ProviderSettingsSnapshotContribution? + { + .grok(context.settings.grokSettingsSnapshot(tokenOverride: context.tokenOverride)) + } + + @MainActor + func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] { + let sourceBinding = Binding( + get: { context.settings.grokUsageDataSource.rawValue }, + set: { raw in + context.settings.grokUsageDataSource = ProviderSourceMode(rawValue: raw) ?? .auto + }) + let sourceOptions: [ProviderSettingsPickerOption] = [ + ProviderSettingsPickerOption(id: ProviderSourceMode.auto.rawValue, title: "Auto"), + ProviderSettingsPickerOption(id: ProviderSourceMode.cli.rawValue, title: "Grok CLI"), + ProviderSettingsPickerOption( + id: ProviderSourceMode.oauth.rawValue, + title: "SuperGrok OAuth"), + ProviderSettingsPickerOption( + id: ProviderSourceMode.web.rawValue, title: "Browser cookies"), + ] + let cookieBinding = Binding( + get: { context.settings.grokCookieSource.rawValue }, + set: { raw in + context.settings.grokCookieSource = ProviderCookieSource(rawValue: raw) ?? .auto + }) + let cookieOptions = ProviderCookieSourceUI.options( + allowsOff: true, + keychainDisabled: context.settings.debugDisableKeychainAccess) + + let cookieSubtitle: () -> String? = { + ProviderCookieSourceUI.subtitle( + source: context.settings.grokCookieSource, + keychainDisabled: context.settings.debugDisableKeychainAccess, + auto: "Automatic imports grok.com cookies from Chrome.", + manual: "Paste a Cookie header from a grok.com request.", + off: "Grok cookies are disabled.") + } + + return [ + ProviderSettingsPickerDescriptor( + id: "grok-usage-source", + title: "Usage source", + subtitle: + "Auto tries the Grok CLI, SuperGrok OAuth, browser cookies, then bearer gRPC.", + binding: sourceBinding, + options: sourceOptions, + isVisible: nil, + onChange: nil), + ProviderSettingsPickerDescriptor( + id: "grok-cookie-source", + title: "Cookie source", + subtitle: "Automatic imports grok.com cookies from Chrome.", + dynamicSubtitle: cookieSubtitle, + binding: cookieBinding, + options: cookieOptions, + isVisible: { + context.settings.grokUsageDataSource == .auto + || context.settings.grokUsageDataSource == .web + }, + onChange: nil), + ] + } + + @MainActor + func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { + [ + ProviderSettingsFieldDescriptor( + id: "grok-cookie", + title: "", + subtitle: "", + kind: .secure, + placeholder: "Cookie: …", + binding: context.stringBinding(\.grokCookieHeader), + actions: [ + ProviderSettingsActionDescriptor( + id: "grok-open-usage", + title: "Open grok.com usage", + style: .link, + isVisible: nil, + perform: { + if let url = URL(string: "https://grok.com/?_s=usage") { + NSWorkspace.shared.open(url) + } + }), + ], + isVisible: { + (context.settings.grokUsageDataSource == .auto + || context.settings.grokUsageDataSource == .web) + && context.settings.grokCookieSource == .manual + }, + onActivate: { context.settings.ensureGrokCookieLoaded() }), + ] + } } diff --git a/Sources/CodexBar/Providers/Grok/GrokSettingsStore.swift b/Sources/CodexBar/Providers/Grok/GrokSettingsStore.swift new file mode 100644 index 0000000000..3902c06d13 --- /dev/null +++ b/Sources/CodexBar/Providers/Grok/GrokSettingsStore.swift @@ -0,0 +1,56 @@ +import CodexBarCore +import Foundation + +extension SettingsStore { + var grokUsageDataSource: ProviderSourceMode { + get { self.configSnapshot.providerConfig(for: .grok)?.source ?? .auto } + set { + self.updateProviderConfig(provider: .grok) { entry in + entry.source = newValue == .auto ? nil : newValue + } + self.logProviderModeChange(provider: .grok, field: "source", value: newValue.rawValue) + } + } + + var grokCookieHeader: String { + get { self.configSnapshot.providerConfig(for: .grok)?.sanitizedCookieHeader ?? "" } + set { + self.updateProviderConfig(provider: .grok) { entry in + entry.cookieHeader = self.normalizedConfigValue(newValue) + } + self.logSecretUpdate(provider: .grok, field: "cookieHeader", value: newValue) + } + } + + var grokCookieSource: ProviderCookieSource { + get { self.resolvedCookieSource(provider: .grok, fallback: .auto) } + set { + self.updateProviderConfig(provider: .grok) { entry in + entry.cookieSource = newValue + } + self.logProviderModeChange( + provider: .grok, field: "cookieSource", value: newValue.rawValue) + } + } + + func ensureGrokCookieLoaded() {} +} + +extension SettingsStore { + func grokSettingsSnapshot(tokenOverride: TokenAccountOverride?) + -> ProviderSettingsSnapshot + .GrokProviderSettings + { + let account = ProviderTokenAccountSelection.selectedAccount( + provider: .grok, + settings: self, + override: tokenOverride) + let resolved = GrokCredentialRouting.cookieSettings( + configuredSource: self.grokCookieSource, + configuredHeader: self.grokCookieHeader, + selectedAccountToken: account?.token) + return GrokProviderSettings( + cookieSource: resolved.cookieSource, + manualCookieHeader: resolved.manualCookieHeader) + } +} diff --git a/Sources/CodexBar/Providers/Shared/ProviderImplementation.swift b/Sources/CodexBar/Providers/Shared/ProviderImplementation.swift index 3e6613ee91..e333279605 100644 --- a/Sources/CodexBar/Providers/Shared/ProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Shared/ProviderImplementation.swift @@ -52,35 +52,45 @@ protocol ProviderImplementation: Sendable { /// Optional provider-specific organizations selection rendered in the Providers pane. @MainActor - func settingsOrganizations(context: ProviderSettingsContext) -> ProviderSettingsOrganizationsDescriptor? + func settingsOrganizations(context: ProviderSettingsContext) + -> ProviderSettingsOrganizationsDescriptor? /// Optional visibility gate for token account settings. @MainActor - func tokenAccountsVisibility(context: ProviderSettingsContext, support: TokenAccountSupport) -> Bool + func tokenAccountsVisibility(context: ProviderSettingsContext, support: TokenAccountSupport) + -> Bool /// Optional provider-specific settings snapshot contribution. @MainActor - func settingsSnapshot(context: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution? + func settingsSnapshot(context: ProviderSettingsSnapshotContext) + -> ProviderSettingsSnapshotContribution? /// Optional primary action for the shared token-account editor. @MainActor func runTokenAccountPrimaryAction(context: ProviderSettingsContext) async + /// Return true if the provider opened its own token file. False keeps the CodexBar config. + @MainActor + func openTokenFile(context: ProviderSettingsContext) -> Bool + /// Optional hook to update provider settings when token accounts change. @MainActor func applyTokenAccountCookieSource(settings: SettingsStore) /// Optional provider-specific menu entries for the usage section. @MainActor - func appendUsageMenuEntries(context: ProviderMenuUsageContext, entries: inout [ProviderMenuEntry]) + func appendUsageMenuEntries( + context: ProviderMenuUsageContext, entries: inout [ProviderMenuEntry]) /// Optional provider-specific menu entries for the actions section. @MainActor - func appendActionMenuEntries(context: ProviderMenuActionContext, entries: inout [ProviderMenuEntry]) + func appendActionMenuEntries( + context: ProviderMenuActionContext, entries: inout [ProviderMenuEntry]) /// Optional override for the login/switch account menu action. @MainActor - func loginMenuAction(context: ProviderMenuLoginContext) -> (label: String, action: MenuDescriptor.MenuAction)? + func loginMenuAction(context: ProviderMenuLoginContext) -> ( + label: String, action: MenuDescriptor.MenuAction)? /// Optional provider-specific login flow. Returns whether to refresh after completion. @MainActor @@ -152,32 +162,45 @@ extension ProviderImplementation { } @MainActor - func settingsOrganizations(context _: ProviderSettingsContext) -> ProviderSettingsOrganizationsDescriptor? { + func settingsOrganizations(context _: ProviderSettingsContext) + -> ProviderSettingsOrganizationsDescriptor? + { nil } @MainActor - func tokenAccountsVisibility(context: ProviderSettingsContext, support: TokenAccountSupport) -> Bool { + func tokenAccountsVisibility(context: ProviderSettingsContext, support: TokenAccountSupport) + -> Bool + { guard support.requiresManualCookieSource else { return true } return !context.settings.tokenAccounts(for: context.provider).isEmpty } @MainActor - func settingsSnapshot(context _: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution? { + func settingsSnapshot(context _: ProviderSettingsSnapshotContext) + -> ProviderSettingsSnapshotContribution? + { ProviderDescriptorRegistry.descriptor(for: self.id).settingsSection.defaultContribution } @MainActor func runTokenAccountPrimaryAction(context _: ProviderSettingsContext) async {} + @MainActor + func openTokenFile(context _: ProviderSettingsContext) -> Bool { + false + } + @MainActor func applyTokenAccountCookieSource(settings _: SettingsStore) {} @MainActor - func appendUsageMenuEntries(context _: ProviderMenuUsageContext, entries _: inout [ProviderMenuEntry]) {} + func appendUsageMenuEntries( + context _: ProviderMenuUsageContext, entries _: inout [ProviderMenuEntry]) {} @MainActor - func appendActionMenuEntries(context _: ProviderMenuActionContext, entries _: inout [ProviderMenuEntry]) {} + func appendActionMenuEntries( + context _: ProviderMenuActionContext, entries _: inout [ProviderMenuEntry]) {} @MainActor func loginMenuAction(context _: ProviderMenuLoginContext) diff --git a/Sources/CodexBar/UsageStore+TokenAccounts.swift b/Sources/CodexBar/UsageStore+TokenAccounts.swift index 6323f72aa3..0466b9d352 100644 --- a/Sources/CodexBar/UsageStore+TokenAccounts.swift +++ b/Sources/CodexBar/UsageStore+TokenAccounts.swift @@ -47,9 +47,11 @@ extension UsageStore { self.tokenAccountLiveStateProviders.insert(provider.instanceID) guard let account = self.uniqueTokenAccount(provider: provider, accountID: accountID), let cached = self.accountSnapshots[provider.instanceID]?.first(where: { - $0.account.id == accountID && $0.cacheKey == self.tokenAccountSnapshotCacheKey( - provider: provider, - account: account) + $0.account.id == accountID + && $0.cacheKey + == self.tokenAccountSnapshotCacheKey( + provider: provider, + account: account) }) else { self.accountSnapshots[provider.instanceID]?.removeAll { $0.account.id == accountID } @@ -90,7 +92,9 @@ extension UsageStore { { let support = TokenAccountSupportCatalog.support(for: provider) let cookieSource = self.settings.providerConfig(for: provider)?.cookieSource ?? .auto - guard support?.selectedAccountRequiresManualCookieSource != true || cookieSource != .auto else { return } + guard support?.selectedAccountRequiresManualCookieSource != true || cookieSource != .auto else { + return + } let cached = TokenAccountUsageSnapshot( account: account, snapshot: snapshot, @@ -208,8 +212,7 @@ extension UsageStore { func shouldFetchAllCodexVisibleAccounts() -> Bool { let projection = self.freshCodexVisibleAccountProjectionForAccountRefresh() - return self.settings.multiAccountMenuLayout == .stacked && - projection.visibleAccounts.count > 1 + return self.settings.multiAccountMenuLayout == .stacked && projection.visibleAccounts.count > 1 } func refreshCodexVisibleAccountsForMenu(generation: UInt64? = nil) async { @@ -286,10 +289,11 @@ extension UsageStore { requireLiveManagedAuthFor: managedAccountIDsWithReadableAuthAtStart) guard self.isCurrentProviderRefreshGeneration(.codex, generation: generation) else { return } let currentSnapshots = snapshots.compactMap { snapshot -> CodexAccountUsageSnapshot? in - guard let currentAccount = Self.currentCodexVisibleAccount( - matching: snapshot.account, - projection: currentProjection, - allowProviderAccountAuthFingerprintMismatch: snapshot.error == nil) + guard + let currentAccount = Self.currentCodexVisibleAccount( + matching: snapshot.account, + projection: currentProjection, + allowProviderAccountAuthFingerprintMismatch: snapshot.error == nil) else { return nil } @@ -331,12 +335,13 @@ extension UsageStore { return } - let allowSelectedAuthFingerprintMismatch = switch selectedOutcome.result { - case .success: - true - case .failure: - false - } + let allowSelectedAuthFingerprintMismatch = + switch selectedOutcome.result { + case .success: + true + case .failure: + false + } let currentSelectedAccount = Self.currentCodexVisibleAccount( matching: selectedAccount, projection: currentProjection, @@ -382,10 +387,13 @@ extension UsageStore { originalAccount, account: currentActiveAccount) } - guard let originalAccount, let currentActiveAccount, currentSelectionSource == originalSelectionSource else { + guard let originalAccount, let currentActiveAccount, + currentSelectionSource == originalSelectionSource + else { return false } - return Self.codexVisibleAccountMatchesCurrentProjection(originalAccount, account: currentActiveAccount) + return Self.codexVisibleAccountMatchesCurrentProjection( + originalAccount, account: currentActiveAccount) } private func freshCodexVisibleAccountProjectionForAccountRefresh( @@ -402,9 +410,10 @@ extension UsageStore { } private func codexManagedAccountIDsWithReadableAuth() -> Set { - Set(self.settings.codexAccountReconciliationSnapshot.storedAccounts.compactMap { account in - CodexAuthFingerprint.fingerprint(homePath: account.managedHomePath) == nil ? nil : account.id - }) + Set( + self.settings.codexAccountReconciliationSnapshot.storedAccounts.compactMap { account in + CodexAuthFingerprint.fingerprint(homePath: account.managedHomePath) == nil ? nil : account.id + }) } private nonisolated static func codexVisibleAccountProjectionWithFreshManagedAuthFingerprints( @@ -414,19 +423,22 @@ extension UsageStore { { let managedRuntimeStates = Dictionary( uniqueKeysWithValues: snapshot.storedAccounts.map { account in - let workspaceAccountID: String? = switch snapshot.runtimeIdentity(for: account) { - case let .providerAccount(id): - id - case .emailOnly, .unresolved: - nil - } + let workspaceAccountID: String? = + switch snapshot.runtimeIdentity(for: account) { + case let .providerAccount(id): + id + case .emailOnly, .unresolved: + nil + } let authFingerprint = CodexAuthFingerprint.fingerprint(homePath: account.managedHomePath) let requiresLiveAuth = accountIDs.contains(account.id) - return (account.id, CodexManagedVisibleAccountRuntimeState( - authFingerprint: authFingerprint ?? (requiresLiveAuth ? nil : account.authFingerprint), - workspaceAccountID: authFingerprint == nil && requiresLiveAuth - ? nil - : (workspaceAccountID ?? account.workspaceAccountID))) + return ( + account.id, + CodexManagedVisibleAccountRuntimeState( + authFingerprint: authFingerprint ?? (requiresLiveAuth ? nil : account.authFingerprint), + workspaceAccountID: authFingerprint == nil && requiresLiveAuth + ? nil + : (workspaceAccountID ?? account.workspaceAccountID))) }) let visibleAccounts = projection.visibleAccounts.map { account in guard case let .managedAccount(id) = account.selectionSource else { return account } @@ -435,8 +447,8 @@ extension UsageStore { let runtimeWorkspaceAccountID = managedRuntimeStates[id]?.workspaceAccountID .map(CodexOpenAIWorkspaceIdentity.normalizeWorkspaceAccountID) guard let runtimeState = managedRuntimeStates[id], - runtimeState.authFingerprint != account.authFingerprint || - runtimeWorkspaceAccountID != accountWorkspaceAccountID + runtimeState.authFingerprint != account.authFingerprint + || runtimeWorkspaceAccountID != accountWorkspaceAccountID else { return account } @@ -488,11 +500,12 @@ extension UsageStore { // Provider-specific by design: Codex managed profiles relabel fetched identity from reconciled workspace data. guard let snapshot else { return nil } let existing = snapshot.identity(for: .codex) - return snapshot.withIdentity(ProviderIdentitySnapshot( - providerID: .codex, - accountEmail: account.email, - accountOrganization: existing?.accountOrganization, - loginMethod: existing?.loginMethod ?? account.workspaceLabel)) + return snapshot.withIdentity( + ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: account.email, + accountOrganization: existing?.accountOrganization, + loginMethod: existing?.loginMethod ?? account.workspaceLabel)) } private static func codexVisibleAccountMatchesCurrentProjection( @@ -516,7 +529,9 @@ extension UsageStore { if priorWorkspaceID != nil || accountWorkspaceID != nil { guard priorWorkspaceID == accountWorkspaceID else { return false } if !allowProviderAccountAuthFingerprintMismatch { - guard self.codexVisibleAccountAuthFingerprintMatches(prior, account: account) else { return false } + guard self.codexVisibleAccountAuthFingerprintMatches(prior, account: account) else { + return false + } } return true } @@ -579,7 +594,8 @@ extension UsageStore { self.activateCachedTokenAccountSnapshot(provider: provider, accountID: effectiveSelected.id) return self.accountSnapshots[provider.instanceID] ?? [] } - let priorByAccountID = Dictionary(uniqueKeysWithValues: priorSnapshots.map { ($0.account.id, $0) }) + let priorByAccountID = Dictionary( + uniqueKeysWithValues: priorSnapshots.map { ($0.account.id, $0) }) var snapshots: [TokenAccountUsageSnapshot] = [] var historySamples: [(account: ProviderTokenAccount, snapshot: UsageSnapshot)] = [] @@ -621,8 +637,8 @@ extension UsageStore { // If every fetch was cancelled (e.g. the user closed/reopened the menu mid-flight) // and we have no usable snapshots, leave the prior per-account state alone. // Wiping it would produce a menu of useless "cancelled" placeholders. - let shouldPreservePriorState = !sawAnyNonCancellationOutcome && - snapshots.allSatisfy { $0.snapshot == nil } + let shouldPreservePriorState = + !sawAnyNonCancellationOutcome && snapshots.allSatisfy { $0.snapshot == nil } if !shouldPreservePriorState { await MainActor.run { self.accountSnapshots[provider.instanceID] = snapshots @@ -662,8 +678,9 @@ extension UsageStore { { // Provider-specific by design: Codex account refresh rejects successful payloads for a different email owner. guard case let .success(result) = outcome.result else { return true } - guard let resultEmail = CodexIdentityResolver.normalizeEmail( - result.usage.scoped(to: .codex).accountEmail(for: .codex)) + guard + let resultEmail = CodexIdentityResolver.normalizeEmail( + result.usage.scoped(to: .codex).accountEmail(for: .codex)) else { return true } @@ -680,9 +697,8 @@ extension UsageStore { let message = error.localizedDescription .trimmingCharacters(in: .whitespacesAndNewlines) .lowercased() - return message == "cancelled" || - message.contains("cancellationerror") || - message.contains("cancelled") + return message == "cancelled" || message.contains("cancellationerror") + || message.contains("cancelled") } func limitedTokenAccounts( @@ -730,8 +746,10 @@ extension UsageStore { override: TokenAccountOverride?, codexActiveSourceOverride: CodexActiveSource? = nil) async -> ProviderFetchOutcome { - let descriptor = self.providerSpecs[provider]?.descriptor ?? ProviderDescriptorRegistry - .descriptor(for: provider) + let descriptor = + self.providerSpecs[provider]?.descriptor + ?? ProviderDescriptorRegistry + .descriptor(for: provider) let context = self.makeFetchContext( provider: provider, override: override, @@ -748,20 +766,25 @@ extension UsageStore { provider: UsageProvider, accounts: [ProviderTokenAccount]) async -> [TokenAccountFetchResult] { - let requests: [( - index: Int, - account: ProviderTokenAccount, - descriptor: ProviderDescriptor, - context: ProviderFetchContext)] = + let requests: + [( + index: Int, + account: ProviderTokenAccount, + descriptor: ProviderDescriptor, + context: ProviderFetchContext)] = accounts.enumerated().map { index, account in let override = TokenAccountOverride(provider: provider, account: account) - let descriptor = self.providerSpecs[provider]?.descriptor ?? ProviderDescriptorRegistry - .descriptor(for: provider) + let descriptor = + self.providerSpecs[provider]?.descriptor + ?? ProviderDescriptorRegistry + .descriptor(for: provider) let context = self.makeFetchContext(provider: provider, override: override) return (index, account, descriptor, context) } - if let delay = TokenAccountSupportCatalog.support(for: provider)?.minimumDelayBetweenAccountRefreshes { + if let delay = TokenAccountSupportCatalog.support(for: provider)? + .minimumDelayBetweenAccountRefreshes + { var results: [TokenAccountFetchResult] = [] results.reserveCapacity(requests.count) for request in requests { @@ -770,21 +793,23 @@ extension UsageStore { try await Task.sleep(for: delay) } catch { for pending in requests.dropFirst(results.count) { - results.append(TokenAccountFetchResult( - index: pending.index, - account: pending.account, - outcome: ProviderFetchOutcome( - result: .failure(CancellationError()), - attempts: []))) + results.append( + TokenAccountFetchResult( + index: pending.index, + account: pending.account, + outcome: ProviderFetchOutcome( + result: .failure(CancellationError()), + attempts: []))) } return results } } let outcome = await request.descriptor.fetchOutcome(context: request.context) - results.append(TokenAccountFetchResult( - index: request.index, - account: request.account, - outcome: outcome)) + results.append( + TokenAccountFetchResult( + index: request.index, + account: request.account, + outcome: outcome)) } return results } @@ -817,10 +842,13 @@ extension UsageStore { allVisibleAccounts: [CodexVisibleAccount], priorSnapshots: [CodexAccountUsageSnapshot], activeVisibleAccountID: String?) async - -> [CodexAccountFetchResult] { + -> [CodexAccountFetchResult] + { let requests: [CodexAccountFetchRequest] = accounts.enumerated().map { index, account in - let descriptor = self.providerSpecs[.codex]?.descriptor ?? ProviderDescriptorRegistry - .descriptor(for: .codex) + let descriptor = + self.providerSpecs[.codex]?.descriptor + ?? ProviderDescriptorRegistry + .descriptor(for: .codex) let context = self.makeFetchContext( provider: .codex, override: nil, @@ -831,13 +859,15 @@ extension UsageStore { let priorSnapshot = Self.codexPriorAccountSnapshot( matching: account, in: priorSnapshots) - let trustedBackfillSnapshots = limitResetOwnerKey == nil - ? [] - : self.codexResetBackfillSnapshots( - for: account, - priorSnapshot: priorSnapshot, - activeVisibleAccountID: activeVisibleAccountID) - let missingWindowBackfillSnapshot = Self.codexMergedResetBackfillSnapshot(trustedBackfillSnapshots) + let trustedBackfillSnapshots = + limitResetOwnerKey == nil + ? [] + : self.codexResetBackfillSnapshots( + for: account, + priorSnapshot: priorSnapshot, + activeVisibleAccountID: activeVisibleAccountID) + let missingWindowBackfillSnapshot = Self.codexMergedResetBackfillSnapshot( + trustedBackfillSnapshots) return CodexAccountFetchRequest( index: index, account: account, @@ -863,24 +893,25 @@ extension UsageStore { fetcher: request.resetCreditsFetcher) } let initialOutcome = await fetchOutcome() - let outcome: ProviderFetchOutcome? = if Self.codexUsageOutcomeMatchesVisibleAccount( - initialOutcome, - account: request.account) - { - if let admitted = await Self.codexOutcomeAdmittedForPublication( - initialOutcome: initialOutcome, - previousSnapshot: request.previousSnapshot, - missingWindowBackfillSnapshot: request.missingWindowBackfillSnapshot, - fetchConfirmation: fetchOutcome), - Self.codexUsageOutcomeMatchesVisibleAccount(admitted, account: request.account) + let outcome: ProviderFetchOutcome? = + if Self.codexUsageOutcomeMatchesVisibleAccount( + initialOutcome, + account: request.account) { - admitted + if let admitted = await Self.codexOutcomeAdmittedForPublication( + initialOutcome: initialOutcome, + previousSnapshot: request.previousSnapshot, + missingWindowBackfillSnapshot: request.missingWindowBackfillSnapshot, + fetchConfirmation: fetchOutcome), + Self.codexUsageOutcomeMatchesVisibleAccount(admitted, account: request.account) + { + admitted + } else { + nil + } } else { nil } - } else { - nil - } return CodexAccountFetchResult( index: request.index, account: request.account, @@ -909,7 +940,10 @@ extension UsageStore { provider: provider, settings: self.settings, override: override) - let sourceMode = self.sourceMode(for: provider) + let sourceMode = ProviderRegistry.resolvedSourceMode( + provider: provider, + settings: self.settings, + account: account) let snapshot = ProviderRegistry.makeSettingsSnapshot( settings: self.settings, tokenOverride: override, @@ -922,7 +956,8 @@ extension UsageStore { codexActiveSourceOverride: codexActiveSourceOverride) let fetcher = ProviderRegistry.makeFetcher(base: self.codexFetcher, provider: provider, env: env) let contextProvider = provider - let publicationGeneration = self.providerRefreshPublicationContexts[provider.instanceID]?.generation + let publicationGeneration = self.providerRefreshPublicationContexts[provider.instanceID]? + .generation let contextConfigRevision = self.settings.providerConfigRevision(for: provider) let originalAccountToken = account?.token let originalManualToken = provider == .stepfun ? self.settings.stepfunToken : nil @@ -951,10 +986,11 @@ extension UsageStore { else { return } - guard self.providerConfigMutationIsCurrent( - provider: provider, - generation: publicationGeneration, - originalConfigRevision: contextConfigRevision) + guard + self.providerConfigMutationIsCurrent( + provider: provider, + generation: publicationGeneration, + originalConfigRevision: contextConfigRevision) else { return } self.settings.updateTokenAccount( provider: provider, @@ -970,10 +1006,11 @@ extension UsageStore { guard let self, provider == .stepfun, self.settings.stepfunToken == originalManualToken else { return } - guard self.providerConfigMutationIsCurrent( - provider: provider, - generation: publicationGeneration, - originalConfigRevision: contextConfigRevision) + guard + self.providerConfigMutationIsCurrent( + provider: provider, + generation: publicationGeneration, + originalConfigRevision: contextConfigRevision) else { return } self.settings.stepfunToken = token self.advanceProviderRefreshConfigRevision( @@ -995,7 +1032,9 @@ extension UsageStore { { guard let generation else { return true } let currentConfigRevision = self.settings.providerConfigRevision(for: provider) - guard let publication = self.providerRefreshPublicationContexts[provider.instanceID] else { return false } + guard let publication = self.providerRefreshPublicationContexts[provider.instanceID] else { + return false + } if publication.generation == generation { return publication.configRevision == currentConfigRevision } @@ -1076,7 +1115,9 @@ extension UsageStore { return snapshot } - func codexLastKnownResetSnapshot(matching guardValue: CodexAccountScopedRefreshGuard?) -> UsageSnapshot? { + func codexLastKnownResetSnapshot(matching guardValue: CodexAccountScopedRefreshGuard?) + -> UsageSnapshot? + { guard let guardValue, let lastGuard = self.lastCodexUsagePublicationGuard, Self.codexScopedRefreshGuardAllowsResetBackfill(lastGuard, matching: guardValue) @@ -1192,9 +1233,12 @@ extension UsageStore { authFingerprint: account.authFingerprint) } - private nonisolated static func codexVisibleAccountIdentity(for account: CodexVisibleAccount) -> CodexIdentity { + private nonisolated static func codexVisibleAccountIdentity(for account: CodexVisibleAccount) + -> CodexIdentity + { if let workspaceAccountID = self.normalizedCodexVisibleAccountText(account.workspaceAccountID) { - return .providerAccount(id: CodexOpenAIWorkspaceIdentity.normalizeWorkspaceAccountID(workspaceAccountID)) + return .providerAccount( + id: CodexOpenAIWorkspaceIdentity.normalizeWorkspaceAccountID(workspaceAccountID)) } return CodexIdentityResolver.resolve(accountId: nil, email: account.email) } @@ -1260,7 +1304,8 @@ extension UsageStore { ClaudeUsageError.isClaudeOAuthUsageRateLimit(error), let priorSnapshot, priorSnapshot.sourceLabel == "oauth", - priorSnapshot.cacheKey == self.tokenAccountSnapshotCacheKey(provider: provider, account: account), + priorSnapshot.cacheKey + == self.tokenAccountSnapshotCacheKey(provider: provider, account: account), let priorUsage = priorSnapshot.snapshot { let snapshot = TokenAccountUsageSnapshot( @@ -1299,7 +1344,8 @@ extension UsageStore { sourceLabel: priorSnapshot?.sourceLabel) } let labeled = self.applyCodexVisibleAccountLabel(scoped, account: account) - let backfilled = Self.codexMergedResetBackfillSnapshot(resetBackfillSnapshots) + let backfilled = + Self.codexMergedResetBackfillSnapshot(resetBackfillSnapshots) .map { Self.codexBackfillingResetWindows(labeled, from: $0) } ?? labeled let snapshot = CodexAccountUsageSnapshot( account: account, @@ -1347,17 +1393,12 @@ extension UsageStore { private static func shouldPreserveCodexAccountSnapshotOnFailure(_ message: String) -> Bool { guard CodexAccountHealth.status(forError: message) == .unavailable else { return false } let normalized = message.lowercased() - return normalized.contains("network") || - normalized.contains("internet connection") || - normalized.contains("offline") || - normalized.contains("timed out") || - normalized.contains("timeout") || - normalized.contains("connection was lost") || - normalized.contains("could not connect") || - normalized.contains("not connected") || - normalized.contains("hostname") || - normalized.contains("dns") || - normalized.contains("temporarily unavailable") + return normalized.contains("network") || normalized.contains("internet connection") + || normalized.contains("offline") || normalized.contains("timed out") + || normalized.contains("timeout") || normalized.contains("connection was lost") + || normalized.contains("could not connect") || normalized.contains("not connected") + || normalized.contains("hostname") || normalized.contains("dns") + || normalized.contains("temporarily unavailable") } func applySelectedCodexVisibleAccountOutcome( @@ -1440,19 +1481,21 @@ extension UsageStore { switch outcome.result { case let .success(result): let scoped = result.usage.scoped(to: provider) - let labeled: UsageSnapshot = if let account { - self.applyAccountLabel(scoped, provider: provider, account: account) - } else { - scoped - } + let labeled: UsageSnapshot = + if let account { + self.applyAccountLabel(scoped, provider: provider, account: account) + } else { + scoped + } let backfilled = await MainActor.run { guard self.isCurrentProviderRefreshGeneration(provider, generation: generation) else { return nil as UsageSnapshot? } - let profileStable = provider == .deepseek - ? labeled.preservingDeepSeekPlatformProfiles( - from: self.presentationSnapshot(for: .deepseek)) - : labeled + let profileStable = + provider == .deepseek + ? labeled.preservingDeepSeekPlatformProfiles( + from: self.presentationSnapshot(for: .deepseek)) + : labeled let backfilled = profileStable.backfillingResetTimes( from: self.lastKnownResetSnapshots[provider.instanceID]) let warningAccountDiscriminator = Self.warningTokenAccountDiscriminator(account) @@ -1492,7 +1535,8 @@ extension UsageStore { let fallbackAccountSnapshot, fallbackAccountSnapshot.account.id == currentAccount.id, fallbackAccountSnapshot.sourceLabel == "oauth", - fallbackAccountSnapshot.cacheKey == self.tokenAccountSnapshotCacheKey( + fallbackAccountSnapshot.cacheKey + == self.tokenAccountSnapshotCacheKey( provider: provider, account: currentAccount), let fallback = fallbackAccountSnapshot.snapshot @@ -1518,8 +1562,9 @@ extension UsageStore { return } let hadPriorData = self.snapshots[provider.instanceID] != nil || fallbackSnapshot != nil - let shouldSurface = self.failureGates[provider.instanceID]? - .shouldSurfaceError(onFailureWithPriorData: hadPriorData) ?? true + let shouldSurface = + self.failureGates[provider.instanceID]? + .shouldSurfaceError(onFailureWithPriorData: hadPriorData) ?? true if shouldSurface { self.errors[provider.instanceID] = message self.snapshots.removeValue(forKey: provider.instanceID) @@ -1537,8 +1582,9 @@ extension UsageStore { -> (accounts: [ProviderTokenAccount], removesAccountAuthority: Bool) { let accounts = self.tokenAccounts(for: provider) - let removesAccountAuthority = self.tokenAccountLiveStateProviders.contains(provider.instanceID) && - self.settings.effectiveSelectedTokenAccount(for: provider) == nil + let removesAccountAuthority = + self.tokenAccountLiveStateProviders.contains(provider.instanceID) + && self.settings.effectiveSelectedTokenAccount(for: provider) == nil return (accounts, removesAccountAuthority) } } diff --git a/Sources/CodexBarCore/Providers/Grok/GrokAuth.swift b/Sources/CodexBarCore/Providers/Grok/GrokAuth.swift index e9081d47eb..e323a53bb1 100644 --- a/Sources/CodexBarCore/Providers/Grok/GrokAuth.swift +++ b/Sources/CodexBarCore/Providers/Grok/GrokAuth.swift @@ -50,6 +50,24 @@ public struct GrokCredentials: Sendable { self.createTime = createTime } + public static func pasted(accessToken: String) -> GrokCredentials { + GrokCredentials( + accessToken: accessToken, + refreshToken: nil, + scope: "", + authMode: "oidc", + userId: nil, + email: nil, + firstName: nil, + lastName: nil, + teamId: nil, + principalType: nil, + oidcIssuer: nil, + oidcClientId: nil, + expiresAt: nil, + createTime: nil) + } + public var displayName: String? { let parts = [self.firstName, self.lastName].compactMap { $0?.nilIfEmpty } guard !parts.isEmpty else { return nil } @@ -117,7 +135,22 @@ public enum GrokCredentialsStore { self.grokHomeURL(env: env, fileManager: fileManager).appendingPathComponent("auth.json") } - public static func load(env: [String: String] = ProcessInfo.processInfo.environment) throws -> GrokCredentials { + /// Prefer the Grok Build token file. If `grok login` has not created it yet, open `~/.grok` + /// instead of inventing an empty auth.json. + public static func tokenFileURLToOpen( + env: [String: String] = ProcessInfo.processInfo.environment, + fileManager: FileManager = .default) -> URL + { + let url = self.authFileURL(env: env, fileManager: fileManager) + if fileManager.fileExists(atPath: url.path) { + return url + } + return self.grokHomeURL(env: env, fileManager: fileManager) + } + + public static func load(env: [String: String] = ProcessInfo.processInfo.environment) throws + -> GrokCredentials + { let url = self.authFileURL(env: env) guard FileManager.default.fileExists(atPath: url.path) else { throw GrokCredentialsError.notFound @@ -164,7 +197,9 @@ public enum GrokCredentialsStore { createTime: Self.parseDate(entry["create_time"])) } - private static func selectPreferredEntry(in root: [String: Any]) -> (scope: String, entry: [String: Any])? { + private static func selectPreferredEntry(in root: [String: Any]) -> ( + scope: String, entry: [String: Any])? + { var oidcCandidate: (String, [String: Any])? var legacyCandidate: (String, [String: Any])? for (scope, value) in root { diff --git a/Sources/CodexBarCore/Providers/Grok/GrokCredentialRouting.swift b/Sources/CodexBarCore/Providers/Grok/GrokCredentialRouting.swift new file mode 100644 index 0000000000..c08887477d --- /dev/null +++ b/Sources/CodexBarCore/Providers/Grok/GrokCredentialRouting.swift @@ -0,0 +1,89 @@ +import Foundation + +public enum GrokCredentialRouting: Sendable, Equatable { + case none + case oauth(accessToken: String) + case webCookie(header: String) + + public static func resolve(tokenAccountToken: String?, manualCookieHeader: String?) -> Self { + if let tokenAccountToken, let route = self.resolvePrimaryCredential(tokenAccountToken) { + return route + } + guard let cookieHeader = self.normalizedWebCookie(manualCookieHeader) else { + return .none + } + return .webCookie(header: cookieHeader) + } + + public var oauthAccessToken: String? { + guard case let .oauth(accessToken) = self else { return nil } + return accessToken + } + + public var manualCookieHeader: String? { + guard case let .webCookie(header) = self else { return nil } + return header + } + + public var sourceMode: ProviderSourceMode? { + switch self { + case .oauth: .oauth + case .webCookie: .web + case .none: nil + } + } + + public static func cookieSettings( + configuredSource: ProviderCookieSource, + configuredHeader: String?, + selectedAccountToken: String?) -> CookieProviderSettings + { + if let header = self.resolve( + tokenAccountToken: selectedAccountToken, + manualCookieHeader: nil).manualCookieHeader + { + return CookieProviderSettings(cookieSource: .manual, manualCookieHeader: header) + } + return CookieProviderSettings( + cookieSource: configuredSource, + manualCookieHeader: configuredHeader) + } + + private static func resolvePrimaryCredential(_ raw: String) -> Self? { + if let accessToken = self.normalizedOAuthToken(raw) { + return .oauth(accessToken: accessToken) + } + if let cookieHeader = self.normalizedWebCookie(raw) { + return .webCookie(header: cookieHeader) + } + return nil + } + + public static func normalizedOAuthToken(_ raw: String?) -> String? { + var token = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if token.lowercased().hasPrefix("bearer ") { + token = String(token.dropFirst(7)).trimmingCharacters(in: .whitespacesAndNewlines) + } + guard !token.isEmpty else { return nil } + let lower = token.lowercased() + if lower.hasPrefix("cookie:") { + return nil + } + if lower.hasPrefix("xai-") { + return nil + } + if token.contains("=") { + return nil + } + return token + } + + public static func normalizedWebCookie(_ raw: String?) -> String? { + guard let normalized = CookieHeaderNormalizer.normalize(raw), + normalized.contains("=") + else { + return nil + } + return normalized + } +} diff --git a/Sources/CodexBarCore/Providers/Grok/GrokCreditsProxyFetcher.swift b/Sources/CodexBarCore/Providers/Grok/GrokCreditsProxyFetcher.swift index 062692f5fb..74ce605f5a 100644 --- a/Sources/CodexBarCore/Providers/Grok/GrokCreditsProxyFetcher.swift +++ b/Sources/CodexBarCore/Providers/Grok/GrokCreditsProxyFetcher.swift @@ -1,4 +1,5 @@ import Foundation + #if canImport(FoundationNetworking) import FoundationNetworking #endif @@ -6,7 +7,8 @@ import FoundationNetworking /// Fetches credits from the Grok CLI's billing backend. The grok.com gRPC-web endpoint now requires /// a browser-held WKE keypair (#2812), so the CLI proxy is the supported bearer-token path. public enum GrokCreditsProxyFetcher { - public static let defaultEndpoint = URL(string: "https://cli-chat-proxy.grok.com/v1/billing?format=credits")! + public static let defaultEndpoint = URL( + string: "https://cli-chat-proxy.grok.com/v1/billing?format=credits")! private static let requestTimeoutSeconds: TimeInterval = 15 public static func fetch( @@ -52,8 +54,10 @@ public enum GrokCreditsProxyFetcher { throw GrokWebBillingError.parseFailed } - let subscriptionTier = GrokPlan.displayName(from: config.subscriptionTier ?? response.subscriptionTier) - let resetsAt = config.currentPeriod?.end.flatMap(Self.parseISO8601) + let subscriptionTier = GrokPlan.displayName( + from: config.subscriptionTier ?? response.subscriptionTier) + let resetsAt = + config.currentPeriod?.end.flatMap(Self.parseISO8601) ?? config.billingPeriodEnd.flatMap(Self.parseISO8601) if let percent = config.creditUsagePercent, percent.isFinite { diff --git a/Sources/CodexBarCore/Providers/Grok/GrokProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Grok/GrokProviderDescriptor.swift index 7a63038677..7596107917 100644 --- a/Sources/CodexBarCore/Providers/Grok/GrokProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Grok/GrokProviderDescriptor.swift @@ -3,6 +3,26 @@ import SweetCookieKit public enum GrokProviderDescriptor { public static let descriptor: ProviderDescriptor = Self.makeDescriptor() + private static let credentials = ProviderCredentialAdapter( + tokenAccountSupport: TokenAccountSupport( + title: "SuperGrok tokens", + subtitle: + "Paste a SuperGrok bearer or grok.com cookie. Open token file opens ~/.grok/auth.json.", + placeholder: "Bearer … or Cookie: …", + injection: .environment(key: GrokSettingsReader.oauthTokenEnvironmentKey), + requiresManualCookieSource: false, + cookieName: nil, + environmentKeysToScrub: [GrokSettingsReader.oauthTokenEnvironmentKey], + environmentOverride: { token in + guard let oauth = GrokCredentialRouting.normalizedOAuthToken(token) else { return nil } + return [GrokSettingsReader.oauthTokenEnvironmentKey: oauth] + }), + selectedAccountSourceModeResolver: { base, account, _ in + guard base == .auto, let account else { return base } + return GrokCredentialRouting.resolve( + tokenAccountToken: account.token, + manualCookieHeader: nil).sourceMode ?? base + }) /// Grok is normally signed in through Chrome; avoid touching unrelated browser keychains. private static var browserCookieOrder: BrowserCookieImportOrder? { @@ -16,6 +36,24 @@ public enum GrokProviderDescriptor { static func makeDescriptor() -> ProviderDescriptor { ProviderDescriptor( id: .grok, + settingsSection: .init( + GrokProviderSettingsKey.self, + cookieSettings: { settings in + CookieProviderSettings( + cookieSource: settings.cookieSource, + manualCookieHeader: settings.manualCookieHeader) + }, + credentialSettings: { context in + let cookies = context.cookieSettings(for: .grok) + let resolved = GrokCredentialRouting.cookieSettings( + configuredSource: cookies.cookieSource, + configuredHeader: cookies.manualCookieHeader, + selectedAccountToken: context.account?.token) + return GrokProviderSettings( + cookieSource: resolved.cookieSource, + manualCookieHeader: resolved.manualCookieHeader) + }), + credentials: self.credentials, metadata: ProviderMetadata( id: .grok, displayName: "Grok", @@ -49,16 +87,17 @@ public enum GrokProviderDescriptor { tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "Grok cost summary is not supported yet." }), - pace: ProviderPaceCapability(resetWindowPace: .custom { window, now in - guard Self.primaryLabel(window: window, now: now) == "Weekly", - let resetsAt = window.resetsAt - else { return false } - let windowMinutes = window.windowMinutes ?? 7 * 24 * 60 - let timeUntilReset = resetsAt.timeIntervalSince(now) - return windowMinutes > 0 - && timeUntilReset > 0 - && timeUntilReset <= TimeInterval(windowMinutes) * 60 - }), + pace: ProviderPaceCapability( + resetWindowPace: .custom { window, now in + guard Self.primaryLabel(window: window, now: now) == "Weekly", + let resetsAt = window.resetsAt + else { return false } + let windowMinutes = window.windowMinutes ?? 7 * 24 * 60 + let timeUntilReset = resetsAt.timeIntervalSince(now) + return windowMinutes > 0 + && timeUntilReset > 0 + && timeUntilReset <= TimeInterval(windowMinutes) * 60 + }), presentation: ProviderUsagePresentation(rateWindowLabeler: { metadata, snapshot, now in ProviderRateWindowLabels( primary: Self.displayLabel(window: snapshot.primary, now: now) ?? metadata.sessionLabel, @@ -67,7 +106,7 @@ public enum GrokProviderDescriptor { showsTertiary: metadata.supportsOpus) }), fetchPlan: ProviderFetchPlan( - sourceModes: [.auto, .cli, .web], + sourceModes: [.auto, .cli, .oauth, .web], pipeline: ProviderFetchPipeline(resolveStrategies: self.resolveStrategies)), cli: ProviderCLIConfig( name: "grok", @@ -75,15 +114,24 @@ public enum GrokProviderDescriptor { browserSupportExemption: { _, _, _ in true })) } - private static func resolveStrategies(context: ProviderFetchContext) async -> [any ProviderFetchStrategy] { + private static func resolveStrategies(context: ProviderFetchContext) async + -> [any ProviderFetchStrategy] + { switch context.sourceMode { case .auto: - [GrokCLIFetchStrategy(), GrokWebFetchStrategy()] + [ + GrokCLIFetchStrategy(), + GrokOAuthFetchStrategy(mode: .proxy), + GrokWebFetchStrategy(), + GrokOAuthFetchStrategy(mode: .grpc), + ] case .cli: [GrokCLIFetchStrategy()] + case .oauth: + [GrokOAuthFetchStrategy()] case .web: [GrokWebFetchStrategy()] - case .api, .oauth: + case .api: [] } } @@ -149,14 +197,76 @@ struct GrokCLIFetchStrategy: ProviderFetchStrategy { } } +struct GrokOAuthFetchStrategy: ProviderFetchStrategy { + enum Mode: Sendable { + case proxyThenGrpc + case proxy + case grpc + } + + let mode: Mode + let kind: ProviderFetchKind = .oauth + + init(mode: Mode = .proxyThenGrpc) { + self.mode = mode + } + + var id: String { + switch self.mode { + case .proxyThenGrpc, .proxy: "grok.oauth" + case .grpc: "grok.oauth-grpc" + } + } + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + GrokSettingsReader.resolvedCredentials(environment: context.env) != nil + || FileManager.default.fileExists( + atPath: GrokCredentialsStore.authFileURL(env: context.env).path) + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + try await GrokWebFetchStrategy().fetch(context) { + let credentials = try GrokWebFetchStrategy.resolvedCredentialsResult(context: context).get() + guard !credentials.isExpired else { + throw GrokWebBillingError.missingCredentials + } + switch self.mode { + case .grpc: + let snapshot = try await GrokWebBillingFetcher.fetch(credentials: credentials) + return (snapshot, "grok-web", true) + case .proxy: + let snapshot = try await GrokCreditsProxyFetcher.fetch(credentials: credentials) + return (snapshot, "grok-cli-proxy", true) + case .proxyThenGrpc: + do { + let snapshot = try await GrokCreditsProxyFetcher.fetch(credentials: credentials) + return (snapshot, "grok-cli-proxy", true) + } catch is CancellationError { + throw CancellationError() + } catch let error as URLError where error.code == .cancelled { + throw error + } catch { + let snapshot = try await GrokWebBillingFetcher.fetch(credentials: credentials) + return (snapshot, "grok-web", true) + } + } + } + } + + func shouldFallback(on _: Error, context: ProviderFetchContext) -> Bool { + context.sourceMode == .auto + } +} + struct GrokWebFetchStrategy: ProviderFetchStrategy { let id: String = "grok.web" let kind: ProviderFetchKind = .web typealias ProxyBillingFetch = @Sendable (GrokCredentials) async throws -> GrokWebBillingSnapshot - typealias WebBillingFetch = @Sendable () async throws -> ( - snapshot: GrokWebBillingSnapshot, - sourceLabel: String, - authenticatedByAuthFile: Bool) + typealias WebBillingFetch = + @Sendable () async throws -> ( + snapshot: GrokWebBillingSnapshot, + sourceLabel: String, + authenticatedByAuthFile: Bool) typealias SettingsTierFetch = @Sendable (GrokCredentials?) async throws -> String? /// Browser-cookie import must stay limited to surfaces where a person explicitly asked for it: @@ -164,29 +274,38 @@ struct GrokWebFetchStrategy: ProviderFetchStrategy { /// commands and app UI gestures), or the environment override. Scheduled and background work /// must keep the default `.background` context so it can never reach Chromium Keychain prompts. static func canImportBrowserCookies(runtime: ProviderRuntime, env: [String: String]) -> Bool { - runtime == .app || - ProviderInteractionContext.current == .userInitiated || - env["CODEXBAR_ALLOW_BROWSER_COOKIE_IMPORT"] == "1" + runtime == .app || ProviderInteractionContext.current == .userInitiated + || env["CODEXBAR_ALLOW_BROWSER_COOKIE_IMPORT"] == "1" } func isAvailable(_ context: ProviderFetchContext) async -> Bool { + let cookieSource = context.settings?.grok?.cookieSource ?? .auto + if cookieSource != .off, + GrokCredentialRouting.normalizedWebCookie( + context.settings?.grok?.manualCookieHeader) != nil + { + return true + } #if os(macOS) - if CookieHeaderCache.load(provider: .grok) != nil { + if cookieSource == .auto, CookieHeaderCache.load(provider: .grok) != nil { return true } - if Self.canImportBrowserCookies(runtime: context.runtime, env: context.env), + if cookieSource == .auto, + Self.canImportBrowserCookies(runtime: context.runtime, env: context.env), GrokCookieImporter.hasSession(browserDetection: context.browserDetection) { return true } #endif - return FileManager.default.fileExists(atPath: GrokCredentialsStore.authFileURL(env: context.env).path) + return false } func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { - try await self.fetch(context, webBilling: { [self] in - try await self.fetchWebBilling(context: context) - }) + try await self.fetch( + context, + webBilling: { [self] in + try await self.fetchWebBilling(context: context) + }) } func fetch( @@ -194,12 +313,13 @@ struct GrokWebFetchStrategy: ProviderFetchStrategy { webBilling fetchWebBilling: @escaping WebBillingFetch, settingsTier loadSettingsTier: SettingsTierFetch? = nil) async throws -> ProviderFetchResult { - let authCredentials = (try? GrokCredentialsStore.load(env: context.env)).flatMap { credentials in + let authCredentials = GrokSettingsReader.resolvedCredentials(environment: context.env).flatMap { credentials in credentials.isExpired ? nil : credentials } - let resolveSettingsTier = loadSettingsTier ?? { credentials in - try await GrokStatusProbe.loadSettingsTier(credentials: credentials) - } + let resolveSettingsTier = + loadSettingsTier ?? { credentials in + try await GrokStatusProbe.loadSettingsTier(credentials: credentials) + } let webBilling: GrokWebBillingSnapshot let sourceLabel: String @@ -222,15 +342,16 @@ struct GrokWebFetchStrategy: ProviderFetchStrategy { diagnostic: identitySnapshot.diagnostic) } let credentials = Self.credentialsForWebBillingSnapshot( - credentials: authCredentials, + credentials: GrokSettingsReader.resolvedCredentials(environment: context.env), authenticatedByAuthFile: authenticatedByAuthFile) // Cookie/gRPC fallback is a different browser session. Never attach the // auth.json account's settings tier onto that usage. - let subscriptionTier: String? = if authenticatedByAuthFile { - try await resolveSettingsTier(authCredentials) - } else { - nil - } + let subscriptionTier: String? = + if authenticatedByAuthFile { + try await resolveSettingsTier(authCredentials) + } else { + nil + } let enrichedBilling = webBilling.applying(subscriptionTier: subscriptionTier) let snapshot = GrokUsageSnapshot( billing: nil, @@ -256,20 +377,9 @@ struct GrokWebFetchStrategy: ProviderFetchStrategy { sourceLabel: String, authenticatedByAuthFile: Bool) { - let credentialsResult: Result = Result { - try GrokCredentialsStore.load(env: context.env) - } - let browserCredentials = try? credentialsResult.get() - - return try await Self.fetchProxyFirst( - credentials: browserCredentials, - proxyBilling: proxyBilling) - { [self] in - try await self.fetchLegacyWebBilling( - context: context, - credentialsResult: credentialsResult, - browserCredentials: browserCredentials) - } + try await self.fetchLegacyWebBilling( + context: context, + browserCredentials: nil) } static func fetchProxyFirst( @@ -300,16 +410,35 @@ struct GrokWebFetchStrategy: ProviderFetchStrategy { private func fetchLegacyWebBilling( context: ProviderFetchContext, - credentialsResult: Result, browserCredentials: GrokCredentials?) async throws -> ( snapshot: GrokWebBillingSnapshot, sourceLabel: String, authenticatedByAuthFile: Bool) { + let cookieSettings = context.settings?.grok + let cookieSource = cookieSettings?.cookieSource ?? .auto + let manualHeader = GrokCredentialRouting.normalizedWebCookie(cookieSettings?.manualCookieHeader) + var lastCookieError: Error? + + if cookieSource != .off, + let manualHeader, !manualHeader.isEmpty + { + do { + let snapshot = try await GrokWebBillingFetcher.fetch( + cookieHeader: manualHeader, + credentials: browserCredentials) + return (snapshot, "manual-cookie", false) + } catch { + lastCookieError = error + if cookieSource == .manual { + throw error + } + } + } + #if os(macOS) var cacheObservation = CookieHeaderCache.observeForConditionalMutation(provider: .grok) - var lastCookieError: Error? - if let cached = cacheObservation.entry { + if cookieSource == .auto, let cached = cacheObservation.entry { do { let snapshot = try await Self.fetchValidCookieHeader( cached.cookieHeader, @@ -325,9 +454,12 @@ struct GrokWebFetchStrategy: ProviderFetchStrategy { } } - if Self.canImportBrowserCookies(runtime: context.runtime, env: context.env) { + if cookieSource == .auto, + Self.canImportBrowserCookies(runtime: context.runtime, env: context.env) + { do { - let sessions = try GrokCookieImporter.importSessions(browserDetection: context.browserDetection) + let sessions = try GrokCookieImporter.importSessions( + browserDetection: context.browserDetection) let (snapshot, sourceLabel) = try await Self.fetchFirstValidCookieSession( sessions, credentials: browserCredentials, @@ -336,23 +468,22 @@ struct GrokWebFetchStrategy: ProviderFetchStrategy { } catch { lastCookieError = error } - if browserCredentials == nil { - if FileManager.default.fileExists( - atPath: GrokCredentialsStore.authFileURL(env: context.env).path) - { - _ = try credentialsResult.get() - } - throw lastCookieError ?? GrokWebBillingError.missingCredentials - } + throw lastCookieError ?? GrokWebBillingError.missingCredentials } #endif - let authCredentials = try credentialsResult.get() - guard !authCredentials.isExpired else { - throw GrokWebBillingError.missingCredentials + throw lastCookieError ?? GrokWebBillingError.missingCredentials + } + + static func resolvedCredentialsResult(context: ProviderFetchContext) -> Result< + GrokCredentials, Error, + > { + if let credentials = GrokSettingsReader.resolvedCredentials(environment: context.env) { + return .success(credentials) + } + return Result { + try GrokCredentialsStore.load(env: context.env) } - let snapshot = try await GrokWebBillingFetcher.fetch(credentials: authCredentials) - return (snapshot, "grok-web", true) } static func credentialsForWebBillingSnapshot( @@ -370,11 +501,12 @@ struct GrokWebFetchStrategy: ProviderFetchStrategy { fetch: ((String, GrokCredentials?) async throws -> GrokWebBillingSnapshot)? = nil) async throws -> (GrokWebBillingSnapshot, String) { - let fetchSnapshot = fetch ?? { cookieHeader, credentials in - try await GrokWebBillingFetcher.fetch( - cookieHeader: cookieHeader, - credentials: credentials) - } + let fetchSnapshot = + fetch ?? { cookieHeader, credentials in + try await GrokWebBillingFetcher.fetch( + cookieHeader: cookieHeader, + credentials: credentials) + } var lastError: Error? var teamUsageUnsupportedError: Error? for session in sessions { @@ -412,11 +544,12 @@ struct GrokWebFetchStrategy: ProviderFetchStrategy { fetch: ((String, GrokCredentials?) async throws -> GrokWebBillingSnapshot)? = nil) async throws -> GrokWebBillingSnapshot { - let fetchSnapshot = fetch ?? { cookieHeader, credentials in - try await GrokWebBillingFetcher.fetch( - cookieHeader: cookieHeader, - credentials: credentials) - } + let fetchSnapshot = + fetch ?? { cookieHeader, credentials in + try await GrokWebBillingFetcher.fetch( + cookieHeader: cookieHeader, + credentials: credentials) + } var lastError: Error? var teamUsageUnsupportedError: Error? for authCredentials in Self.cookieAuthAttempts(credentials: credentials) { @@ -430,8 +563,9 @@ struct GrokWebFetchStrategy: ProviderFetchStrategy { } } if let teamUsageUnsupportedError { - let trailingAuthenticationFailure = preferTrailingAuthenticationFailure - && lastError.map(Self.isCookieAuthenticationFailure) == true + let trailingAuthenticationFailure = + preferTrailingAuthenticationFailure + && lastError.map(Self.isCookieAuthenticationFailure) == true if !trailingAuthenticationFailure { throw teamUsageUnsupportedError } @@ -457,7 +591,7 @@ struct GrokWebFetchStrategy: ProviderFetchStrategy { } #endif - func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { - false + func shouldFallback(on _: Error, context: ProviderFetchContext) -> Bool { + context.sourceMode == .auto } } diff --git a/Sources/CodexBarCore/Providers/Grok/GrokProviderSettings.swift b/Sources/CodexBarCore/Providers/Grok/GrokProviderSettings.swift new file mode 100644 index 0000000000..425d882052 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Grok/GrokProviderSettings.swift @@ -0,0 +1,33 @@ +import Foundation + +public struct GrokProviderSettings: ProviderCookieSettings { + public let cookieSource: ProviderCookieSource + public let manualCookieHeader: String? + + public init(cookieSource: ProviderCookieSource, manualCookieHeader: String?) { + self.cookieSource = cookieSource + self.manualCookieHeader = manualCookieHeader + } +} + +public enum GrokProviderSettingsKey: ProviderSettingsSectionKey { + public static let providerID = ProviderInstanceID.grok + public typealias Section = GrokProviderSettings +} + +extension ProviderSettingsSnapshot { + public typealias GrokProviderSettings = CodexBarCore.GrokProviderSettings + public var grok: GrokProviderSettings? { + self[GrokProviderSettingsKey.self] + } + + public static func make(grok: GrokProviderSettings?) -> Self { + self.make(grok, for: GrokProviderSettingsKey.self) + } +} + +extension ProviderSettingsSnapshotContribution { + public static func grok(_ section: GrokProviderSettings) -> Self { + Self(section, for: GrokProviderSettingsKey.self) + } +} diff --git a/Sources/CodexBarCore/Providers/Grok/GrokSettingsReader.swift b/Sources/CodexBarCore/Providers/Grok/GrokSettingsReader.swift new file mode 100644 index 0000000000..817dd91577 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Grok/GrokSettingsReader.swift @@ -0,0 +1,39 @@ +import Foundation + +public enum GrokSettingsReader { + public static let oauthTokenEnvironmentKey = "GROK_OAUTH_TOKEN" + + public static func oauthAccessToken( + environment: [String: String], + settings _: ProviderSettingsSnapshot? = nil) -> String? + { + GrokCredentialRouting.normalizedOAuthToken(environment[self.oauthTokenEnvironmentKey]) + } + + public static func pastedCredentials( + environment: [String: String], + settings: ProviderSettingsSnapshot? = nil) -> GrokCredentials? + { + guard let token = self.oauthAccessToken(environment: environment, settings: settings) else { + return nil + } + return GrokCredentials.pasted(accessToken: token) + } + + public static func resolvedCredentials( + environment: [String: String], + settings: ProviderSettingsSnapshot? = nil) -> GrokCredentials? + { + if let pasted = self.pastedCredentials(environment: environment, settings: settings) { + return pasted + } + if let file = try? GrokCredentialsStore.load(env: environment), !file.isExpired { + return file + } + return nil + } + + public static func normalizedOAuthToken(_ raw: String?) -> String? { + GrokCredentialRouting.normalizedOAuthToken(raw) + } +} diff --git a/Sources/CodexBarCore/Providers/Grok/GrokStatusProbe.swift b/Sources/CodexBarCore/Providers/Grok/GrokStatusProbe.swift index 8269c0c55a..00542b2357 100644 --- a/Sources/CodexBarCore/Providers/Grok/GrokStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Grok/GrokStatusProbe.swift @@ -77,13 +77,16 @@ public struct GrokStatusProbe: Sendable { public init() {} - public static func detectVersion(env: [String: String] = ProcessInfo.processInfo.environment) -> String? { + public static func detectVersion(env: [String: String] = ProcessInfo.processInfo.environment) + -> String? + { guard let binary = BinaryLocator.resolveGrokBinary(env: env) else { return nil } - guard let output = ProviderVersionDetector.run( - path: binary, - args: ["--version"], - environment: env, - mergeStandardError: true) + guard + let output = ProviderVersionDetector.run( + path: binary, + args: ["--version"], + environment: env, + mergeStandardError: true) else { return nil } // Output is like "grok 0.1.210 (8b63e9068c)" — strip the leading "grok " so // callers can prefix the CLI name themselves without duplicating it. @@ -95,7 +98,9 @@ public struct GrokStatusProbe: Sendable { return withoutPrefix.isEmpty ? nil : withoutPrefix } - public func fetch(env: [String: String] = ProcessInfo.processInfo.environment) async throws -> GrokUsageSnapshot { + public func fetch(env: [String: String] = ProcessInfo.processInfo.environment) async throws + -> GrokUsageSnapshot + { // Credentials are optional: we still show identity-less state if the user // hasn't logged in, with a clear hint via the RPC error. let credentials = try? GrokCredentialsStore.load(env: env) @@ -234,7 +239,9 @@ public struct GrokStatusProbe: Sendable { { // If remote usage succeeded, xAI accepted auth and the local // identity is still useful even when the persisted expires_at is stale. - if billing != nil || webBilling != nil { return credentials } + if billing != nil || webBilling != nil { + return credentials + } return credentials.flatMap { $0.isExpired ? nil : $0 } } diff --git a/Sources/CodexBarCore/Providers/Grok/GrokWebBillingFetcher.swift b/Sources/CodexBarCore/Providers/Grok/GrokWebBillingFetcher.swift index 79ee2b4d8b..bac87741cd 100644 --- a/Sources/CodexBarCore/Providers/Grok/GrokWebBillingFetcher.swift +++ b/Sources/CodexBarCore/Providers/Grok/GrokWebBillingFetcher.swift @@ -1,4 +1,5 @@ import Foundation + #if canImport(FoundationNetworking) import FoundationNetworking #endif @@ -64,8 +65,8 @@ public enum GrokWebBillingError: LocalizedError, Sendable { private static let reauthMessage = "Grok web billing rejected credentials. Sign in to grok.com in Chrome or run `grok login` to refresh xAI auth." private static let webKeyExchangeReauthMessage = - "grok.com billing no longer accepts browser-cookie sign-in for this endpoint. Run `grok login` so CodexBar " + - "can read usage via the Grok CLI token." + "grok.com billing no longer accepts browser-cookie sign-in for this endpoint. Run `grok login` so CodexBar " + + "can read usage via the Grok CLI token." static func isWebKeyExchangeCredentialRejection(status: Int, message: String) -> Bool { guard status == 16 else { return false } @@ -74,16 +75,16 @@ public enum GrokWebBillingError: LocalizedError, Sendable { } static func isAuthenticationFailure(status: Int, message: String) -> Bool { - if status == 16 { return true } + if status == 16 { + return true + } guard status == 7 else { return false } let lower = message.lowercased() - return lower.contains("bad-credentials") || - lower.contains("unauthenticated") || - (lower.contains("oauth2") && lower.contains("could not be validated")) || - (lower.contains("access token") && - (lower.contains("invalid") || - lower.contains("expired") || - lower.contains("could not be validated"))) + return lower.contains("bad-credentials") || lower.contains("unauthenticated") + || (lower.contains("oauth2") && lower.contains("could not be validated")) + || (lower.contains("access token") + && (lower.contains("invalid") || lower.contains("expired") + || lower.contains("could not be validated"))) } } @@ -164,10 +165,11 @@ public enum GrokWebBillingFetcher { } private static func classified(_ error: Error, principalType: String?) -> Error { - guard principalType?.trimmingCharacters(in: .whitespacesAndNewlines) - .caseInsensitiveCompare("team") == .orderedSame, - case let GrokWebBillingError.rpcFailed(status, message) = error, - self.isTeamBillingUnavailable(status: status, message: message) + guard + principalType?.trimmingCharacters(in: .whitespacesAndNewlines) + .caseInsensitiveCompare("team") == .orderedSame, + case let GrokWebBillingError.rpcFailed(status, message) = error, + self.isTeamBillingUnavailable(status: status, message: message) else { return error } @@ -210,7 +212,8 @@ public enum GrokWebBillingFetcher { let body = String(data: response.data.prefix(400), encoding: .utf8) ?? "" throw GrokWebBillingError.requestFailed(response.statusCode, body) } - try Self.validateGRPCStatusFields(Self.grpcHeaderFields(from: response.response.allHeaderFields)) + try Self.validateGRPCStatusFields( + Self.grpcHeaderFields(from: response.response.allHeaderFields)) try Self.validateGRPCWebTrailers(response.data) return try Self.parseGRPCWebResponse(response.data) @@ -221,19 +224,25 @@ public enum GrokWebBillingFetcher { return urlError.code == .timedOut || urlError.code == .networkConnectionLost } if case let GrokWebBillingError.requestFailed(status, body) = error { - if [408, 502, 503, 504].contains(status) { return true } + if [408, 502, 503, 504].contains(status) { + return true + } return body.localizedCaseInsensitiveContains("timeout") || body.localizedCaseInsensitiveContains("deadline") } guard case let GrokWebBillingError.rpcFailed(status, message) = error else { return false } - if status == 4 { return true } + if status == 4 { + return true + } guard status == 1 else { return false } return message.localizedCaseInsensitiveContains("timeout") || message.localizedCaseInsensitiveContains("deadline") || message.localizedCaseInsensitiveContains("expired") } - static func parseGRPCWebResponse(_ data: Data, now: Date = Date()) throws -> GrokWebBillingSnapshot { + static func parseGRPCWebResponse(_ data: Data, now: Date = Date()) throws + -> GrokWebBillingSnapshot + { var payloads = Self.grpcWebDataFrames(from: data) if payloads.isEmpty, Self.looksLikeProtobufPayload(data) { payloads = [data] @@ -260,21 +269,21 @@ public enum GrokWebBillingFetcher { return (field.path, Date(timeIntervalSince1970: TimeInterval(raw))) } let futureResetFields = resetFields.filter { $0.date > now } - let reset = futureResetFields - .filter { $0.path == [1, 5, 1] } - .map(\.date) - .min() ?? futureResetFields - .map(\.date) - .min() + let reset = + futureResetFields + .filter { $0.path == [1, 5, 1] } + .map(\.date) + .min() + ?? futureResetFields + .map(\.date) + .min() let hasUsagePeriod = scan.varintFields.contains { field in - field.path.starts(with: [1, 6]) || - (field.path == [1, 8, 1] && (field.value == 1 || field.value == 2)) + field.path.starts(with: [1, 6]) + || (field.path == [1, 8, 1] && (field.value == 1 || field.value == 2)) } - let noUsageYet = parsedPercent == nil && - scan.fixed32Fields.isEmpty && - reset != nil && - hasUsagePeriod + let noUsageYet = + parsedPercent == nil && scan.fixed32Fields.isEmpty && reset != nil && hasUsagePeriod guard let percent = parsedPercent ?? (noUsageYet ? 0 : nil) else { throw GrokWebBillingError.parseFailed } @@ -295,7 +304,8 @@ public enum GrokWebBillingFetcher { while index < bytes.count { guard index + 5 <= bytes.count else { return [] } let flags = bytes[index] - let length = (Int(bytes[index + 1]) << 24) + let length = + (Int(bytes[index + 1]) << 24) | (Int(bytes[index + 2]) << 16) | (Int(bytes[index + 3]) << 8) | Int(bytes[index + 4]) @@ -337,7 +347,8 @@ public enum GrokWebBillingFetcher { .trimmingCharacters(in: .whitespacesAndNewlines) .lowercased() guard normalizedKey.hasPrefix("grpc-") else { continue } - fields[normalizedKey] = String(describing: value) + fields[normalizedKey] = + String(describing: value) .trimmingCharacters(in: .whitespacesAndNewlines) .removingPercentEncoding ?? "" } @@ -350,7 +361,8 @@ public enum GrokWebBillingFetcher { var index = 0 while index + 5 <= bytes.count { let flags = bytes[index] - let length = (Int(bytes[index + 1]) << 24) + let length = + (Int(bytes[index + 1]) << 24) | (Int(bytes[index + 2]) << 16) | (Int(bytes[index + 3]) << 8) | Int(bytes[index + 4]) @@ -363,9 +375,10 @@ public enum GrokWebBillingFetcher { let key = line[.. (HTTPURLResponse, Data) { let url = try #require(request.url) - let response = try #require(HTTPURLResponse( - url: url, - statusCode: statusCode, - httpVersion: nil, - headerFields: ["Content-Type": "application/json"])) + let response = try #require( + HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"])) return (response, Data(body.utf8)) } diff --git a/Tests/CodexBarTests/GrokSettingsReaderTests.swift b/Tests/CodexBarTests/GrokSettingsReaderTests.swift new file mode 100644 index 0000000000..1f967b86b0 --- /dev/null +++ b/Tests/CodexBarTests/GrokSettingsReaderTests.swift @@ -0,0 +1,194 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct GrokSettingsReaderTests { + @Test + func `normalizes a pasted SuperGrok bearer and rejects cookies`() { + #expect(GrokSettingsReader.normalizedOAuthToken(" Bearer abc.def.ghi ") == "abc.def.ghi") + #expect(GrokSettingsReader.normalizedOAuthToken("Cookie: sso=abc") == nil) + #expect(GrokSettingsReader.normalizedOAuthToken("sso=abc; sso-rw=def") == nil) + #expect(GrokSettingsReader.normalizedOAuthToken("xai-mgmt-key") == nil) + #expect(GrokSettingsReader.normalizedOAuthToken(" ") == nil) + } + + @Test + func `reads pasted SuperGrok credentials from GROK_OAUTH_TOKEN`() { + let env = [GrokSettingsReader.oauthTokenEnvironmentKey: "Bearer pasted-token"] + let creds = GrokSettingsReader.pastedCredentials(environment: env) + + #expect(creds?.accessToken == "pasted-token") + #expect(creds?.loginMethod == "SuperGrok") + #expect(GrokSettingsReader.oauthAccessToken(environment: [:]) == nil) + } + + @Test + func `prefers a pasted SuperGrok token when auth json is expired`() throws { + let home = FileManager.default.temporaryDirectory + .appendingPathComponent("CodexBar-GrokExpiredAuth-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: home, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: home) } + try Data( + """ + { + "https://auth.x.ai::client": { + "key": "stale-file-token", + "auth_mode": "oidc", + "expires_at": "2020-01-01T00:00:00Z" + } + } + """.utf8).write(to: home.appendingPathComponent("auth.json")) + + var env = [GrokSettingsReader.oauthTokenEnvironmentKey: "pasted-token"] + env["GROK_HOME"] = home.path + #expect(GrokSettingsReader.resolvedCredentials(environment: env)?.accessToken == "pasted-token") + } + + @Test + func `descriptor exposes SuperGrok token accounts`() { + let support = GrokProviderDescriptor.descriptor.credentials?.tokenAccountSupport + #expect(support?.title == "SuperGrok tokens") + if case let .environment(key) = support?.injection { + #expect(key == GrokSettingsReader.oauthTokenEnvironmentKey) + } else { + Issue.record("expected environment token injection") + } + #expect( + support?.envOverride(token: "pasted-token") == [ + GrokSettingsReader.oauthTokenEnvironmentKey: "pasted-token", + ]) + #expect( + support?.envOverride(token: "Bearer abc.def.ghi") == [ + GrokSettingsReader.oauthTokenEnvironmentKey: "abc.def.ghi", + ]) + #expect(support?.envOverride(token: "Cookie: sso=abc") == nil) + } + + @Test + func `classifies bearer, cookie, and management-key secrets`() { + #expect( + GrokCredentialRouting.resolve( + tokenAccountToken: "Bearer abc.def", manualCookieHeader: nil) + == .oauth(accessToken: "abc.def")) + #expect( + GrokCredentialRouting.resolve( + tokenAccountToken: "Cookie: sso=abc", manualCookieHeader: nil) + == .webCookie(header: "sso=abc")) + #expect( + GrokCredentialRouting.resolve( + tokenAccountToken: "xai-mgmt-key", manualCookieHeader: nil) == .none) + #expect( + GrokCredentialRouting.resolve( + tokenAccountToken: nil, manualCookieHeader: "sso=abc; sso-rw=def") + == .webCookie(header: "sso=abc; sso-rw=def")) + } + + @Test + func `selected SuperGrok accounts remap to oauth or web, never an empty oauth pipeline`() { + let adapter = GrokProviderDescriptor.descriptor.credentials + let oauthAccount = ProviderTokenAccount( + id: UUID(), + label: "oauth", + token: "pasted-token", + addedAt: 0, + lastUsed: nil) + let cookieAccount = ProviderTokenAccount( + id: UUID(), + label: "cookie", + token: "Cookie: sso=abc", + addedAt: 0, + lastUsed: nil) + #expect( + adapter?.selectedAccountSourceMode(base: .auto, account: oauthAccount, config: nil) + == .oauth) + #expect( + adapter?.selectedAccountSourceMode(base: .auto, account: cookieAccount, config: nil) + == .web) + #expect(adapter?.selectedAccountSourceMode(base: .auto, account: nil, config: nil) == .auto) + #expect( + adapter?.selectedAccountSourceMode(base: .cli, account: oauthAccount, config: nil) == .cli) + #expect( + adapter?.selectedAccountSourceMode(base: .web, account: oauthAccount, config: nil) == .web) + #expect( + GrokProviderDescriptor.descriptor.fetchPlan.sourceModes == [.auto, .cli, .oauth, .web]) + } + + @Test + func `selected pasted SuperGrok token wins over a valid auth json file`() throws { + let home = FileManager.default.temporaryDirectory + .appendingPathComponent("CodexBar-GrokSelectedBearer-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: home, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: home) } + try Data( + """ + { + "https://auth.x.ai::client": { + "key": "file-token", + "auth_mode": "oidc" + } + } + """.utf8).write(to: home.appendingPathComponent("auth.json")) + + var env = [GrokSettingsReader.oauthTokenEnvironmentKey: "pasted-token"] + env["GROK_HOME"] = home.path + #expect(GrokSettingsReader.resolvedCredentials(environment: env)?.accessToken == "pasted-token") + env.removeValue(forKey: GrokSettingsReader.oauthTokenEnvironmentKey) + #expect(GrokSettingsReader.resolvedCredentials(environment: env)?.accessToken == "file-token") + } + + @Test + func `selected cookie account overrides the configured Grok cookie header`() { + let selected = GrokCredentialRouting.cookieSettings( + configuredSource: .auto, + configuredHeader: "sso=configured", + selectedAccountToken: "Cookie: sso=selected") + #expect(selected.cookieSource == .manual) + #expect(selected.manualCookieHeader == "sso=selected") + + let bearerKeepsConfigured = GrokCredentialRouting.cookieSettings( + configuredSource: .auto, + configuredHeader: "sso=configured", + selectedAccountToken: "pasted-token") + #expect(bearerKeepsConfigured.cookieSource == .auto) + #expect(bearerKeepsConfigured.manualCookieHeader == "sso=configured") + + let none = GrokCredentialRouting.cookieSettings( + configuredSource: .auto, + configuredHeader: "sso=configured", + selectedAccountToken: nil) + #expect(none.cookieSource == .auto) + #expect(none.manualCookieHeader == "sso=configured") + } + + @Test + func `auto tries SuperGrok OAuth before cookies and after the CLI`() async { + let browserDetection = BrowserDetection(cacheTTL: 0) + func makeContext(sourceMode: ProviderSourceMode) -> ProviderFetchContext { + ProviderFetchContext( + runtime: .app, + sourceMode: sourceMode, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: [:], + settings: nil, + fetcher: UsageFetcher(), + claudeFetcher: ClaudeUsageFetcher(browserDetection: browserDetection), + browserDetection: browserDetection) + } + let auto = makeContext(sourceMode: .auto) + let web = makeContext(sourceMode: .web) + let strategies = await GrokProviderDescriptor.descriptor.fetchPlan.pipeline + .resolveStrategies(auto) + #expect(strategies.map(\.id) == ["grok.cli", "grok.oauth", "grok.web", "grok.oauth-grpc"]) + #expect( + GrokWebFetchStrategy().shouldFallback( + on: GrokWebBillingError.missingCredentials, + context: auto)) + #expect( + !GrokWebFetchStrategy().shouldFallback( + on: GrokWebBillingError.missingCredentials, + context: web)) + } +} diff --git a/Tests/CodexBarTests/GrokWebBillingFetcherTests.swift b/Tests/CodexBarTests/GrokWebBillingFetcherTests.swift index 8afc1ff9f6..e6e036c593 100644 --- a/Tests/CodexBarTests/GrokWebBillingFetcherTests.swift +++ b/Tests/CodexBarTests/GrokWebBillingFetcherTests.swift @@ -24,7 +24,7 @@ struct GrokWebBillingFetcherTests { @Test func `provider exposes cli and web source modes`() { - #expect(GrokProviderDescriptor.descriptor.fetchPlan.sourceModes == [.auto, .cli, .web]) + #expect(GrokProviderDescriptor.descriptor.fetchPlan.sourceModes == [.auto, .cli, .oauth, .web]) } @Test @@ -214,7 +214,7 @@ struct GrokWebBillingFetcherTests { } @Test - func `web strategy preserves malformed auth file error`() async throws { + func `oauth strategy preserves malformed auth file error`() async throws { let grokHome = FileManager.default.temporaryDirectory .appendingPathComponent("CodexBar-GrokWebBilling-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: grokHome, withIntermediateDirectories: true) @@ -224,7 +224,7 @@ struct GrokWebBillingFetcherTests { let browserDetection = BrowserDetection(cacheTTL: 0) let context = ProviderFetchContext( runtime: .cli, - sourceMode: .web, + sourceMode: .oauth, includeCredits: true, webTimeout: 1, webDebugDumpHTML: false, @@ -236,7 +236,7 @@ struct GrokWebBillingFetcherTests { browserDetection: browserDetection) await #expect { - _ = try await GrokWebFetchStrategy().fetch(context) + _ = try await GrokOAuthFetchStrategy().fetch(context) } throws: { error in guard case GrokCredentialsError.decodeFailed = error else { return false } return true diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index 9d8c9d496d..895c6aacaa 100644 --- a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift +++ b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift @@ -1136,61 +1136,61 @@ struct ProviderArchitectureGatekeeperTests { reason: "This Codex account projection passes its fixed provider identity to shared spend infrastructure."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+TokenAccounts.swift", - line: 823, + line: 851, anchor: ".descriptor(for: .codex)", expectedProviderIDs: ["codex"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+TokenAccounts.swift", - line: 825, + line: 853, anchor: "provider: .codex,", expectedProviderIDs: ["codex"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+TokenAccounts.swift", - line: 1292, + line: 1337, anchor: "let scoped = result.usage.scoped(to: .codex)", expectedProviderIDs: ["codex"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+TokenAccounts.swift", - line: 1380, + line: 1421, anchor: "provider: .codex,", expectedProviderIDs: ["codex"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+TokenAccounts.swift", - line: 1384, + line: 1425, anchor: "provider: .codex,", expectedProviderIDs: ["codex"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+TokenAccounts.swift", - line: 1387, + line: 1428, anchor: "self.handlePredictivePaceWarningTransitions(provider: .codex, snapshot: snapshot)", expectedProviderIDs: ["codex"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+TokenAccounts.swift", - line: 1397, + line: 1438, anchor: "self.rememberLiveSystemCodexEmailIfNeeded(snapshot.accountEmail(for: .codex))", expectedProviderIDs: ["codex"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+TokenAccounts.swift", - line: 1400, + line: 1441, anchor: "provider: .codex,", expectedProviderIDs: ["codex"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+TokenAccounts.swift", - line: 1420, + line: 1461, anchor: "self.snapshots.removeValue(forKey: .codex)", expectedProviderIDs: ["codex"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+TokenAccounts.swift", - line: 1454, + line: 1497, anchor: "from: self.presentationSnapshot(for: .deepseek))", expectedProviderIDs: ["deepseek"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), @@ -2923,7 +2923,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+TokenAccounts.swift", - line: 319, + line: 323, anchor: "self.snapshots[.codex] = snapshot", expectedProviderIDs: ["codex"], expectedReferenceCount: 2, @@ -2931,7 +2931,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+TokenAccounts.swift", - line: 740, + line: 758, anchor: "guard provider == .codex else { return outcome }", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -2939,15 +2939,15 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+TokenAccounts.swift", - line: 822, - anchor: "let descriptor = self.providerSpecs[.codex]?.descriptor ?? ProviderDescriptorRegistry", + line: 849, + anchor: "self.providerSpecs[.codex]?.descriptor", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, expectedReferenceFingerprint: ["codex@0"], reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+TokenAccounts.swift", - line: 928, + line: 963, anchor: "let originalManualToken = provider == .stepfun ? self.settings.stepfunToken : nil", expectedProviderIDs: ["stepfun"], expectedReferenceCount: 1, @@ -2955,7 +2955,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+TokenAccounts.swift", - line: 970, + line: 1006, anchor: "guard let self, provider == .stepfun,", expectedProviderIDs: ["stepfun"], expectedReferenceCount: 1, @@ -2963,7 +2963,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+TokenAccounts.swift", - line: 1070, + line: 1109, anchor: "guard let snapshot = self.lastKnownResetSnapshots[.codex],", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -2971,7 +2971,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+TokenAccounts.swift", - line: 1086, + line: 1127, anchor: "return self.lastKnownResetSnapshots[.codex]", expectedProviderIDs: ["codex"], expectedReferenceCount: 2, @@ -2979,7 +2979,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+TokenAccounts.swift", - line: 1293, + line: 1338, anchor: "if let resultEmail = CodexIdentityResolver.normalizeEmail(scoped.accountEmail(for: .codex)),", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -2987,7 +2987,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+TokenAccounts.swift", - line: 1371, + line: 1412, anchor: "guard self.isCurrentProviderRefreshGeneration(.codex, generation: generation) else { return }", expectedProviderIDs: ["codex"], expectedReferenceCount: 9, @@ -3005,7 +3005,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+TokenAccounts.swift", - line: 1413, + line: 1454, anchor: "self.lastFetchAttempts[.codex] = outcome.attempts", expectedProviderIDs: ["codex"], expectedReferenceCount: 5, @@ -3013,15 +3013,15 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+TokenAccounts.swift", - line: 1452, - anchor: "let profileStable = provider == .deepseek", + line: 1495, + anchor: "provider == .deepseek", expectedProviderIDs: ["deepseek"], expectedReferenceCount: 1, expectedReferenceFingerprint: ["deepseek@0"], reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+TokenAccounts.swift", - line: 1467, + line: 1510, anchor: "accountDiscriminatorOverride: provider == .claude ? warningAccountDiscriminator : nil)", expectedProviderIDs: ["claude", "deepseek"], expectedReferenceCount: 2, @@ -3029,7 +3029,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+TokenAccounts.swift", - line: 1488, + line: 1531, anchor: "if provider == .claude,", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, @@ -3037,7 +3037,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+TokenAccounts.swift", - line: 1513, + line: 1557, anchor: "if provider == .deepseek {", expectedProviderIDs: ["deepseek"], expectedReferenceCount: 1, diff --git a/Tests/CodexBarTests/ProviderCredentialCharacterizationTests.swift b/Tests/CodexBarTests/ProviderCredentialCharacterizationTests.swift index 09374afd3c..7dab269a5b 100644 --- a/Tests/CodexBarTests/ProviderCredentialCharacterizationTests.swift +++ b/Tests/CodexBarTests/ProviderCredentialCharacterizationTests.swift @@ -211,6 +211,7 @@ struct ProviderCredentialCharacterizationTests { (.litellm, "LITELLM_API_KEY"), (.sub2api, "SUB2API_API_KEY"), (.ibmbob, "BOBSHELL_API_KEY"), + (.grok, "GROK_OAUTH_TOKEN"), ] let cookieProviders: [UsageProvider] = [ .claude, .cursor, .opencode, .opencodego, .factory, .minimax, .manus, diff --git a/docs/grok.md b/docs/grok.md index 33ca5bee5d..f26f710705 100644 --- a/docs/grok.md +++ b/docs/grok.md @@ -13,6 +13,15 @@ fetched via the ACP JSON-RPC `x.ai/billing` extension method over `grok agent st when available, then via the Grok CLI billing REST API using the local login token. The grok.com billing gRPC-web endpoint remains a best-effort fallback. +## Settings source picker + +- **Auto**: Grok CLI, then SuperGrok OAuth CLI-proxy, then browser cookies, then bearer gRPC. +- **Grok CLI**: `grok agent stdio` only. +- **SuperGrok OAuth**: `~/.grok/auth.json` or a pasted bearer / `GROK_OAUTH_TOKEN`. CLI-proxy credits, then bearer gRPC. No cookies. +- **Browser cookies**: grok.com Cookie header / Chrome import only. No OAuth bearer. +- Token accounts classify at fetch time: bearer → OAuth, `Cookie:` / `name=value` → cookies, `xai-` management keys rejected. +- Selecting a SuperGrok token account remaps Auto to OAuth or Web so it cannot hit an empty `.oauth` pipeline. + ## Data sources + fallback order 1) **`~/.grok/auth.json` (primary identity source)** @@ -49,18 +58,17 @@ The grok.com billing gRPC-web endpoint remains a best-effort fallback. without either value represents zero usage. The reset timestamp comes from `config.currentPeriod.end`, then `config.billingPeriodEnd`. - Plan name does not come from the credits payload. After a successful - auth-file web billing result (CLI-proxy) or the team identity-only path, - CodexBar GETs `https://cli-chat-proxy.grok.com/v1/settings` with the same - bearer headers and reads `subscription_tier_display` (`SuperGrok Heavy` vs - `SuperGrok`). Cookie/gRPC fallbacks are a different browser session and do - not reuse the auth-file settings tier. The request uses a 2-second timeout + auth-file or SuperGrok OAuth web billing result (CLI-proxy) or the team + identity-only path, CodexBar GETs `https://cli-chat-proxy.grok.com/v1/settings` + with the same bearer headers and reads `subscription_tier_display` + (`SuperGrok Heavy` vs `SuperGrok`). Cookie mode does not call the proxy. + If the proxy fails, OAuth retries the grok.com bearer gRPC path, still + without cookies. Cookie/gRPC fallbacks are a different browser session and + do not reuse the auth-file settings tier. The request uses a 2-second timeout and `BoundedTaskJoin`, so a stuck settings call cannot delay already-fetched usage by 15 seconds. Settings timeouts, request failures, and 200 responses that omit `subscription_tier_display` all drop the plan overlay and fall back to the OIDC SuperGrok label. There is no process-lifetime tier cache. - - This is the Grok CLI's supported token-authenticated billing backend. If it - fails, CodexBar continues through the existing browser-cookie and legacy - bearer fallbacks. 4) **grok.com billing gRPC-web fallback** (best-effort) - POSTs an empty gRPC-web protobuf request to `https://grok.com/grok_api_v2.GrokBuildBilling/GetGrokCreditsConfig`. @@ -95,7 +103,8 @@ The grok.com billing gRPC-web endpoint remains a best-effort fallback. ## OAuth credentials -- File: `~/.grok/auth.json` (path overridable via `GROK_HOME`). +- File: `~/.grok/auth.json` (path overridable via `GROK_HOME`). This remains + the preferred identity source when `grok login` has written a non-expired token. - Top-level keys are OIDC scope URLs. CodexBar prefers entries under `https://auth.x.ai::` (SuperGrok), falling back to `https://accounts.x.ai/sign-in` (legacy session). @@ -104,6 +113,15 @@ The grok.com billing gRPC-web endpoint remains a best-effort fallback. `principal_type` is optional because older auth files do not include it. - Tokens are issued by `grok login` and expire after ~7 days; refresh is handled by the CLI itself (CodexBar does not refresh; it just reads the cached credential). +- If `auth.json` is missing or expired, paste a SuperGrok bearer into Grok token + accounts or set `GROK_OAUTH_TOKEN`. Cookie-shaped values and `xai-` management + keys are rejected. The pasted token uses the same CLI-proxy credits URL. +- Settings also expose a cookie source (Auto / Manual / Off). Manual accepts a + grok.com Cookie header when Chrome Safe Storage is denied. Auto still imports + Chrome only. +- Credits `subscriptionTier` maps SuperGrok vs SuperGrok Heavy on the plan badge. + SuperGrok Heavy with no `creditUsagePercent` is unknown usage, not 0%. + ## JSON-RPC contract diff --git a/docs/screenshots/grok-supergrok-oauth-settings.png b/docs/screenshots/grok-supergrok-oauth-settings.png new file mode 100644 index 0000000000..1266cf76ab Binary files /dev/null and b/docs/screenshots/grok-supergrok-oauth-settings.png differ