diff --git a/CHANGELOG.md b/CHANGELOG.md index 812e30ba4a..ecb47a0097 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Usage bars: make workday tick marks configurable with hidden, subtle, and high-contrast appearances (#2904, #2950). Thanks @dstier-git! ### Fixed +- Codex: keep CLI-owned `auth.json` read-only during usage refresh, delegate stale native credentials to CLI recovery, and fail closed for stale external OAuth files (#2944). Thanks @Yuxin-Qiao! - Usage & Spend: keep safely priced Codex totals visible after completed history scans when request-tier uncertainty leaves some days unpriced (#2948). Thanks @Atopoz for the report! - Vertex AI: match Cloud Monitoring quota usage without a `limit_name` to its unambiguous same-metric, same-location limit, restoring quota percentages (#2958). Thanks @MachApple! - Cursor: rename the included-usage split to Cursor and Third Party across menu, widgets, and menu-bar windows, matching Cursor's dashboard labels (#2951). Thanks @baanish! diff --git a/Sources/CodexBar/ManagedCodexAccountService.swift b/Sources/CodexBar/ManagedCodexAccountService.swift index b4ba6cee18..79b5bf3ca8 100644 --- a/Sources/CodexBar/ManagedCodexAccountService.swift +++ b/Sources/CodexBar/ManagedCodexAccountService.swift @@ -364,7 +364,6 @@ final class ManagedCodexAccountService { else { throw ManagedCodexAccountServiceError.workspaceSelectionCancelled } - try self.persistSelectedWorkspaceID(selected.workspaceAccountID, homePath: homePath) return selected } @@ -378,21 +377,6 @@ final class ManagedCodexAccountService { providerAccountID: providerAccountID) } - private func persistSelectedWorkspaceID(_ workspaceID: String, homePath: String) throws { - let env = CodexHomeScope.scopedEnvironment( - base: ProcessInfo.processInfo.environment, - codexHome: homePath) - let credentials = try CodexOAuthCredentialsStore.load(env: env) - try CodexOAuthCredentialsStore.save( - CodexOAuthCredentials( - accessToken: credentials.accessToken, - refreshToken: credentials.refreshToken, - idToken: credentials.idToken, - accountId: workspaceID, - lastRefresh: credentials.lastRefresh), - env: env) - } - private func reconciledExistingAccount( authenticatedEmail: String, providerAccountID: String?, diff --git a/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift b/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift index 322a15637d..4b0c0a0723 100644 --- a/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift @@ -18,6 +18,7 @@ struct CodexProviderImplementation: ProviderImplementation { _ = settings.codexUsageDataSource _ = settings.codexCookieSource _ = settings.codexCookieHeader + _ = settings.codexExternalOAuthSourcesAllowed } @MainActor @@ -132,6 +133,21 @@ struct CodexProviderImplementation: ProviderImplementation { onChange: nil, onAppDidBecomeActive: nil, onAppearWhenEnabled: nil), + ProviderSettingsToggleDescriptor( + id: "codex-external-oauth-sources", + title: "External Codex OAuth sources", + subtitle: [ + "Explicitly allow read-only fallback to legacy Codex and OpenCode OAuth files.", + "CodexBar never refreshes or writes those external credentials.", + "Off by default because this shares another app's OAuth session with Codex usage requests.", + ].joined(separator: " "), + binding: context.boolBinding(\.codexExternalOAuthSourcesAllowed), + statusText: nil, + actions: [], + isVisible: nil, + onChange: nil, + onAppDidBecomeActive: nil, + onAppearWhenEnabled: nil), ProviderSettingsToggleDescriptor( id: "codex-openai-web-battery-saver", title: "OpenAI web battery saver", diff --git a/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift b/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift index 79ffbf1a95..261404f6f5 100644 --- a/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift +++ b/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift @@ -696,7 +696,8 @@ extension SettingsStore { cookieSource: self.codexSnapshotCookieSource(tokenOverride: tokenOverride), manualCookieHeader: self.codexSnapshotCookieHeader(tokenOverride: tokenOverride), reconciliationSnapshot: reconciliationSnapshot, - resolvedActiveSource: resolvedActiveSource)) + resolvedActiveSource: resolvedActiveSource, + allowExternalOAuthSources: self.codexExternalOAuthSourcesAllowed)) } private static func codexUsageDataSource(from source: ProviderSourceMode?) -> CodexUsageDataSource { diff --git a/Sources/CodexBar/SettingsStore+Defaults.swift b/Sources/CodexBar/SettingsStore+Defaults.swift index f01ace9659..c798307157 100644 --- a/Sources/CodexBar/SettingsStore+Defaults.swift +++ b/Sources/CodexBar/SettingsStore+Defaults.swift @@ -723,6 +723,15 @@ extension SettingsStore { } } + var codexExternalOAuthSourcesAllowed: Bool { + get { self.defaultsState.codexExternalOAuthSourcesAllowed } + set { + self.defaultsState.codexExternalOAuthSourcesAllowed = newValue + self.userDefaults.set(newValue, forKey: "codexExternalOAuthSourcesAllowed") + self.noteBackgroundWorkSettingsChanged() + } + } + var openAIWebAccessEnabled: Bool { get { self.defaultsState.openAIWebAccessEnabled } set { diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index 6eda71615c..16e0c2dbc9 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -534,6 +534,11 @@ extension SettingsStore { if Self.isRunningTests, codexSparkUsageVisibleDefault == nil { userDefaults.set(true, forKey: "codexSparkUsageVisible") } + let codexExternalOAuthSourcesAllowed = userDefaults.object( + forKey: "codexExternalOAuthSourcesAllowed") as? Bool ?? false + if Self.isRunningTests, userDefaults.object(forKey: "codexExternalOAuthSourcesAllowed") == nil { + userDefaults.set(false, forKey: "codexExternalOAuthSourcesAllowed") + } let openAIWebAccessDefault = userDefaults.object(forKey: "openAIWebAccessEnabled") as? Bool let openAIWebAccessEnabled = openAIWebAccessDefault ?? false if Self.isRunningTests, openAIWebAccessDefault == nil { @@ -637,6 +642,7 @@ extension SettingsStore { showOptionalCreditsAndExtraUsage: showOptionalCreditsAndExtraUsage, claudeDailyRoutinesUsageVisible: claudeDailyRoutinesUsageVisible, codexSparkUsageVisible: codexSparkUsageVisible, + codexExternalOAuthSourcesAllowed: codexExternalOAuthSourcesAllowed, openAIWebAccessEnabled: openAIWebAccessEnabled, openAIWebBatterySaverEnabled: openAIWebBatterySaverEnabled, backgroundWorkLowPowerModeEnabled: backgroundWorkLowPowerModeEnabled, diff --git a/Sources/CodexBar/SettingsStoreState.swift b/Sources/CodexBar/SettingsStoreState.swift index 6fee78682b..a11d75a923 100644 --- a/Sources/CodexBar/SettingsStoreState.swift +++ b/Sources/CodexBar/SettingsStoreState.swift @@ -61,6 +61,7 @@ struct SettingsDefaultsState { var showOptionalCreditsAndExtraUsage: Bool var claudeDailyRoutinesUsageVisible: Bool var codexSparkUsageVisible: Bool + var codexExternalOAuthSourcesAllowed: Bool var openAIWebAccessEnabled: Bool var openAIWebBatterySaverEnabled: Bool var backgroundWorkLowPowerModeEnabled: Bool diff --git a/Sources/CodexBar/UsageStore+CodexResetCredits.swift b/Sources/CodexBar/UsageStore+CodexResetCredits.swift index c9634bdfe2..918edcf4d9 100644 --- a/Sources/CodexBar/UsageStore+CodexResetCredits.swift +++ b/Sources/CodexBar/UsageStore+CodexResetCredits.swift @@ -5,12 +5,14 @@ extension UsageStore { typealias CodexResetCreditsFetcher = @Sendable ([String: String]) async throws -> CodexRateLimitResetCreditsSnapshot? - func codexResetCreditsFetcher() -> CodexResetCreditsFetcher { + func codexResetCreditsFetcher(workspaceAccountID: String? = nil) -> CodexResetCreditsFetcher { if let override = self._test_codexResetCreditsFetcherOverride { return override } return { env in - try await Self.fetchCodexResetCredits(env: env) + try await Self.fetchCodexResetCredits( + env: env, + workspaceAccountID: workspaceAccountID) } } @@ -35,6 +37,14 @@ extension UsageStore { if result.usage.codexResetCredits != nil { return outcome } + if result.codexResetCreditsAttempted { + // OAuth already tried the winning in-memory credential snapshot. Never reload + // auth.json here: a concurrent CLI login could attach another account's credits. + if requiresResetCreditRescue { + return outcome.replacingResult(with: .failure(UsageError.noRateLimitsFound)) + } + return outcome + } do { try Task.checkCancellation() @@ -68,13 +78,15 @@ extension UsageStore { } nonisolated static func fetchCodexResetCredits( - env: [String: String]) async throws -> CodexRateLimitResetCreditsSnapshot? + env: [String: String], + workspaceAccountID: String? = nil) async throws -> CodexRateLimitResetCreditsSnapshot? { try Task.checkCancellation() let credentials = try CodexOAuthCredentialsStore.loadOAuthTokens(env: env) return try await Self.fetchCodexResetCredits( credentials: credentials, env: env, + workspaceAccountID: workspaceAccountID, request: { accessToken, accountId, requestEnvironment in try await CodexOAuthUsageFetcher.fetchRateLimitResetCredits( accessToken: accessToken, @@ -86,6 +98,7 @@ extension UsageStore { private nonisolated static func fetchCodexResetCredits( credentials: CodexOAuthCredentials, env: [String: String], + workspaceAccountID: String? = nil, request: @escaping @Sendable (String, String?, [String: String]) async throws -> CodexRateLimitResetCreditsSnapshot?) async throws -> CodexRateLimitResetCreditsSnapshot? { @@ -93,16 +106,21 @@ extension UsageStore { // Supplemental inventory is strictly read-only. The main OAuth usage strategy owns token refreshes; // CLI/web winners with stale credentials simply skip this best-effort GET. guard !credentials.needsRefresh else { return nil } - return try await request(credentials.accessToken, credentials.accountId, env) + return try await request(credentials.accessToken, workspaceAccountID ?? credentials.accountId, env) } nonisolated static func _fetchCodexResetCreditsForTesting( credentials: CodexOAuthCredentials, env: [String: String] = [:], + workspaceAccountID: String? = nil, request: @escaping @Sendable (String, String?, [String: String]) async throws -> CodexRateLimitResetCreditsSnapshot?) async throws -> CodexRateLimitResetCreditsSnapshot? { - try await self.fetchCodexResetCredits(credentials: credentials, env: env, request: request) + try await self.fetchCodexResetCredits( + credentials: credentials, + env: env, + workspaceAccountID: workspaceAccountID, + request: request) } } @@ -117,6 +135,7 @@ extension ProviderFetchOutcome { sourceLabel: result.sourceLabel, strategyID: result.strategyID, strategyKind: result.strategyKind, + codexResetCreditsAttempted: result.codexResetCreditsAttempted, diagnostic: result.diagnostic, claudeOAuthKeychainPersistentRefHash: result.claudeOAuthKeychainPersistentRefHash, claudeOAuthHistoryOwnerIdentifier: result.claudeOAuthHistoryOwnerIdentifier, diff --git a/Sources/CodexBar/UsageStore+Refresh.swift b/Sources/CodexBar/UsageStore+Refresh.swift index 0fb7b3dbc1..b5ce2a7c8b 100644 --- a/Sources/CodexBar/UsageStore+Refresh.swift +++ b/Sources/CodexBar/UsageStore+Refresh.swift @@ -404,7 +404,7 @@ extension UsageStore { : nil let priorTokenAccountSnapshot = self.tokenAccountSnapshot(provider: provider, account: tokenAccount) let descriptor = spec.descriptor - let codexResetCreditsFetcher = self.codexResetCreditsFetcher() + let codexResetCreditsFetcher = self.codexResetCreditsFetcher(workspaceAccountID: fetchContext.codexWorkspaceID) let previousCodexSnapshot = codexPreparation?.previousSnapshot let codexMissingWindowBackfillSnapshot = codexPreparation?.missingWindowBackfillSnapshot let fetchOutcome: @Sendable () async -> ProviderFetchOutcome = { diff --git a/Sources/CodexBar/UsageStore+TokenAccounts.swift b/Sources/CodexBar/UsageStore+TokenAccounts.swift index 8e6e2a4820..681c76733f 100644 --- a/Sources/CodexBar/UsageStore+TokenAccounts.swift +++ b/Sources/CodexBar/UsageStore+TokenAccounts.swift @@ -180,6 +180,7 @@ private struct CodexAccountFetchRequest { let limitResetOwnerKey: CodexLimitResetOwnerKey? let descriptor: ProviderDescriptor let context: ProviderFetchContext + let resetCreditsFetcher: UsageStore.CodexResetCreditsFetcher } private struct CodexManagedVisibleAccountRuntimeState { @@ -740,7 +741,7 @@ extension UsageStore { return await Self.attachingCodexResetCreditsIfNeeded( to: outcome, env: context.env, - fetcher: self.codexResetCreditsFetcher()) + fetcher: self.codexResetCreditsFetcher(workspaceAccountID: context.codexWorkspaceID)) } private func fetchTokenAccountOutcomes( @@ -817,7 +818,6 @@ extension UsageStore { priorSnapshots: [CodexAccountUsageSnapshot], activeVisibleAccountID: String?) async -> [CodexAccountFetchResult] { - let resetCreditsFetcher = self.codexResetCreditsFetcher() let requests: [CodexAccountFetchRequest] = accounts.enumerated().map { index, account in let descriptor = self.providerSpecs[.codex]?.descriptor ?? ProviderDescriptorRegistry .descriptor(for: .codex) @@ -845,7 +845,8 @@ extension UsageStore { missingWindowBackfillSnapshot: missingWindowBackfillSnapshot, limitResetOwnerKey: limitResetOwnerKey, descriptor: descriptor, - context: context) + context: context, + resetCreditsFetcher: self.codexResetCreditsFetcher(workspaceAccountID: context.codexWorkspaceID)) } return await withTaskGroup( @@ -859,7 +860,7 @@ extension UsageStore { return await Self.attachingCodexResetCreditsIfNeeded( to: baseOutcome, env: request.context.env, - fetcher: resetCreditsFetcher) + fetcher: request.resetCreditsFetcher) } let initialOutcome = await fetchOutcome() let outcome: ProviderFetchOutcome? = if Self.codexUsageOutcomeMatchesVisibleAccount( diff --git a/Sources/CodexBarCore/ManagedCodexAccountStore.swift b/Sources/CodexBarCore/ManagedCodexAccountStore.swift index 060b303f43..af53859598 100644 --- a/Sources/CodexBarCore/ManagedCodexAccountStore.swift +++ b/Sources/CodexBarCore/ManagedCodexAccountStore.swift @@ -49,8 +49,10 @@ public struct FileManagedCodexAccountStore: ManagedCodexAccountStoring, @uncheck if !self.fileManager.fileExists(atPath: directory.path) { try self.fileManager.createDirectory(at: directory, withIntermediateDirectories: true) } - try data.write(to: self.fileURL, options: [.atomic]) - try self.applySecurePermissionsIfNeeded() + // Managed account metadata contains account identities and private Codex home paths. Use + // the same staged 0600 writer as auth.json so a newly-created file is never briefly + // readable under a permissive umask. + try CredentialFileWriter.writePrivate(data, to: self.fileURL) } public func ensureFileExists() throws -> URL { @@ -59,14 +61,6 @@ public struct FileManagedCodexAccountStore: ManagedCodexAccountStoring, @uncheck return self.fileURL } - private func applySecurePermissionsIfNeeded() throws { - #if os(macOS) - try self.fileManager.setAttributes([ - .posixPermissions: NSNumber(value: Int16(0o600)), - ], ofItemAtPath: self.fileURL.path) - #endif - } - private static func emptyAccountSet() -> ManagedCodexAccountSet { ManagedCodexAccountSet(version: self.currentVersion, accounts: []) } diff --git a/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthCredentials.swift b/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthCredentials.swift index f02d9e9d7f..b8bbc84135 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthCredentials.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthCredentials.swift @@ -7,28 +7,53 @@ import Musl #endif import Foundation -public struct CodexOAuthCredentials: Sendable { +public enum CodexOAuthCredentialSource: String, Equatable, Sendable { + case codexHome + case legacyCodexHome + case openCode + + var canPersistRefresh: Bool { + self == .codexHome + } +} + +public struct CodexOAuthCredentials: Equatable, Sendable { public let accessToken: String public let refreshToken: String public let idToken: String? public let accountId: String? public let lastRefresh: Date? + public let expiresAt: Date? + public let source: CodexOAuthCredentialSource + public let isAPIKey: Bool public init( accessToken: String, refreshToken: String, idToken: String?, accountId: String?, - lastRefresh: Date?) + lastRefresh: Date?, + expiresAt: Date? = nil, + source: CodexOAuthCredentialSource = .codexHome, + isAPIKey: Bool = false) { self.accessToken = accessToken self.refreshToken = refreshToken self.idToken = idToken self.accountId = accountId self.lastRefresh = lastRefresh + self.expiresAt = expiresAt + self.source = source + self.isAPIKey = isAPIKey } public var needsRefresh: Bool { + if self.isAPIKey { + return false + } + if let expiresAt { + return expiresAt.timeIntervalSinceNow <= 60 + } guard let lastRefresh else { return true } let eightDays: TimeInterval = 8 * 24 * 60 * 60 return Date().timeIntervalSince(lastRefresh) > eightDays @@ -37,17 +62,28 @@ public struct CodexOAuthCredentials: Sendable { public enum CodexOAuthCredentialsError: LocalizedError, Sendable { case notFound + case unreadable case decodeFailed(String) case missingTokens + case nativeRefreshRequired + case readOnlySource public var errorDescription: String? { switch self { case .notFound: - "Codex auth.json not found. Run `codex` to log in." + "Codex auth.json not found. Run `codex login` to sign in." + case .unreadable: + "Codex auth.json could not be read. Check its permissions or run `codex login` to sign in again." case let .decodeFailed(message): "Failed to decode Codex credentials: \(message)" case .missingTokens: "Codex auth.json exists but contains no tokens." + case .nativeRefreshRequired: + "Codex auth.json needs refresh. CodexBar will retry through the Codex CLI; " + + "run `codex login` if recovery fails." + case .readOnlySource: + "This external Codex credential source is stale and read-only. " + + "Sign in again with its owning app or run `codex login` to create fresh native credentials." } } } @@ -55,64 +91,133 @@ public enum CodexOAuthCredentialsError: LocalizedError, Sendable { public enum CodexOAuthCredentialsStore { private static func authFilePath( env: [String: String] = ProcessInfo.processInfo.environment, - fileManager: FileManager = .default) -> URL + fileManager: FileManager = .default, + homeDirectory: URL? = nil) -> URL { - CodexHomeScope - .ambientHomeURL(env: env, fileManager: fileManager) + let home = if self.nonEmpty(env["CODEX_HOME"]) != nil { + CodexHomeScope.ambientHomeURL(env: env, fileManager: fileManager) + } else { + (homeDirectory ?? fileManager.homeDirectoryForCurrentUser) + .appendingPathComponent(".codex", isDirectory: true) + } + return home .appendingPathComponent("auth.json") } public static func load(env: [String: String] = ProcessInfo.processInfo .environment) throws -> CodexOAuthCredentials { - let url = self.authFilePath(env: env) - guard FileManager.default.fileExists(atPath: url.path) else { - throw CodexOAuthCredentialsError.notFound - } - - let data = try Data(contentsOf: url) - return try self.parse(data: data) + try self.loadNative(env: env, homeDirectory: nil) } public static func loadOAuthTokens(env: [String: String] = ProcessInfo.processInfo .environment) throws -> CodexOAuthCredentials { - let url = self.authFilePath(env: env) - guard FileManager.default.fileExists(atPath: url.path) else { - throw CodexOAuthCredentialsError.notFound - } - - let data = try Data(contentsOf: url) - guard let credentials = try self.tokenCredentials(data: data) else { - throw CodexOAuthCredentialsError.missingTokens - } - return credentials + try self.parseOAuthTokens(data: self.readAuthData(env: env), source: .codexHome) } public static func parse(data: Data) throws -> CodexOAuthCredentials { - guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { - throw CodexOAuthCredentialsError.decodeFailed("Invalid JSON") + try self.parse(data: data, source: .codexHome) + } + + /// Resolve a credential for a usage probe without changing any source file. + /// + /// The ambient Codex home wins. External sources are opt-in because reading another + /// application's OAuth file is a provider-auth and privacy boundary. + public static func loadForUsage( + env: [String: String] = ProcessInfo.processInfo.environment, + allowExternalSources: Bool = false) throws -> CodexOAuthCredentials + { + try self.loadForUsage(env: env, homeDirectory: nil, allowExternalSources: allowExternalSources) + } + + private static func loadForUsage( + env: [String: String], + homeDirectory: URL?, + allowExternalSources: Bool) throws -> CodexOAuthCredentials + { + do { + return try self.loadNative(env: env, homeDirectory: homeDirectory) + } catch let nativeError as CodexOAuthCredentialsError { + guard allowExternalSources, + self.shouldTryExternalFallback(nativeError, env: env) + else { throw nativeError } + if let legacy = try? self.loadLegacyCodexCredentials(homeDirectory: homeDirectory) { + return legacy + } + if let openCode = try? self.loadOpenCodeCredentials(env: env, homeDirectory: homeDirectory) { + return openCode + } + throw nativeError } + } - if let apiKeyCredentials = Self.apiKeyCredentials(in: json) { + private static func loadNative( + env: [String: String], + homeDirectory: URL?) throws -> CodexOAuthCredentials + { + let data = try self.readAuthData(env: env, homeDirectory: homeDirectory) + return try self.parse(data: data, source: .codexHome) + } + + private static func parse( + data: Data, + source: CodexOAuthCredentialSource) throws -> CodexOAuthCredentials + { + let json = try self.decodeObject(data: data) + + if let apiKeyCredentials = Self.apiKeyCredentials(in: json, source: source) { return apiKeyCredentials } - if let tokenCredentials = Self.tokenCredentials(in: json) { + if let tokenCredentials = Self.tokenCredentials(in: json, source: source) { return tokenCredentials } throw CodexOAuthCredentialsError.missingTokens } - private static func tokenCredentials(data: Data) throws -> CodexOAuthCredentials? { - guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + private static func readAuthData( + env: [String: String], + fileManager: FileManager = .default, + homeDirectory: URL? = nil) throws -> Data + { + let url = self.authFilePath(env: env, fileManager: fileManager, homeDirectory: homeDirectory) + return try self.readAuthData(at: url) + } + + private static func readAuthData(at url: URL) throws -> Data { + do { + // Read once instead of checking existence first. Codex publishes auth.json atomically, + // so a single read avoids a TOCTOU window and lets us distinguish a missing file from a + // transiently unreadable/partially published one without logging credentials. + return try Data(contentsOf: url, options: [.mappedIfSafe]) + } catch { + let nsError = error as NSError + let missingFile = (nsError.domain == NSCocoaErrorDomain && + nsError.code == CocoaError.fileReadNoSuchFile.rawValue) || + (nsError.domain == NSPOSIXErrorDomain && nsError.code == ENOENT) + throw missingFile ? CodexOAuthCredentialsError.notFound : CodexOAuthCredentialsError.unreadable + } + } + + private static func decodeObject(data: Data) throws -> [String: Any] { + do { + guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw CodexOAuthCredentialsError.decodeFailed("Invalid JSON") + } + return json + } catch let error as CodexOAuthCredentialsError { + throw error + } catch { throw CodexOAuthCredentialsError.decodeFailed("Invalid JSON") } - return self.tokenCredentials(in: json) } - private static func tokenCredentials(in json: [String: Any]) -> CodexOAuthCredentials? { + private static func tokenCredentials( + in json: [String: Any], + source: CodexOAuthCredentialSource) -> CodexOAuthCredentials? + { guard let tokens = json["tokens"] as? [String: Any], let accessToken = stringValue( in: tokens, @@ -128,7 +233,9 @@ public enum CodexOAuthCredentialsStore { } let idToken = Self.stringValue(in: tokens, snakeCaseKey: "id_token", camelCaseKey: "idToken") - let accountId = Self.stringValue(in: tokens, snakeCaseKey: "account_id", camelCaseKey: "accountId") + let accountId = Self.nonEmpty( + Self.stringValue(in: tokens, snakeCaseKey: "account_id", camelCaseKey: "accountId")) + ?? Self.accountIDFromJWT(idToken: idToken, accessToken: accessToken) let lastRefresh = Self.parseLastRefresh(from: json["last_refresh"]) return CodexOAuthCredentials( @@ -136,10 +243,14 @@ public enum CodexOAuthCredentialsStore { refreshToken: refreshToken, idToken: idToken, accountId: accountId, - lastRefresh: lastRefresh) + lastRefresh: lastRefresh, + source: source) } - private static func apiKeyCredentials(in json: [String: Any]) -> CodexOAuthCredentials? { + private static func apiKeyCredentials( + in json: [String: Any], + source: CodexOAuthCredentialSource) -> CodexOAuthCredentials? + { guard let apiKey = json["OPENAI_API_KEY"] as? String, !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { @@ -150,13 +261,18 @@ public enum CodexOAuthCredentialsStore { refreshToken: "", idToken: nil, accountId: nil, - lastRefresh: nil) + lastRefresh: nil, + source: source, + isAPIKey: true) } public static func save( _ credentials: CodexOAuthCredentials, env: [String: String] = ProcessInfo.processInfo.environment) throws { + guard credentials.source.canPersistRefresh else { + throw CodexOAuthCredentialsError.readOnlySource + } let url = self.authFilePath(env: env) var json: [String: Any] = [:] @@ -178,7 +294,11 @@ public enum CodexOAuthCredentialsStore { } json["tokens"] = tokens - json["last_refresh"] = ISO8601DateFormatter().string(from: Date()) + // Persist the timestamp that belongs to the credential material. A metadata-only caller + // must not make an old access token look freshly rotated by stamping the current time. + if let lastRefresh = credentials.lastRefresh { + json["last_refresh"] = ISO8601DateFormatter().string(from: lastRefresh) + } let data = try JSONSerialization.data(withJSONObject: json, options: [.prettyPrinted, .sortedKeys]) let directory = url.deletingLastPathComponent() @@ -186,11 +306,105 @@ public enum CodexOAuthCredentialsStore { try CredentialFileWriter.writePrivate(data, to: url) } + private static func shouldTryExternalFallback( + _ error: CodexOAuthCredentialsError, + env: [String: String]) -> Bool + { + guard self.nonEmpty(env["CODEX_HOME"]) == nil else { return false } + switch error { + // A present-but-unreadable or malformed native file is an identity boundary, not an + // invitation to silently substitute another application's session. + case .notFound: + return true + case .unreadable, .decodeFailed, .missingTokens, .nativeRefreshRequired, .readOnlySource: + return false + } + } + + private static func loadLegacyCodexCredentials( + fileManager: FileManager = .default, + homeDirectory: URL? = nil) throws -> CodexOAuthCredentials + { + let home = homeDirectory ?? fileManager.homeDirectoryForCurrentUser + let url = home + .appendingPathComponent(".config", isDirectory: true) + .appendingPathComponent("codex", isDirectory: true) + .appendingPathComponent("auth.json") + return try self.parseOAuthTokens( + data: self.readAuthData(at: url), + source: .legacyCodexHome) + } + + private static func parseOAuthTokens( + data: Data, + source: CodexOAuthCredentialSource) throws -> CodexOAuthCredentials + { + let json = try self.decodeObject(data: data) + guard let credentials = self.tokenCredentials(in: json, source: source) else { + throw CodexOAuthCredentialsError.missingTokens + } + return credentials + } + + private static func loadOpenCodeCredentials( + env: [String: String], + fileManager: FileManager = .default, + homeDirectory: URL? = nil) throws -> CodexOAuthCredentials + { + let root: URL = if let configured = self.nonEmpty(env["XDG_DATA_HOME"]), + let normalized = CodexHomeScope.normalizedHomePath(configured, fileManager: fileManager) + { + URL(fileURLWithPath: normalized, isDirectory: true) + } else { + (homeDirectory ?? fileManager.homeDirectoryForCurrentUser) + .appendingPathComponent(".local", isDirectory: true) + .appendingPathComponent("share", isDirectory: true) + } + // Provider-specific by design: OpenCode stores the OpenAI OAuth entry under its own data directory. + let url = root + .appendingPathComponent("opencode", isDirectory: true) + .appendingPathComponent("auth.json") + return try self.parseOpenCode(data: self.readAuthData(at: url)) + } + + private static func parseOpenCode(data: Data) throws -> CodexOAuthCredentials { + let json = try self.decodeObject(data: data) + guard let auth = json["openai"] as? [String: Any], + let type = self.nonEmpty(auth["type"] as? String), + type.caseInsensitiveCompare("oauth") == .orderedSame, + let access = self.nonEmpty(auth["access"] as? String) + else { + throw CodexOAuthCredentialsError.missingTokens + } + return CodexOAuthCredentials( + accessToken: access, + refreshToken: self.nonEmpty(auth["refresh"] as? String) ?? "", + idToken: nil, + accountId: self.nonEmpty(auth["accountId"] as? String), + lastRefresh: nil, + expiresAt: self.parseEpochMilliseconds(auth["expires"]), + source: .openCode) + } + + private static func parseEpochMilliseconds(_ raw: Any?) -> Date? { + guard let number = raw as? NSNumber else { return nil } + let milliseconds = number.doubleValue + guard milliseconds.isFinite, milliseconds >= 0 else { return nil } + return Date(timeIntervalSince1970: milliseconds / 1000) + } + + private static func nonEmpty(_ value: String?) -> String? { + let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed?.isEmpty == false ? trimmed : nil + } + private static func parseLastRefresh(from raw: Any?) -> Date? { guard let value = raw as? String, !value.isEmpty else { return nil } let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - if let date = formatter.date(from: value) { return date } + if let date = formatter.date(from: value) { + return date + } formatter.formatOptions = [.withInternetDateTime] return formatter.date(from: value) } @@ -209,6 +423,40 @@ public enum CodexOAuthCredentialsStore { } return nil } + + /// Codex auth files normally persist `tokens.account_id`, but older and partially migrated + /// files can omit it. OpenAI also carries the identity in JWT claims; recover it without + /// treating a malformed or opaque token as a credential-read failure. + private static func accountIDFromJWT(idToken: String?, accessToken: String?) -> String? { + for token in [idToken, accessToken].compactMap(\.self) { + let parts = token.split(separator: ".", omittingEmptySubsequences: false) + guard parts.count == 3 else { continue } + var encoded = String(parts[1]) + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + encoded += String(repeating: "=", count: (4 - encoded.count % 4) % 4) + guard let payloadData = Data(base64Encoded: encoded), + let payload = try? JSONSerialization.jsonObject(with: payloadData) as? [String: Any] + else { continue } + + if let accountID = Self.nonEmpty(payload["chatgpt_account_id"] as? String) { + return accountID + } + if let auth = payload["https://api.openai.com/auth"] as? [String: Any], + let accountID = Self.nonEmpty(auth["chatgpt_account_id"] as? String) + { + return accountID + } + if let organizations = payload["organizations"] as? [[String: Any]], + let accountID = organizations + .compactMap({ Self.nonEmpty($0["id"] as? String) }) + .first + { + return accountID + } + } + return nil + } } #if DEBUG @@ -217,6 +465,21 @@ extension CodexOAuthCredentialsStore { self.authFilePath(env: env) } + static func _loadForUsageForTesting( + env: [String: String], + homeDirectory: URL, + allowExternalSources: Bool = false) throws -> CodexOAuthCredentials + { + try self.loadForUsage( + env: env, + homeDirectory: homeDirectory, + allowExternalSources: allowExternalSources) + } + + static func _parseOpenCodeForTesting(data: Data) throws -> CodexOAuthCredentials { + try self.parseOpenCode(data: data) + } + static func _writePrivateFileForTesting( _ data: Data, to url: URL, diff --git a/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthUsageFetcher.swift b/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthUsageFetcher.swift index 87ccfbebfe..0798df7a31 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthUsageFetcher.swift @@ -374,7 +374,7 @@ public enum CodexOAuthFetchError: LocalizedError, Sendable { public var errorDescription: String? { switch self { case .unauthorized: - return "Codex OAuth token expired or invalid. Run `codex` to re-authenticate." + return "Codex OAuth token expired or invalid. Run `codex login` to re-authenticate." case .invalidResponse: return "Invalid response from Codex usage API." case let .serverError(code, message): @@ -647,7 +647,9 @@ public enum CodexOAuthUsageFetcher { private static func normalizeChatGPTBaseURL(_ value: String) -> String { var trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.isEmpty { trimmed = Self.defaultChatGPTBaseURL } + if trimmed.isEmpty { + trimmed = Self.defaultChatGPTBaseURL + } while trimmed.hasSuffix("/") { trimmed.removeLast() } diff --git a/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexTokenRefresher.swift b/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexTokenRefresher.swift index ff797642d1..489b8cdfb9 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexTokenRefresher.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexTokenRefresher.swift @@ -77,7 +77,9 @@ public enum CodexTokenRefresher { refreshToken: newRefreshToken, idToken: newIdToken, accountId: credentials.accountId, - lastRefresh: Date()) + lastRefresh: Date(), + source: credentials.source, + isAPIKey: credentials.isAPIKey) } catch let error as RefreshError { throw error } catch { diff --git a/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift index fc450e1697..aea6191856 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift @@ -1,6 +1,13 @@ import Foundation import SweetCookieKit +extension ProviderFetchContext { + /// The managed Codex workspace identity is app metadata, not a mutation of Codex's auth file. + public var codexWorkspaceID: String? { + self.settings?.codex?.managedWorkspaceAccountID + } +} + public enum CodexProviderDescriptor { public static let descriptor: ProviderDescriptor = Self.makeDescriptor() @@ -127,7 +134,7 @@ public enum CodexProviderDescriptor { case .cli: switch context.sourceMode { case .oauth: - return [oauth] + return [oauth, CodexOAuthNativeRefreshCLIStrategy()] case .web: return [web] case .cli: @@ -135,12 +142,12 @@ public enum CodexProviderDescriptor { case .api: return [] case .auto: - return [oauth, cli] + return context.codexWorkspaceID == nil ? [oauth, cli] : [oauth] } case .app: switch context.sourceMode { case .oauth: - return [oauth] + return [oauth, CodexOAuthNativeRefreshCLIStrategy()] case .cli: return [cli] case .web: @@ -148,7 +155,7 @@ public enum CodexProviderDescriptor { case .api: return [] case .auto: - return [oauth, cli] + return context.codexWorkspaceID == nil ? [oauth, cli] : [oauth] } } } @@ -312,26 +319,88 @@ struct CodexCLIUsageStrategy: ProviderFetchStrategy { } } +/// Explicit OAuth may recover stale native credentials through the Codex CLI, without allowing +/// missing or external credentials to silently switch sources. +struct CodexOAuthNativeRefreshCLIStrategy: ProviderFetchStrategy { + let id: String = "codex.oauth-native-refresh-cli" + let kind: ProviderFetchKind = .cli + private let binaryResolver: @Sendable (ProviderFetchContext) -> String? + + init( + binaryResolver: @escaping @Sendable (ProviderFetchContext) -> String? = { + CodexCLIUsageStrategy.resolvedBinary(env: $0.env) + }) + { + self.binaryResolver = binaryResolver + } + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + // The Codex CLI app-server has no supported way to receive CodexBar's selected managed + // workspace account header. Falling back to it would therefore report the auth.json + // workspace under a different selected workspace. Keep this path unavailable until the + // owner CLI can carry that scope explicitly. + guard context.codexWorkspaceID == nil, + context.sourceMode == .oauth, + self.binaryResolver(context) != nil, + let credentials = try? CodexOAuthCredentialsStore.loadForUsage( + env: context.env, + allowExternalSources: context.settings?.codex?.allowExternalOAuthSources == true) + else { return false } + return credentials.source == .codexHome && credentials.needsRefresh + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + try await CodexCLIUsageStrategy().fetch(context) + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } +} + struct CodexOAuthFetchStrategy: ProviderFetchStrategy { let id: String = "codex.oauth" let kind: ProviderFetchKind = .oauth func isAvailable(_ context: ProviderFetchContext) async -> Bool { - (try? CodexOAuthCredentialsStore.load(env: context.env)) != nil + (try? CodexOAuthCredentialsStore.loadForUsage( + env: context.env, + allowExternalSources: context.settings?.codex?.allowExternalOAuthSources == true)) != nil } func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { - var credentials = try CodexOAuthCredentialsStore.load(env: context.env) + let credentials = try CodexOAuthCredentialsStore.loadForUsage( + env: context.env, + allowExternalSources: context.settings?.codex?.allowExternalOAuthSources == true) + return try await Self.fetch(context: context, credentials: credentials) + } - if credentials.needsRefresh, !credentials.refreshToken.isEmpty { - credentials = try await CodexTokenRefresher.refresh(credentials) - try CodexOAuthCredentialsStore.save(credentials, env: context.env) + private static func fetch( + context: ProviderFetchContext, + credentials initialCredentials: CodexOAuthCredentials) async throws -> ProviderFetchResult + { + var credentials = try await Self.prepareCredentialsForUsage( + initialCredentials, + env: context.env) + if let managedWorkspaceAccountID = context.settings?.codex?.managedWorkspaceAccountID, + !managedWorkspaceAccountID.isEmpty + { + credentials = CodexOAuthCredentials( + accessToken: credentials.accessToken, + refreshToken: credentials.refreshToken, + idToken: credentials.idToken, + accountId: managedWorkspaceAccountID, + lastRefresh: credentials.lastRefresh, + expiresAt: credentials.expiresAt, + source: credentials.source, + isAPIKey: credentials.isAPIKey) } let usage = try await CodexOAuthUsageFetcher.fetchUsage( accessToken: credentials.accessToken, accountId: credentials.accountId, env: context.env) + let resetCreditsAttempted = Self.shouldFetchResetCredits(context) let resetCredits = try await Self.fetchResetCreditsIfRequested( context: context, credentials: credentials) @@ -341,7 +410,8 @@ struct CodexOAuthFetchStrategy: ProviderFetchStrategy { resetCredits: resetCredits, credentials: credentials, updatedAt: updatedAt, - allowEmptyUsageForResetCreditEnrichment: Self.defersResetCreditFetchToApp(context)) + allowEmptyUsageForResetCreditEnrichment: Self.defersResetCreditFetchToApp(context), + codexResetCreditsAttempted: resetCreditsAttempted) let spendControlsResult = try await Self.applyingSpendControlsMonthlyLimit( oauthResult, usage: usage, @@ -350,13 +420,47 @@ struct CodexOAuthFetchStrategy: ProviderFetchStrategy { return try await Self.replacingWithCLIMonthlyLimitIfAvailable(spendControlsResult, context: context) } + private static func prepareCredentialsForUsage( + _ credentials: CodexOAuthCredentials, + env _: [String: String]) async throws -> CodexOAuthCredentials + { + guard credentials.needsRefresh else { return credentials } + switch credentials.source { + case .codexHome: + // Codex CLI owns the native auth file and its refresh-token lifecycle. Do not redeem + // that shared token in-process: a rotated response would strand the CLI with the old + // refresh token because CodexBar deliberately never publishes it back to auth.json. + throw CodexOAuthCredentialsError.nativeRefreshRequired + case .legacyCodexHome, .openCode: + // External OAuth files are explicitly read-only and have no safe writer handoff. + // Failing closed avoids consuming a refresh token owned by another application. + throw CodexOAuthCredentialsError.readOnlySource + } + } + private static func shouldFetchResetCredits(_ context: ProviderFetchContext) -> Bool { - guard case .cli = context.runtime else { return false } - return context.includeCredits + switch context.runtime { + case .app: + // Fetch with the winning in-memory OAuth credentials before UsageStore's generic + // enrichment hook runs. Reloading auth.json there can still observe the stale source + // snapshot after an in-memory refresh and would silently drop reset-credit inventory. + true + case .cli: + context.includeCredits + } } func shouldFallback(on error: Error, context: ProviderFetchContext) -> Bool { - guard context.sourceMode == .auto else { return false } + let isExplicitNativeRefresh = if let credentialsError = error as? CodexOAuthCredentialsError, + case .nativeRefreshRequired = credentialsError + { + true + } else { + false + } + guard context.sourceMode == .auto || (context.sourceMode == .oauth && isExplicitNativeRefresh) else { + return false + } // Auto mode may launch the CLI as the next strategy. Keep that fallback // limited to OAuth states the CLI can actually repair, otherwise // transient API or decode failures can spawn `codex app-server` @@ -371,9 +475,9 @@ struct CodexOAuthFetchStrategy: ProviderFetchStrategy { } if let credentialsError = error as? CodexOAuthCredentialsError { switch credentialsError { - case .notFound, .missingTokens: + case .notFound, .unreadable, .missingTokens, .nativeRefreshRequired: return true - case .decodeFailed: + case .decodeFailed, .readOnlySource: return false } } @@ -404,7 +508,8 @@ struct CodexOAuthFetchStrategy: ProviderFetchStrategy { resetCredits: CodexRateLimitResetCreditsSnapshot? = nil, credentials: CodexOAuthCredentials, updatedAt: Date, - allowEmptyUsageForResetCreditEnrichment: Bool = false) throws -> ProviderFetchResult + allowEmptyUsageForResetCreditEnrichment: Bool = false, + codexResetCreditsAttempted: Bool = false) throws -> ProviderFetchResult { let credits = Self.mapCredits(response: usageResponse, updatedAt: updatedAt) let reconciled = CodexReconciledState.fromOAuth( @@ -417,12 +522,13 @@ struct CodexOAuthFetchStrategy: ProviderFetchStrategy { || usageResponse.additionalRateLimitsDecodeFailed ? .unknown : .exact - return CodexOAuthFetchStrategy().makeResult( + let result = CodexOAuthFetchStrategy().makeResult( usage: reconciled.toUsageSnapshot() .withCodexResetCredits(resetCredits) .withDataConfidence(dataConfidence), credits: credits, sourceLabel: "oauth") + return Self.markResetCreditsAttempted(result, attempted: codexResetCreditsAttempted) } guard credits != nil @@ -434,7 +540,7 @@ struct CodexOAuthFetchStrategy: ProviderFetchStrategy { // Credit balances and manual resets remain useful when OAuth omits // rate-limit windows. Keep the partial result instead of discarding it. - return CodexOAuthFetchStrategy().makeResult( + let result = CodexOAuthFetchStrategy().makeResult( usage: UsageSnapshot( primary: nil, secondary: nil, @@ -446,6 +552,29 @@ struct CodexOAuthFetchStrategy: ProviderFetchStrategy { credentials: credentials)), credits: credits, sourceLabel: "oauth") + return Self.markResetCreditsAttempted(result, attempted: codexResetCreditsAttempted) + } + + private static func markResetCreditsAttempted( + _ result: ProviderFetchResult, + attempted: Bool) -> ProviderFetchResult + { + guard attempted else { return result } + return ProviderFetchResult( + usage: result.usage, + credits: result.credits, + dashboard: result.dashboard, + sourceLabel: result.sourceLabel, + strategyID: result.strategyID, + strategyKind: result.strategyKind, + codexResetCreditsAttempted: true, + diagnostic: result.diagnostic, + claudeOAuthKeychainPersistentRefHash: result.claudeOAuthKeychainPersistentRefHash, + claudeOAuthHistoryOwnerIdentifier: result.claudeOAuthHistoryOwnerIdentifier, + claudeOAuthCredentialOwner: result.claudeOAuthCredentialOwner, + claudeOAuthKeychainCredentialMismatch: result.claudeOAuthKeychainCredentialMismatch, + claudeOAuthKeychainCredentialAbsent: result.claudeOAuthKeychainCredentialAbsent, + claudeOAuthKeychainCredentialUnavailable: result.claudeOAuthKeychainCredentialUnavailable) } private static func replacingWithCLIMonthlyLimitIfAvailable( @@ -454,6 +583,7 @@ struct CodexOAuthFetchStrategy: ProviderFetchStrategy { cliStrategy: any ProviderFetchStrategy = CodexCLIUsageStrategy()) async throws -> ProviderFetchResult { guard context.sourceMode == .auto, + context.codexWorkspaceID == nil, context.includeCredits, self.shouldTryCLIForMonthlyLimit(oauthResult) else { return oauthResult } @@ -482,6 +612,7 @@ struct CodexOAuthFetchStrategy: ProviderFetchStrategy { sourceLabel: oauthResult.sourceLabel, strategyID: oauthResult.strategyID, strategyKind: oauthResult.strategyKind, + codexResetCreditsAttempted: oauthResult.codexResetCreditsAttempted, diagnostic: oauthResult.diagnostic) } @@ -561,6 +692,7 @@ struct CodexOAuthFetchStrategy: ProviderFetchStrategy { sourceLabel: result.sourceLabel, strategyID: result.strategyID, strategyKind: result.strategyKind, + codexResetCreditsAttempted: result.codexResetCreditsAttempted, diagnostic: result.diagnostic, claudeOAuthKeychainPersistentRefHash: result.claudeOAuthKeychainPersistentRefHash, claudeOAuthHistoryOwnerIdentifier: result.claudeOAuthHistoryOwnerIdentifier, @@ -638,6 +770,20 @@ struct CodexOAuthFetchStrategy: ProviderFetchStrategy { #if DEBUG extension CodexOAuthFetchStrategy { + static func _fetchForTesting( + context: ProviderFetchContext, + credentials: CodexOAuthCredentials) async throws -> ProviderFetchResult + { + try await self.fetch(context: context, credentials: credentials) + } + + static func _prepareCredentialsForTesting( + _ credentials: CodexOAuthCredentials, + env: [String: String] = [:]) async throws -> CodexOAuthCredentials + { + try await self.prepareCredentialsForUsage(credentials, env: env) + } + static func _applySpendControlsMonthlyLimitForTesting( _ result: ProviderFetchResult, usage: CodexUsageResponse, diff --git a/Sources/CodexBarCore/Providers/Codex/CodexProviderSettings.swift b/Sources/CodexBarCore/Providers/Codex/CodexProviderSettings.swift index 11d64c2764..58bec97bae 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexProviderSettings.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexProviderSettings.swift @@ -9,6 +9,10 @@ public struct CodexProviderSettings: Sendable { public let profileAccountTargetUnavailable: Bool public let openAIWebCacheScope: CookieHeaderCache.Scope? public let dashboardAuthorityKnownOwners: [CodexDashboardKnownOwnerCandidate] + public let allowExternalOAuthSources: Bool + /// Selected workspace identity stored in CodexBar's managed-account metadata. It is sent as + /// an account header without rewriting the Codex-owned auth.json. + public let managedWorkspaceAccountID: String? public init( usageDataSource: CodexUsageDataSource, @@ -18,7 +22,9 @@ public struct CodexProviderSettings: Sendable { managedAccountTargetUnavailable: Bool = false, profileAccountTargetUnavailable: Bool = false, openAIWebCacheScope: CookieHeaderCache.Scope? = nil, - dashboardAuthorityKnownOwners: [CodexDashboardKnownOwnerCandidate] = []) + dashboardAuthorityKnownOwners: [CodexDashboardKnownOwnerCandidate] = [], + allowExternalOAuthSources: Bool = false, + managedWorkspaceAccountID: String? = nil) { self.usageDataSource = usageDataSource self.cookieSource = cookieSource @@ -28,6 +34,8 @@ public struct CodexProviderSettings: Sendable { self.profileAccountTargetUnavailable = profileAccountTargetUnavailable self.openAIWebCacheScope = openAIWebCacheScope self.dashboardAuthorityKnownOwners = dashboardAuthorityKnownOwners + self.allowExternalOAuthSources = allowExternalOAuthSources + self.managedWorkspaceAccountID = managedWorkspaceAccountID } } diff --git a/Sources/CodexBarCore/Providers/Codex/CodexProviderSettingsBuilder.swift b/Sources/CodexBarCore/Providers/Codex/CodexProviderSettingsBuilder.swift index 62de1552f5..8cef5c7bf2 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexProviderSettingsBuilder.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexProviderSettingsBuilder.swift @@ -6,19 +6,22 @@ public struct CodexProviderSettingsBuilderInput: Sendable { public let manualCookieHeader: String? public let reconciliationSnapshot: CodexAccountReconciliationSnapshot public let resolvedActiveSource: CodexResolvedActiveSource + public let allowExternalOAuthSources: Bool public init( usageDataSource: CodexUsageDataSource, cookieSource: ProviderCookieSource, manualCookieHeader: String?, reconciliationSnapshot: CodexAccountReconciliationSnapshot, - resolvedActiveSource: CodexResolvedActiveSource) + resolvedActiveSource: CodexResolvedActiveSource, + allowExternalOAuthSources: Bool = false) { self.usageDataSource = usageDataSource self.cookieSource = cookieSource self.manualCookieHeader = manualCookieHeader self.reconciliationSnapshot = reconciliationSnapshot self.resolvedActiveSource = resolvedActiveSource + self.allowExternalOAuthSources = allowExternalOAuthSources } } @@ -77,6 +80,13 @@ public enum CodexProviderSettingsBuilder { case let .profileHome(path): snapshot.profileHomeAccount(path: path) == nil } + let managedWorkspaceAccountID: String? = switch input.resolvedActiveSource.resolvedSource { + case .liveSystem, .profileHome: + nil + case .managedAccount: + input.reconciliationSnapshot.activeStoredAccount?.workspaceAccountID + ?? input.reconciliationSnapshot.activeStoredAccount?.providerAccountID + } return ProviderSettingsSnapshot.CodexProviderSettings( usageDataSource: input.usageDataSource, @@ -88,6 +98,8 @@ public enum CodexProviderSettingsBuilder { && snapshot.activeStoredAccount == nil, profileAccountTargetUnavailable: profileAccountTargetUnavailable, openAIWebCacheScope: openAIWebCacheScope, - dashboardAuthorityKnownOwners: CodexKnownOwnerCatalog.candidates(from: snapshot)) + dashboardAuthorityKnownOwners: CodexKnownOwnerCatalog.candidates(from: snapshot), + allowExternalOAuthSources: input.allowExternalOAuthSources, + managedWorkspaceAccountID: managedWorkspaceAccountID) } } diff --git a/Sources/CodexBarCore/Providers/ProviderFetchPlan.swift b/Sources/CodexBarCore/Providers/ProviderFetchPlan.swift index 8a82741952..c14e429756 100644 --- a/Sources/CodexBarCore/Providers/ProviderFetchPlan.swift +++ b/Sources/CodexBarCore/Providers/ProviderFetchPlan.swift @@ -113,6 +113,10 @@ public struct ProviderFetchResult: Sendable { public let sourceLabel: String public let strategyID: String public let strategyKind: ProviderFetchKind + /// True when the Codex OAuth strategy already attempted reset-credit enrichment with its + /// winning in-memory credential snapshot. Generic enrichment must not reload auth.json after + /// that attempt fails, or it could attach another account's credits to this usage result. + public let codexResetCreditsAttempted: Bool /// Optional live diagnostic retained alongside an otherwise usable snapshot. public let diagnostic: String? /// Transient account ownership evidence for plan-utilization history. @@ -137,6 +141,7 @@ public struct ProviderFetchResult: Sendable { sourceLabel: String, strategyID: String, strategyKind: ProviderFetchKind, + codexResetCreditsAttempted: Bool = false, diagnostic: String? = nil, claudeOAuthKeychainPersistentRefHash: String? = nil, claudeOAuthHistoryOwnerIdentifier: String? = nil, @@ -151,6 +156,7 @@ public struct ProviderFetchResult: Sendable { self.sourceLabel = sourceLabel self.strategyID = strategyID self.strategyKind = strategyKind + self.codexResetCreditsAttempted = codexResetCreditsAttempted self.diagnostic = diagnostic self.claudeOAuthKeychainPersistentRefHash = claudeOAuthKeychainPersistentRefHash self.claudeOAuthHistoryOwnerIdentifier = claudeOAuthHistoryOwnerIdentifier diff --git a/Tests/CodexBarTests/CodexBaselineCharacterizationTests.swift b/Tests/CodexBarTests/CodexBaselineCharacterizationTests.swift index fec7fe5c99..9b1350d30b 100644 --- a/Tests/CodexBarTests/CodexBaselineCharacterizationTests.swift +++ b/Tests/CodexBarTests/CodexBaselineCharacterizationTests.swift @@ -153,9 +153,9 @@ struct CodexBaselineCharacterizationTests { } @Test - func `explicit fetch plan modes keep single Codex strategy selection`() async { + func `explicit fetch plan modes keep Codex strategy selection`() async { let appCases: [(ProviderSourceMode, [String])] = [ - (.oauth, ["codex.oauth"]), + (.oauth, ["codex.oauth", "codex.oauth-native-refresh-cli"]), (.cli, ["codex.cli"]), (.web, ["codex.web.dashboard"]), ] diff --git a/Tests/CodexBarTests/CodexOAuthCredentialReadTests.swift b/Tests/CodexBarTests/CodexOAuthCredentialReadTests.swift new file mode 100644 index 0000000000..f3c7ae9de7 --- /dev/null +++ b/Tests/CodexBarTests/CodexOAuthCredentialReadTests.swift @@ -0,0 +1,754 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct CodexOAuthCredentialReadTests { + @Test + func `missing auth json maps to a not found credential error`() throws { + let home = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-oauth-missing-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: home) } + + let error = #expect(throws: CodexOAuthCredentialsError.self) { + try CodexOAuthCredentialsStore.loadOAuthTokens(env: ["CODEX_HOME": home.path]) + } + guard case .notFound = error else { + Issue.record("Expected a missing auth file to remain distinguishable") + return + } + } + + @Test + func `unreadable auth json maps to an unreadable credential error`() throws { + let home = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-oauth-unreadable-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: home) } + try FileManager.default.createDirectory(at: home, withIntermediateDirectories: true) + try FileManager.default.createDirectory( + at: home.appendingPathComponent("auth.json"), + withIntermediateDirectories: false) + + let error = #expect(throws: CodexOAuthCredentialsError.self) { + try CodexOAuthCredentialsStore.loadOAuthTokens(env: ["CODEX_HOME": home.path]) + } + guard case .unreadable = error else { + Issue.record("Expected an unreadable auth path to remain distinguishable") + return + } + } + + @Test + func `malformed auth json maps to a safe decode error`() throws { + let error = #expect(throws: CodexOAuthCredentialsError.self) { + try CodexOAuthCredentialsStore.parse(data: Data("not-json".utf8)) + } + guard case let .decodeFailed(message) = error else { + Issue.record("Expected malformed JSON to map to decodeFailed") + return + } + #expect(message == "Invalid JSON") + } + + @Test + func `missing account id falls back to the OpenAI JWT auth claim`() throws { + let idToken = Self.jwt(payload: [ + "https://api.openai.com/auth": ["chatgpt_account_id": "acct-namespaced"], + ]) + let data = try JSONSerialization.data(withJSONObject: [ + "tokens": [ + "access_token": "opaque-access", + "refresh_token": "refresh", + "id_token": idToken, + ], + ]) + + let credentials = try CodexOAuthCredentialsStore.parse(data: data) + + #expect(credentials.accountId == "acct-namespaced") + } + + @Test + func `missing account id falls back to the direct OpenAI JWT claim`() throws { + let idToken = Self.jwt(payload: [ + "chatgpt_account_id": "acct-direct", + ]) + let data = try JSONSerialization.data(withJSONObject: [ + "tokens": [ + "access_token": "opaque-access", + "refresh_token": "refresh", + "id_token": idToken, + ], + ]) + + let credentials = try CodexOAuthCredentialsStore.parse(data: data) + + #expect(credentials.accountId == "acct-direct") + } + + @Test + func `whitespace account id falls back to the OpenAI JWT claim`() throws { + let accessToken = Self.jwt(payload: [ + "organizations": [["id": "org-from-jwt"]], + ]) + let data = try JSONSerialization.data(withJSONObject: [ + "tokens": [ + "access_token": accessToken, + "refresh_token": "refresh", + "account_id": " \n", + ], + ]) + + let credentials = try CodexOAuthCredentialsStore.parse(data: data) + + #expect(credentials.accountId == "org-from-jwt") + } + + @Test + func `missing account id falls back to the first OpenAI organization`() throws { + let accessToken = Self.jwt(payload: [ + "organizations": [["id": "org-first"], ["id": "org-second"]], + ]) + let data = try JSONSerialization.data(withJSONObject: [ + "tokens": [ + "access_token": accessToken, + "refresh_token": "refresh", + ], + ]) + + let credentials = try CodexOAuthCredentialsStore.parse(data: data) + + #expect(credentials.accountId == "org-first") + } + + @Test + func `missing account id skips blank OpenAI organizations`() throws { + let accessToken = Self.jwt(payload: [ + "organizations": [["id": " "], ["id": "org-later"], ["id": ""]], + ]) + let data = try JSONSerialization.data(withJSONObject: [ + "tokens": [ + "access_token": accessToken, + "refresh_token": "refresh", + ], + ]) + + let credentials = try CodexOAuthCredentialsStore.parse(data: data) + + #expect(credentials.accountId == "org-later") + } + + @Test + func `open code oauth credentials preserve expiry and remain read only`() throws { + let expiresAt = Date().addingTimeInterval(3600) + let payload: [String: Any] = [ + "openai": [ + "type": "oauth", + "access": "open-code-access", + "refresh": "open-code-refresh", + "expires": Int(expiresAt.timeIntervalSince1970 * 1000), + "accountId": "open-code-account", + ], + ] + let data = try JSONSerialization.data(withJSONObject: payload) + let credentials = try CodexOAuthCredentialsStore._parseOpenCodeForTesting(data: data) + + #expect(credentials.accessToken == "open-code-access") + #expect(credentials.refreshToken == "open-code-refresh") + #expect(credentials.accountId == "open-code-account") + #expect(credentials.source == .openCode) + #expect(credentials.expiresAt.map { abs($0.timeIntervalSince(expiresAt)) < 1 } == true) + #expect(!credentials.needsRefresh) + let error = #expect(throws: CodexOAuthCredentialsError.self) { + try CodexOAuthCredentialsStore.save(credentials, env: ["CODEX_HOME": "/tmp/unused-codex-home"]) + } + guard case .readOnlySource = error else { + Issue.record("OpenCode credentials must never be persisted by CodexBar") + return + } + } + + @Test + func `expired open code oauth credentials are marked for refresh`() throws { + let payload: [String: Any] = [ + "openai": [ + "type": "oauth", + "access": "expired-access", + "refresh": "expired-refresh", + "expires": Int(Date().addingTimeInterval(-1).timeIntervalSince1970 * 1000), + ], + ] + let data = try JSONSerialization.data(withJSONObject: payload) + let credentials = try CodexOAuthCredentialsStore._parseOpenCodeForTesting(data: data) + + #expect(credentials.source == .openCode) + #expect(credentials.needsRefresh) + } + + @Test + func `expired read-only oauth credentials fail closed without refresh`() async throws { + let credentials = CodexOAuthCredentials( + accessToken: "expired-access", + refreshToken: "external-refresh", + idToken: nil, + accountId: nil, + lastRefresh: nil, + expiresAt: Date().addingTimeInterval(-1), + source: .openCode) + let error = await #expect(throws: CodexOAuthCredentialsError.self) { + try await CodexOAuthFetchStrategy._prepareCredentialsForTesting(credentials) + } + guard case .readOnlySource = error else { + Issue.record("Expired external credentials must not consume an owner refresh token") + return + } + } + + @Test + func `expired read-only oauth credentials without a refresh token are rejected`() async throws { + let credentials = CodexOAuthCredentials( + accessToken: "expired-access", + refreshToken: "", + idToken: nil, + accountId: nil, + lastRefresh: nil, + expiresAt: Date().addingTimeInterval(-1), + source: .legacyCodexHome) + + let error = await #expect(throws: CodexOAuthCredentialsError.self) { + try await CodexOAuthFetchStrategy._prepareCredentialsForTesting(credentials) + } + guard case .readOnlySource = error else { + Issue.record("Expired external credentials without a refresh token must fail closed") + return + } + } + + @Test + func `expired external oauth fetch fails closed without mutating its source`() async throws { + let home = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-oauth-stale-fetch-home-\(UUID().uuidString)", isDirectory: true) + let dataHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-oauth-stale-fetch-data-\(UUID().uuidString)", isDirectory: true) + defer { + try? FileManager.default.removeItem(at: home) + try? FileManager.default.removeItem(at: dataHome) + } + let openCodeDirectory = dataHome.appendingPathComponent("opencode", isDirectory: true) + try FileManager.default.createDirectory(at: openCodeDirectory, withIntermediateDirectories: true) + let authData = Data( + #"{"openai":{"type":"oauth","access":"expired-access","refresh":"external-refresh","expires":1}}"# + .utf8) + let authURL = openCodeDirectory.appendingPathComponent("auth.json") + try authData.write(to: authURL) + let credentials = try CodexOAuthCredentialsStore._loadForUsageForTesting( + env: ["XDG_DATA_HOME": dataHome.path], + homeDirectory: home, + allowExternalSources: true) + let error = await #expect(throws: CodexOAuthCredentialsError.self) { + try await CodexOAuthFetchStrategy._prepareCredentialsForTesting( + credentials, + env: ["XDG_DATA_HOME": dataHome.path]) + } + guard case .readOnlySource = error else { + Issue.record("Expired external credentials must fail closed") + return + } + #expect(try Data(contentsOf: authURL) == authData) + } + + @Test + func `valid read-only oauth credentials pass through without refresh`() async throws { + let credentials = CodexOAuthCredentials( + accessToken: "valid-access", + refreshToken: "external-refresh", + idToken: nil, + accountId: nil, + lastRefresh: Date(), + expiresAt: Date().addingTimeInterval(3600), + source: .openCode) + let resolved = try await CodexOAuthFetchStrategy._prepareCredentialsForTesting(credentials) + + #expect(resolved.accessToken == "valid-access") + #expect(resolved.source == .openCode) + } + + @Test + func `managed workspace metadata supplies the account header without rewriting auth`() async throws { + let credentials = CodexOAuthCredentials( + accessToken: "valid-access", + refreshToken: "refresh", + idToken: nil, + accountId: "auth-account", + lastRefresh: Date(), + source: .codexHome) + let settings = ProviderSettingsSnapshot.make(codex: CodexProviderSettings( + usageDataSource: .oauth, + cookieSource: .off, + manualCookieHeader: nil, + managedWorkspaceAccountID: "workspace-team")) + let transport = ProviderHTTPTransportStub { request in + #expect(request.value(forHTTPHeaderField: "ChatGPT-Account-Id") == "workspace-team") + guard let url = request.url, + let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: nil) + else { throw URLError(.badURL) } + let body = #""" + {"rate_limit":{"primary_window":{"used_percent":4,"reset_at":1786161204,"limit_window_seconds":18000}}} + """# + return (Data(body.utf8), response) + } + + let result = try await CodexAuthenticatedHTTPTransport.$overrideForTesting.withValue(transport) { + try await CodexOAuthFetchStrategy._fetchForTesting( + context: Self.context(settings: settings), + credentials: credentials) + } + + #expect(result.usage.primary?.usedPercent == 4) + } + + @Test + func `expired native oauth credentials delegate refresh to the Codex CLI`() async throws { + let credentials = CodexOAuthCredentials( + accessToken: "expired-access", + refreshToken: "native-refresh", + idToken: nil, + accountId: nil, + lastRefresh: Date(timeIntervalSince1970: 0), + source: .codexHome) + let error = await #expect(throws: CodexOAuthCredentialsError.self) { + try await CodexOAuthFetchStrategy._prepareCredentialsForTesting( + credentials, + env: ["CODEX_HOME": "/tmp/codexbar-native-refresh-memory"]) + } + guard case .nativeRefreshRequired = error else { + Issue.record("Native stale credentials must be handed to Codex CLI") + return + } + } + + @Test + func `stale native probes never redeem a shared refresh token`() async throws { + let credentials = CodexOAuthCredentials( + accessToken: "expired-access", + refreshToken: "native-refresh", + idToken: nil, + accountId: nil, + lastRefresh: Date(timeIntervalSince1970: 0), + source: .codexHome) + let first = await #expect(throws: CodexOAuthCredentialsError.self) { + try await CodexOAuthFetchStrategy._prepareCredentialsForTesting(credentials) + } + let second = await #expect(throws: CodexOAuthCredentialsError.self) { + try await CodexOAuthFetchStrategy._prepareCredentialsForTesting(credentials) + } + guard case .nativeRefreshRequired = first, + case .nativeRefreshRequired = second + else { + Issue.record("Every stale native probe must hand refresh to Codex CLI") + return + } + } + + @Test + func `consented external OAuth fetch uses the token without mutating its source`() async throws { + let home = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-oauth-fetch-home-\(UUID().uuidString)", isDirectory: true) + let dataHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-oauth-fetch-data-\(UUID().uuidString)", isDirectory: true) + defer { + try? FileManager.default.removeItem(at: home) + try? FileManager.default.removeItem(at: dataHome) + } + let openCodeDirectory = dataHome.appendingPathComponent("opencode", isDirectory: true) + try FileManager.default.createDirectory(at: openCodeDirectory, withIntermediateDirectories: true) + let authData = Data(#""" + {"openai":{"type":"oauth","access":"external-access","refresh":"external-refresh","expires":4102444800000}} + """#.utf8) + let authURL = openCodeDirectory.appendingPathComponent("auth.json") + try authData.write(to: authURL) + + let credentials = try CodexOAuthCredentialsStore._loadForUsageForTesting( + env: ["XDG_DATA_HOME": dataHome.path], + homeDirectory: home, + allowExternalSources: true) + let settings = ProviderSettingsSnapshot.make(codex: CodexProviderSettings( + usageDataSource: .oauth, + cookieSource: .off, + manualCookieHeader: nil, + allowExternalOAuthSources: true)) + let transport = ProviderHTTPTransportStub { request in + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer external-access") + guard let url = request.url, + let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: nil) + else { + throw URLError(.badURL) + } + let body = #""" + {"rate_limit":{"primary_window":{"used_percent":12,"reset_at":1786161204, + "limit_window_seconds":18000},"secondary_window":null}} + """# + return (Data(body.utf8), response) + } + + let result = try await CodexAuthenticatedHTTPTransport.$overrideForTesting + .withValue(transport) { + try await CodexOAuthFetchStrategy._fetchForTesting( + context: Self.context( + env: ["XDG_DATA_HOME": dataHome.path], + settings: settings), + credentials: credentials) + } + + #expect(result.usage.primary?.usedPercent == 12) + #expect(try Data(contentsOf: authURL) == authData) + } + + @Test + func `open code api credentials are not accepted as oauth`() throws { + let payload: [String: Any] = [ + "openai": [ + "type": "api", + "key": "open-code-api-key", + ], + ] + let data = try JSONSerialization.data(withJSONObject: payload) + let error = #expect(throws: CodexOAuthCredentialsError.self) { + try CodexOAuthCredentialsStore._parseOpenCodeForTesting(data: data) + } + guard case .missingTokens = error else { + Issue.record("OpenCode API-key entries must not be treated as OAuth credentials") + return + } + } + + @Test + func `credential save preserves the supplied refresh timestamp`() throws { + let home = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-oauth-save-timestamp-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: home) } + let timestamp = Date(timeIntervalSince1970: 1_700_000_000) + try CodexOAuthCredentialsStore.save( + CodexOAuthCredentials( + accessToken: "access", + refreshToken: "refresh", + idToken: nil, + accountId: "account", + lastRefresh: timestamp), + env: ["CODEX_HOME": home.path]) + + let data = try Data(contentsOf: home.appendingPathComponent("auth.json")) + let json = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any]) + let rawTimestamp = try #require(json["last_refresh"] as? String) + let parsed = try #require(ISO8601DateFormatter().date(from: rawTimestamp)) + #expect(abs(parsed.timeIntervalSince(timestamp)) < 0.001) + } + + private static func context( + env: [String: String] = [:], + settings: ProviderSettingsSnapshot? = nil) -> ProviderFetchContext + { + let browserDetection = BrowserDetection(cacheTTL: 0) + return ProviderFetchContext( + runtime: .app, + sourceMode: .oauth, + includeCredits: false, + webTimeout: 60, + webDebugDumpHTML: false, + verbose: false, + env: env, + settings: settings, + fetcher: UsageFetcher(environment: env), + claudeFetcher: ClaudeUsageFetcher(browserDetection: browserDetection), + browserDetection: browserDetection) + } + + @Test + func `native codex home wins over external oauth sources`() throws { + let home = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-oauth-native-home-\(UUID().uuidString)", isDirectory: true) + let dataHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-oauth-native-data-\(UUID().uuidString)", isDirectory: true) + defer { + try? FileManager.default.removeItem(at: home) + try? FileManager.default.removeItem(at: dataHome) + } + + let nativeDirectory = home.appendingPathComponent(".codex", isDirectory: true) + let openCodeDirectory = dataHome.appendingPathComponent("opencode", isDirectory: true) + try FileManager.default.createDirectory(at: nativeDirectory, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: openCodeDirectory, withIntermediateDirectories: true) + + let native = """ + { + "tokens": { + "access_token": "native-access", + "refresh_token": "native-refresh" + } + } + """ + try Data(native.utf8).write(to: nativeDirectory.appendingPathComponent("auth.json")) + let external: [String: Any] = [ + "openai": ["type": "oauth", "access": "external-access"], + ] + try JSONSerialization.data(withJSONObject: external) + .write(to: openCodeDirectory.appendingPathComponent("auth.json")) + + let credentials = try CodexOAuthCredentialsStore._loadForUsageForTesting( + env: ["XDG_DATA_HOME": dataHome.path], + homeDirectory: home, + allowExternalSources: true) + + #expect(credentials.source == .codexHome) + #expect(credentials.accessToken == "native-access") + } + + @Test + func `legacy codex home wins before open code fallback`() throws { + let home = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-oauth-legacy-home-\(UUID().uuidString)", isDirectory: true) + let dataHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-oauth-legacy-data-\(UUID().uuidString)", isDirectory: true) + defer { + try? FileManager.default.removeItem(at: home) + try? FileManager.default.removeItem(at: dataHome) + } + + let legacyDirectory = home + .appendingPathComponent(".config", isDirectory: true) + .appendingPathComponent("codex", isDirectory: true) + let openCodeDirectory = dataHome.appendingPathComponent("opencode", isDirectory: true) + try FileManager.default.createDirectory(at: legacyDirectory, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: openCodeDirectory, withIntermediateDirectories: true) + + let legacy = """ + { + "tokens": { + "access_token": "legacy-access", + "refresh_token": "legacy-refresh" + } + } + """ + try Data(legacy.utf8).write(to: legacyDirectory.appendingPathComponent("auth.json")) + let external: [String: Any] = [ + "openai": ["type": "oauth", "access": "external-access"], + ] + try JSONSerialization.data(withJSONObject: external) + .write(to: openCodeDirectory.appendingPathComponent("auth.json")) + + let credentials = try CodexOAuthCredentialsStore._loadForUsageForTesting( + env: ["XDG_DATA_HOME": dataHome.path], + homeDirectory: home, + allowExternalSources: true) + + #expect(credentials.source == .legacyCodexHome) + #expect(credentials.accessToken == "legacy-access") + } + + @Test + func `legacy API keys are rejected by the external OAuth fallback`() throws { + let home = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-oauth-legacy-api-key-home-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: home) } + let legacyDirectory = home + .appendingPathComponent(".config", isDirectory: true) + .appendingPathComponent("codex", isDirectory: true) + try FileManager.default.createDirectory(at: legacyDirectory, withIntermediateDirectories: true) + let legacy = #"{"OPENAI_API_KEY":"legacy-api-key"}"# + try Data(legacy.utf8).write(to: legacyDirectory.appendingPathComponent("auth.json")) + + let error = #expect(throws: CodexOAuthCredentialsError.self) { + try CodexOAuthCredentialsStore._loadForUsageForTesting( + env: [:], + homeDirectory: home, + allowExternalSources: true) + } + guard case .notFound = error else { + Issue.record("External legacy API keys must not be treated as OAuth credentials") + return + } + } + + @Test + func `usage credential loading falls back to isolated open code data`() throws { + let home = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-oauth-fallback-home-\(UUID().uuidString)", isDirectory: true) + let dataHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-oauth-fallback-data-\(UUID().uuidString)", isDirectory: true) + defer { + try? FileManager.default.removeItem(at: home) + try? FileManager.default.removeItem(at: dataHome) + } + let openCodeDirectory = dataHome.appendingPathComponent("opencode", isDirectory: true) + try FileManager.default.createDirectory(at: openCodeDirectory, withIntermediateDirectories: true) + let payload: [String: Any] = [ + "openai": [ + "type": "oauth", + "access": "fallback-access", + "refresh": "fallback-refresh", + "expires": Int(Date().addingTimeInterval(3600).timeIntervalSince1970 * 1000), + ], + ] + let data = try JSONSerialization.data(withJSONObject: payload) + try data.write(to: openCodeDirectory.appendingPathComponent("auth.json")) + + let credentials = try CodexOAuthCredentialsStore._loadForUsageForTesting( + env: ["XDG_DATA_HOME": dataHome.path], + homeDirectory: home, + allowExternalSources: true) + + #expect(credentials.source == .openCode) + #expect(credentials.accessToken == "fallback-access") + } + + @Test + func `external fallback does not mask an unreadable native auth file`() throws { + let home = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-oauth-unreadable-native-\(UUID().uuidString)", isDirectory: true) + let dataHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-oauth-unreadable-external-\(UUID().uuidString)", isDirectory: true) + defer { + try? FileManager.default.removeItem(at: home) + try? FileManager.default.removeItem(at: dataHome) + } + let nativeDirectory = home.appendingPathComponent(".codex", isDirectory: true) + let openCodeDirectory = dataHome.appendingPathComponent("opencode", isDirectory: true) + try FileManager.default.createDirectory(at: nativeDirectory, withIntermediateDirectories: true) + try FileManager.default.createDirectory( + at: nativeDirectory.appendingPathComponent("auth.json"), + withIntermediateDirectories: false) + try FileManager.default.createDirectory(at: openCodeDirectory, withIntermediateDirectories: true) + try Data(#"{"openai":{"type":"oauth","access":"must-not-mask-native-read-error"}}"#.utf8) + .write(to: openCodeDirectory.appendingPathComponent("auth.json")) + + let error = #expect(throws: CodexOAuthCredentialsError.self) { + try CodexOAuthCredentialsStore._loadForUsageForTesting( + env: ["XDG_DATA_HOME": dataHome.path], + homeDirectory: home, + allowExternalSources: true) + } + guard case .unreadable = error else { + Issue.record("An unreadable native file must not be replaced by another app's OAuth session") + return + } + } + + @Test + func `external fallback does not mask malformed native auth json`() throws { + let home = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-oauth-malformed-native-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: home) } + let nativeDirectory = home.appendingPathComponent(".codex", isDirectory: true) + try FileManager.default.createDirectory(at: nativeDirectory, withIntermediateDirectories: true) + try Data("not-json".utf8).write(to: nativeDirectory.appendingPathComponent("auth.json")) + + let error = #expect(throws: CodexOAuthCredentialsError.self) { + try CodexOAuthCredentialsStore._loadForUsageForTesting( + env: [:], + homeDirectory: home, + allowExternalSources: true) + } + guard case .decodeFailed = error else { + Issue.record("Malformed native auth JSON must remain a decode failure") + return + } + } + + @Test + func `external fallback does not mask native auth without oauth tokens`() throws { + let home = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-oauth-missing-tokens-native-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: home) } + let nativeDirectory = home.appendingPathComponent(".codex", isDirectory: true) + try FileManager.default.createDirectory(at: nativeDirectory, withIntermediateDirectories: true) + try Data(#"{"tokens":{}}"#.utf8).write(to: nativeDirectory.appendingPathComponent("auth.json")) + + let error = #expect(throws: CodexOAuthCredentialsError.self) { + try CodexOAuthCredentialsStore._loadForUsageForTesting( + env: [:], + homeDirectory: home, + allowExternalSources: true) + } + guard case .missingTokens = error else { + Issue.record("Native auth without OAuth tokens must not silently borrow another source") + return + } + } + + @Test + func `external OAuth fallback is disabled without explicit consent`() throws { + let home = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-oauth-consent-home-\(UUID().uuidString)", isDirectory: true) + let dataHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-oauth-consent-data-\(UUID().uuidString)", isDirectory: true) + defer { + try? FileManager.default.removeItem(at: home) + try? FileManager.default.removeItem(at: dataHome) + } + let openCodeDirectory = dataHome.appendingPathComponent("opencode", isDirectory: true) + try FileManager.default.createDirectory(at: openCodeDirectory, withIntermediateDirectories: true) + let payload: [String: Any] = [ + "openai": ["type": "oauth", "access": "must-not-be-read"], + ] + try JSONSerialization.data(withJSONObject: payload) + .write(to: openCodeDirectory.appendingPathComponent("auth.json")) + + let error = #expect(throws: CodexOAuthCredentialsError.self) { + try CodexOAuthCredentialsStore._loadForUsageForTesting( + env: ["XDG_DATA_HOME": dataHome.path], + homeDirectory: home) + } + guard case .notFound = error else { + Issue.record("External OAuth files require explicit consent before they are read") + return + } + } + + @Test + func `explicit codex home does not borrow open code credentials`() throws { + let home = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-oauth-isolated-home-\(UUID().uuidString)", isDirectory: true) + let dataHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-oauth-isolated-data-\(UUID().uuidString)", isDirectory: true) + defer { + try? FileManager.default.removeItem(at: home) + try? FileManager.default.removeItem(at: dataHome) + } + let openCodeDirectory = dataHome.appendingPathComponent("opencode", isDirectory: true) + try FileManager.default.createDirectory(at: openCodeDirectory, withIntermediateDirectories: true) + let payload: [String: Any] = [ + "openai": ["type": "oauth", "access": "should-not-be-used"], + ] + let data = try JSONSerialization.data(withJSONObject: payload) + try data.write(to: openCodeDirectory.appendingPathComponent("auth.json")) + + let error = #expect(throws: CodexOAuthCredentialsError.self) { + try CodexOAuthCredentialsStore._loadForUsageForTesting( + env: ["CODEX_HOME": home.path, "XDG_DATA_HOME": dataHome.path], + homeDirectory: home) + } + guard case .notFound = error else { + Issue.record("An explicit CODEX_HOME must not borrow an OpenCode credential") + return + } + } + + private static func jwt(payload: [String: Any]) -> String { + let encode: (Data) -> String = { data in + data.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } + let header = encode(Data(#"{"alg":"none","typ":"JWT"}"#.utf8)) + let body = (try? JSONSerialization.data(withJSONObject: payload)).map(encode) ?? "" + return "\(header).\(body).signature" + } +} diff --git a/Tests/CodexBarTests/CodexOAuthCreditLimitTests.swift b/Tests/CodexBarTests/CodexOAuthCreditLimitTests.swift index f2335f2ec6..a8c87d0036 100644 --- a/Tests/CodexBarTests/CodexOAuthCreditLimitTests.swift +++ b/Tests/CodexBarTests/CodexOAuthCreditLimitTests.swift @@ -25,9 +25,17 @@ struct CodexOAuthCreditLimitTests { private func makeContext( sourceMode: ProviderSourceMode = .auto, - includeCredits: Bool = true) -> ProviderFetchContext + includeCredits: Bool = true, + managedWorkspaceAccountID: String? = nil) -> ProviderFetchContext { let browserDetection = BrowserDetection(cacheTTL: 0) + let settings = managedWorkspaceAccountID.map { accountID in + ProviderSettingsSnapshot.make(codex: CodexProviderSettings( + usageDataSource: .auto, + cookieSource: .off, + manualCookieHeader: nil, + managedWorkspaceAccountID: accountID)) + } return ProviderFetchContext( runtime: .app, sourceMode: sourceMode, @@ -36,7 +44,7 @@ struct CodexOAuthCreditLimitTests { webDebugDumpHTML: false, verbose: false, env: [:], - settings: nil, + settings: settings, fetcher: UsageFetcher(), claudeFetcher: ClaudeUsageFetcher(browserDetection: browserDetection), browserDetection: browserDetection) @@ -257,6 +265,27 @@ struct CodexOAuthCreditLimitTests { #expect(result.credits?.codexCreditLimit == nil) } + @Test + func `managed workspace O auth does not mix in unscoped CLI monthly limit`() async throws { + let mappedOAuth = try CodexOAuthFetchStrategy._mapResultForTesting( + Data(self.oauthZeroCreditRateWindowJSON().utf8), + credentials: self.makeCredentials(), + sourceMode: .auto) + let oauthResult = self.replacingIdentity(mappedOAuth, email: "owner@example.com") + let cliResult = self.makeCLIResult( + credits: self.makeMonthlyLimitCredits(), + email: "owner@example.com") + + let result = try await CodexOAuthFetchStrategy._replaceWithCLIMonthlyLimitForTesting( + oauthResult: oauthResult, + context: self.makeContext( + sourceMode: .auto, + managedWorkspaceAccountID: "workspace-team"), + cliStrategy: StubFetchStrategy(available: true, result: cliResult)) + + #expect(result.credits?.codexCreditLimit == nil) + } + @Test func `auto O auth zero credits rejects CLI monthly limit without verified identity`() async throws { let mappedOAuth = try CodexOAuthFetchStrategy._mapResultForTesting( diff --git a/Tests/CodexBarTests/CodexOAuthManagedWorkspaceRecoveryTests.swift b/Tests/CodexBarTests/CodexOAuthManagedWorkspaceRecoveryTests.swift new file mode 100644 index 0000000000..2b1b388c53 --- /dev/null +++ b/Tests/CodexBarTests/CodexOAuthManagedWorkspaceRecoveryTests.swift @@ -0,0 +1,59 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct CodexOAuthManagedWorkspaceRecoveryTests { + @Test + func `automatic mode does not expose unscoped CLI fallback for a managed workspace`() async { + let context = self.makeContext(sourceMode: .auto) + let strategies = await CodexProviderDescriptor.descriptor.fetchPlan.pipeline.resolveStrategies(context) + + #expect(strategies.map(\.id) == ["codex.oauth"]) + } + + @Test + func `native refresh recovery is unavailable when managed workspace scope is selected`() async throws { + let home = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-native-refresh-managed-workspace-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: home, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: home) } + try CodexOAuthCredentialsStore.save( + CodexOAuthCredentials( + accessToken: "access-token", + refreshToken: "refresh-token", + idToken: nil, + accountId: "auth-account", + lastRefresh: Date(timeIntervalSinceNow: -(9 * 24 * 60 * 60))), + env: ["CODEX_HOME": home.path]) + + let context = self.makeContext(sourceMode: .oauth, env: ["CODEX_HOME": home.path]) + + let isAvailable = await CodexOAuthNativeRefreshCLIStrategy(binaryResolver: { _ in "/usr/bin/codex" }) + .isAvailable(context) + #expect(!isAvailable) + } + + private func makeContext( + sourceMode: ProviderSourceMode, + env: [String: String] = [:]) -> ProviderFetchContext + { + let browserDetection = BrowserDetection(cacheTTL: 0) + let settings = ProviderSettingsSnapshot.make(codex: CodexProviderSettings( + usageDataSource: sourceMode == .auto ? .auto : .oauth, + cookieSource: .off, + manualCookieHeader: nil, + managedWorkspaceAccountID: "workspace-team")) + return ProviderFetchContext( + runtime: .app, + sourceMode: sourceMode, + includeCredits: false, + webTimeout: 60, + webDebugDumpHTML: false, + verbose: false, + env: env, + settings: settings, + fetcher: UsageFetcher(environment: env), + claudeFetcher: ClaudeUsageFetcher(browserDetection: browserDetection), + browserDetection: browserDetection) + } +} diff --git a/Tests/CodexBarTests/CodexOAuthResetCreditFetchTests.swift b/Tests/CodexBarTests/CodexOAuthResetCreditFetchTests.swift index 356c1a5a50..504d9fecb4 100644 --- a/Tests/CodexBarTests/CodexOAuthResetCreditFetchTests.swift +++ b/Tests/CodexBarTests/CodexOAuthResetCreditFetchTests.swift @@ -19,7 +19,7 @@ struct CodexOAuthResetCreditFetchTests { } @Test - func `app defers reset credit GET while CLI attempts it once on failure`() async throws { + func `app uses winning OAuth credentials while CLI follows credits flag`() async throws { let credentials = Self.credentials() let recorder = CodexOAuthResetCreditFetchRecorder() let fetcher: @Sendable (CodexOAuthCredentials) async throws -> CodexRateLimitResetCreditsSnapshot = { _ in @@ -32,14 +32,14 @@ struct CodexOAuthResetCreditFetchTests { credentials: credentials, fetcher: fetcher) #expect(appResult == nil) - #expect(await recorder.requestCount() == 0) + #expect(await recorder.requestCount() == 1) let cliResult = try await CodexOAuthFetchStrategy._fetchResetCreditsForTesting( context: Self.context(runtime: .cli), credentials: credentials, fetcher: fetcher) #expect(cliResult == nil) - #expect(await recorder.requestCount() == 1) + #expect(await recorder.requestCount() == 2) } @Test @@ -107,12 +107,12 @@ struct CodexOAuthResetCreditFetchTests { } @Test - func `O auth strategy defers app inventory and CLI follows credits flag`() { + func `O auth strategy fetches app inventory and CLI follows credits flag`() { let appContext = Self.context(runtime: .app, includeCredits: false, includeOptionalUsage: false) let cliNoCreditsContext = Self.context(runtime: .cli, includeCredits: false, includeOptionalUsage: true) let cliCreditsContext = Self.context(runtime: .cli, includeCredits: true, includeOptionalUsage: false) - #expect(CodexOAuthFetchStrategy._shouldFetchResetCreditsForTesting(appContext) == false) + #expect(CodexOAuthFetchStrategy._shouldFetchResetCreditsForTesting(appContext)) #expect(CodexOAuthFetchStrategy._shouldFetchResetCreditsForTesting(cliNoCreditsContext) == false) #expect(CodexOAuthFetchStrategy._shouldFetchResetCreditsForTesting(cliCreditsContext)) } diff --git a/Tests/CodexBarTests/CodexOAuthTests.swift b/Tests/CodexBarTests/CodexOAuthTests.swift index d6a338b038..01a332f3ae 100644 --- a/Tests/CodexBarTests/CodexOAuthTests.swift +++ b/Tests/CodexBarTests/CodexOAuthTests.swift @@ -81,6 +81,8 @@ struct CodexOAuthTests { #expect(creds.refreshToken.isEmpty) #expect(creds.idToken == nil) #expect(creds.accountId == nil) + #expect(creds.isAPIKey) + #expect(!creds.needsRefresh) } @Test @@ -736,7 +738,9 @@ struct CodexOAuthTests { #expect(strategy.shouldFallback(on: CodexOAuthFetchError.unauthorized, context: context)) #expect(strategy.shouldFallback(on: CodexOAuthCredentialsError.notFound, context: context)) + #expect(strategy.shouldFallback(on: CodexOAuthCredentialsError.unreadable, context: context)) #expect(strategy.shouldFallback(on: CodexOAuthCredentialsError.missingTokens, context: context)) + #expect(!strategy.shouldFallback(on: CodexOAuthCredentialsError.readOnlySource, context: context)) #expect(strategy.shouldFallback(on: CodexTokenRefresher.RefreshError.expired, context: context)) #expect(strategy.shouldFallback(on: CodexTokenRefresher.RefreshError.revoked, context: context)) #expect(strategy.shouldFallback(on: CodexTokenRefresher.RefreshError.reused, context: context)) @@ -780,14 +784,66 @@ struct CodexOAuthTests { } @Test - func `explicit O auth mode never falls back to CLI`() { + func `explicit O auth mode only falls back to CLI for native refresh recovery`() { let strategy = CodexOAuthFetchStrategy() let context = self.makeContext(sourceMode: .oauth) #expect(!strategy.shouldFallback(on: CodexOAuthFetchError.unauthorized, context: context)) + #expect(strategy.shouldFallback(on: CodexOAuthCredentialsError.nativeRefreshRequired, context: context)) + #expect(!strategy.shouldFallback(on: CodexOAuthCredentialsError.readOnlySource, context: context)) #expect(!strategy.shouldFallback(on: CodexTokenRefresher.RefreshError.expired, context: context)) } + @Test + func `credential recovery errors direct users to codex login`() { + #expect(CodexOAuthCredentialsError.nativeRefreshRequired.localizedDescription.contains("codex login")) + #expect(CodexOAuthCredentialsError.readOnlySource.localizedDescription.contains("codex login")) + #expect(CodexOAuthFetchError.unauthorized.localizedDescription.contains("codex login")) + } + + @Test + func `explicit O auth mode includes CLI recovery after native credentials expire`() async { + let context = self.makeContext(sourceMode: .oauth) + let strategies = await CodexProviderDescriptor.descriptor.fetchPlan.pipeline.resolveStrategies(context) + + #expect(strategies.map(\.id) == ["codex.oauth", "codex.oauth-native-refresh-cli"]) + } + + @Test + func `native refresh recovery is unavailable when Codex CLI is missing`() async throws { + let home = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-native-refresh-no-cli-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: home, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: home) } + try CodexOAuthCredentialsStore.save( + CodexOAuthCredentials( + accessToken: "access-token", + refreshToken: "refresh-token", + idToken: nil, + accountId: "account-id", + lastRefresh: Date(timeIntervalSinceNow: -(9 * 24 * 60 * 60))), + env: ["CODEX_HOME": home.path]) + + var context = self.makeContext(sourceMode: .oauth) + context = ProviderFetchContext( + runtime: context.runtime, + sourceMode: context.sourceMode, + includeCredits: context.includeCredits, + includeOptionalUsage: context.includeOptionalUsage, + webTimeout: context.webTimeout, + webDebugDumpHTML: context.webDebugDumpHTML, + verbose: context.verbose, + env: ["CODEX_HOME": home.path, "CODEX_CLI_PATH": "/missing/codex"], + settings: context.settings, + fetcher: context.fetcher, + claudeFetcher: context.claudeFetcher, + browserDetection: context.browserDetection) + + let isAvailable = await CodexOAuthNativeRefreshCLIStrategy(binaryResolver: { _ in nil }) + .isAvailable(context) + #expect(!isAvailable) + } + @Test func `resolves chat GPT usage URL from config`() { let config = "chatgpt_base_url = \"https://chatgpt.com/backend-api/\"\n" diff --git a/Tests/CodexBarTests/CodexResetCreditOutcomeTests.swift b/Tests/CodexBarTests/CodexResetCreditOutcomeTests.swift index 6aae6f23e2..231478428a 100644 --- a/Tests/CodexBarTests/CodexResetCreditOutcomeTests.swift +++ b/Tests/CodexBarTests/CodexResetCreditOutcomeTests.swift @@ -38,6 +38,22 @@ struct CodexResetCreditOutcomeTests { #expect(await recorder.lastEnvironment()["CODEX_HOME"] == "/tmp/account-a") } + @Test + func `supplemental inventory uses the selected managed workspace identity`() async throws { + let recorder = ResetCreditRequestRecorder() + let result = try await UsageStore._fetchCodexResetCreditsForTesting( + credentials: Self.credentials(lastRefresh: Date()), + env: ["CODEX_HOME": "/tmp/account-a"], + workspaceAccountID: "workspace-team", + request: { accessToken, accountID, environment in + await recorder.record(accessToken: accessToken, accountID: accountID, environment: environment) + return Self.resetSnapshot(id: "workspace-team", now: Date()) + }) + + #expect(result != nil) + #expect(await recorder.lastAccountID() == "workspace-team") + } + @Test func `embedded OAuth inventory prevents a duplicate supplemental GET`() async throws { let now = Date(timeIntervalSince1970: 1_781_726_400) @@ -102,11 +118,16 @@ struct CodexResetCreditOutcomeTests { } @Test - func `single failed GET restores failure for reset-credit-only O auth usage`() async { + func `OAuth reset-credit-only usage fails without rereading auth after its snapshot attempt`() async { let now = Date(timeIntervalSince1970: 1_781_726_400) let recorder = ResetCreditFetchRecorder() let outcome = await UsageStore.attachingCodexResetCreditsIfNeeded( - to: Self.outcome(resetCredits: nil, now: now, primary: nil, strategyID: "codex.oauth"), + to: Self.outcome( + resetCredits: nil, + now: now, + primary: nil, + strategyID: "codex.oauth", + codexResetCreditsAttempted: true), env: ["CODEX_HOME": "/tmp/account-a"], fetcher: { env in await recorder.record(env) @@ -118,7 +139,33 @@ struct CodexResetCreditOutcomeTests { return } #expect(error is UsageError) - #expect(await recorder.environments().count == 1) + #expect(await recorder.environments().isEmpty) + } + + @Test + func `OAuth reset-credit failure never triggers a generic auth reload`() async throws { + let now = Date(timeIntervalSince1970: 1_781_726_400) + let recorder = ResetCreditFetchRecorder() + let primary = RateWindow( + usedPercent: 25, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil) + let outcome = await UsageStore.attachingCodexResetCreditsIfNeeded( + to: Self.outcome( + resetCredits: nil, + now: now, + primary: primary, + strategyID: "codex.oauth", + codexResetCreditsAttempted: true), + env: ["CODEX_HOME": "/tmp/account-a"], + fetcher: { env in + await recorder.record(env) + throw ResetCreditFetchTestError.failed + }) + + #expect(try Self.usage(from: outcome).codexResetCredits == nil) + #expect(await recorder.environments().isEmpty) } @Test @@ -173,7 +220,8 @@ struct CodexResetCreditOutcomeTests { resetCredits: CodexRateLimitResetCreditsSnapshot?, now: Date, primary: RateWindow? = nil, - strategyID: String = "test") -> ProviderFetchOutcome + strategyID: String = "test", + codexResetCreditsAttempted: Bool = false) -> ProviderFetchOutcome { let resolvedPrimary = strategyID == "codex.oauth" ? primary : primary ?? RateWindow( usedPercent: 25, @@ -191,7 +239,8 @@ struct CodexResetCreditOutcomeTests { dashboard: nil, sourceLabel: "test", strategyID: strategyID, - strategyKind: .cli)), + strategyKind: .cli, + codexResetCreditsAttempted: codexResetCreditsAttempted)), attempts: []) } diff --git a/Tests/CodexBarTests/ManagedCodexAccountServiceTests.swift b/Tests/CodexBarTests/ManagedCodexAccountServiceTests.swift index 44ba8a5e1a..c627f35a73 100644 --- a/Tests/CodexBarTests/ManagedCodexAccountServiceTests.swift +++ b/Tests/CodexBarTests/ManagedCodexAccountServiceTests.swift @@ -204,7 +204,9 @@ struct ManagedCodexAccountServiceTests { #expect(account.providerAccountID == "workspace-team") #expect(account.workspaceLabel == "Team") - #expect(credentials.accountId == "workspace-team") + // Workspace selection is CodexBar-owned metadata; the Codex CLI auth file remains untouched. + #expect(credentials.accountId == "workspace-personal") + #expect(store.snapshot.account(id: account.id)?.workspaceAccountID == "workspace-team") #expect(store.snapshot.accounts.count == 1) } diff --git a/Tests/CodexBarTests/ManagedCodexAccountStoreTests.swift b/Tests/CodexBarTests/ManagedCodexAccountStoreTests.swift index 274761cf53..0b1568c9d8 100644 --- a/Tests/CodexBarTests/ManagedCodexAccountStoreTests.swift +++ b/Tests/CodexBarTests/ManagedCodexAccountStoreTests.swift @@ -47,6 +47,9 @@ func `FileManagedCodexAccountStore round trip`() throws { #expect(contents.contains("\n \"accounts\"")) #expect(accountsRange.lowerBound < versionRange.lowerBound) #expect(contents.contains("\"activeAccountID\"") == false) + let attributes = try FileManager.default.attributesOfItem(atPath: fileURL.path) + let permissions = (attributes[.posixPermissions] as? NSNumber)?.uint16Value + #expect(permissions.map { $0 & 0o077 } == 0) } @Test diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index e03bd74add..624608233e 100644 --- a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift +++ b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift @@ -1148,49 +1148,49 @@ struct ProviderArchitectureGatekeeperTests { reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+TokenAccounts.swift", - line: 1291, + line: 1292, 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: 1379, + line: 1380, 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: 1383, + line: 1384, 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: 1386, + line: 1387, 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: 1396, + line: 1397, 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: 1399, + line: 1400, 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: 1419, + line: 1420, 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: 1453, + line: 1454, anchor: "from: self.presentationSnapshot(for: .deepseek))", expectedProviderIDs: ["deepseek"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), @@ -2281,7 +2281,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/SettingsStore.swift", - line: 1048, + line: 1054, anchor: "if !seen.contains(.factory), let zaiIndex = ordered.firstIndex(of: .zai) {", expectedProviderIDs: ["factory", "minimax", "zai"], expectedReferenceCount: 8, @@ -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: 318, + line: 319, 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: 739, + line: 740, anchor: "guard provider == .codex else { return outcome }", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -2947,7 +2947,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: 927, + line: 928, 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: 969, + line: 970, 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: 1069, + line: 1070, 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: 1085, + line: 1086, 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: 1292, + line: 1293, 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: 1370, + line: 1371, 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: 1412, + line: 1413, anchor: "self.lastFetchAttempts[.codex] = outcome.attempts", expectedProviderIDs: ["codex"], expectedReferenceCount: 5, @@ -3013,7 +3013,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: 1451, + line: 1452, anchor: "let profileStable = provider == .deepseek", expectedProviderIDs: ["deepseek"], expectedReferenceCount: 1, @@ -3021,7 +3021,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: 1466, + line: 1467, 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: 1487, + line: 1488, 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: 1512, + line: 1513, anchor: "if provider == .deepseek {", expectedProviderIDs: ["deepseek"], expectedReferenceCount: 1, @@ -3572,7 +3572,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared provider integration dispatches a capability owned by the provider descriptor or adapter."), AllowedProviderConstruct( path: "Sources/CodexBarCore/Providers/ProviderFetchPlan.swift", - line: 194, + line: 200, anchor: "if provider == .kiro {", expectedProviderIDs: ["kiro"], expectedReferenceCount: 1, diff --git a/docs/codex-oauth.md b/docs/codex-oauth.md index 85211d9d1a..8a56855b0f 100644 --- a/docs/codex-oauth.md +++ b/docs/codex-oauth.md @@ -1,14 +1,15 @@ --- -summary: "Codex OAuth resolver: tokens, refresh, usage endpoint, and fetch strategy wiring." +summary: "Codex OAuth resolver: read-only tokens, CLI-owned refresh, usage endpoint, and fetch strategy wiring." read_when: - Adding or modifying Codex OAuth usage fetching - Debugging auth.json parsing or token refresh behavior - Adjusting Codex provider source selection --- -# Codex OAuth Resolver Implementation Plan +# Codex OAuth resolver -> Replicate Codex's direct OAuth token usage in CodexBar instead of calling the CLI. +> Read Codex's OAuth tokens for usage in CodexBar while leaving refresh and persistence to the +> Codex CLI that owns `auth.json`. ## Background @@ -17,7 +18,8 @@ Currently, CodexBar fetches Codex usage by: 2. Sending `/status` command 3. Parsing the text output -This is slow and unreliable. The goal is to directly read Codex's OAuth tokens and call the same API endpoints that Codex uses internally. +This is slow and unreliable. CodexBar now reads OAuth tokens for usage and calls the same API +endpoints that Codex uses internally, while stale native credentials are recovered by the CLI. --- @@ -42,32 +44,20 @@ This is slow and unreliable. The goal is to directly read Codex's OAuth tokens a **Source:** `codex-rs/core/src/auth/storage.rs` -### Token Refresh +### Token Freshness and Ownership -**Endpoint:** `POST https://auth.openai.com/oauth/token` +Codex CLI owns the refresh endpoint and the refresh-token lifecycle for the native +`CODEX_HOME/auth.json`. CodexBar may inspect `last_refresh` to decide whether a usage snapshot is +stale, but its usage path must not redeem that token or write a replacement file. Instead: -**Request:** -```json -{ - "client_id": "app_EMoamEEZ73f0CkXaXp7hrann", - "grant_type": "refresh_token", - "refresh_token": "", - "scope": "openid profile email" -} -``` +- stale native credentials produce `nativeRefreshRequired` and route to Codex CLI recovery; +- stale legacy/OpenCode credentials produce `readOnlySource` and fail closed because there is no + safe writer handoff; and +- `CodexTokenRefresher` is not part of the shared-file usage path. -**Response:** -```json -{ - "id_token": "eyJ...", - "access_token": "eyJ...", - "refresh_token": "..." -} -``` - -**Refresh Interval:** 8 days (from `TOKEN_REFRESH_INTERVAL` constant) - -**Source:** `codex-rs/core/src/auth.rs:504-545` +The Codex CLI refresh interval is 8 days (from `TOKEN_REFRESH_INTERVAL`); the source reference is +`codex-rs/core/src/auth.rs:504-545`. The refresh endpoint is documented here for ownership +context only, not as a CodexBar usage action. ### Usage API @@ -83,10 +73,9 @@ ChatGPT-Account-Id: User-Agent: codex-cli ``` -**Quick checks** -- Command: `cat ~/.codex/auth.json` -- Command: `curl -H "Authorization: Bearer " -H "ChatGPT-Account-Id: " -H "User-Agent: codex-cli" https://chatgpt.com/backend-api/wham/usage` -- Command: `CodexBarCLI usage --provider codex --source oauth --json --pretty` +Use fixture credentials in an isolated `CODEX_HOME` for diagnostics. Do not print the native auth +file or put bearer tokens in shell history. The safe product-level check is +`CodexBarCLI usage --provider codex --source oauth --json --pretty` with an isolated environment. **Response:** ```json @@ -124,7 +113,7 @@ User-Agent: codex-cli |------|----------|---------| | `CodexOAuthCredentials.swift` | `Sources/CodexBarCore/Providers/Codex/CodexOAuth/` | Token storage model + loader | | `CodexOAuthUsageFetcher.swift` | `Sources/CodexBarCore/Providers/Codex/CodexOAuth/` | API client for usage endpoint | -| `CodexTokenRefresher.swift` | `Sources/CodexBarCore/Providers/Codex/CodexOAuth/` | Token refresh logic | +| `CodexTokenRefresher.swift` | `Sources/CodexBarCore/Providers/Codex/CodexOAuth/` | Refresh-error classification and isolated transport tests; not shared-file usage ownership | ### Files to Modify @@ -136,125 +125,19 @@ User-Agent: codex-cli ### Step 1: CodexOAuthCredentials.swift -```swift -import Foundation - -public struct CodexOAuthCredentials: Sendable { - public let accessToken: String - public let refreshToken: String - public let idToken: String? - public let accountId: String? - public let lastRefresh: Date? - - public var needsRefresh: Bool { - guard let last = lastRefresh else { return true } - let eightDays: TimeInterval = 8 * 24 * 3600 - return Date().timeIntervalSince(last) > eightDays - } -} - -public enum CodexOAuthCredentialsError: LocalizedError { - case notFound - case decodeFailed(String) - case missingTokens - - public var errorDescription: String? { - switch self { - case .notFound: - "Codex auth.json not found. Run `codex` to log in." - case .decodeFailed(let msg): - "Failed to decode Codex credentials: \(msg)" - case .missingTokens: - "Codex auth.json exists but contains no tokens." - } - } -} - -public enum CodexOAuthCredentialsStore { - private static var authFilePath: URL { - let home = FileManager.default.homeDirectoryForCurrentUser - // Respect CODEX_HOME if set - if let codexHome = ProcessInfo.processInfo.environment["CODEX_HOME"], - !codexHome.isEmpty { - return URL(fileURLWithPath: codexHome).appendingPathComponent("auth.json") - } - return home.appendingPathComponent(".codex/auth.json") - } - - public static func load() throws -> CodexOAuthCredentials { - let url = authFilePath - guard FileManager.default.fileExists(atPath: url.path) else { - throw CodexOAuthCredentialsError.notFound - } - - let data = try Data(contentsOf: url) - guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { - throw CodexOAuthCredentialsError.decodeFailed("Invalid JSON") - } - - // Check for API key auth (no tokens needed for refresh) - if let apiKey = json["OPENAI_API_KEY"] as? String, !apiKey.isEmpty { - return CodexOAuthCredentials( - accessToken: apiKey, - refreshToken: "", - idToken: nil, - accountId: nil, - lastRefresh: nil) - } - - guard let tokens = json["tokens"] as? [String: Any], - let accessToken = tokens["access_token"] as? String, - let refreshToken = tokens["refresh_token"] as? String else { - throw CodexOAuthCredentialsError.missingTokens - } - - let idToken = tokens["id_token"] as? String - let accountId = tokens["account_id"] as? String - - let lastRefresh: Date? = { - guard let str = json["last_refresh"] as? String else { return nil } - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - return formatter.date(from: str) ?? ISO8601DateFormatter().date(from: str) - }() - - return CodexOAuthCredentials( - accessToken: accessToken, - refreshToken: refreshToken, - idToken: idToken, - accountId: accountId, - lastRefresh: lastRefresh) - } - - public static func save(_ credentials: CodexOAuthCredentials) throws { - let url = authFilePath - - // Read existing file to preserve structure - var json: [String: Any] = [:] - if let data = try? Data(contentsOf: url), - let existing = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { - json = existing - } - - var tokens: [String: Any] = [ - "access_token": credentials.accessToken, - "refresh_token": credentials.refreshToken - ] - if let idToken = credentials.idToken { - tokens["id_token"] = idToken - } - if let accountId = credentials.accountId { - tokens["account_id"] = accountId - } - - json["tokens"] = tokens - json["last_refresh"] = ISO8601DateFormatter().string(from: Date()) +The credential store exposes a read-only usage contract: - let data = try JSONSerialization.data(withJSONObject: json, options: [.prettyPrinted, .sortedKeys]) - try data.write(to: url, options: .atomic) - } -} -``` +- `loadForUsage(env:allowExternalSources:)` gives native `CODEX_HOME/auth.json` precedence and + only considers legacy Codex/OpenCode files when the explicit external-source setting is on. +- Credentials carry their source, freshness, access token, account scope, and refresh metadata; + account IDs are normalized before JWT fallback is attempted. +- `load()` and `loadOAuthTokens()` are parsing entry points. They do not refresh a token or write a + file. +- `save(...)` is guarded by the credential source and rejects external files that cannot safely + persist refresh material. The usage strategy never calls it for shared auth refreshes; native + `auth.json` refresh remains Codex CLI-owned. +- Missing, malformed, stale-native, and stale-external states remain distinct so the provider can + choose CLI recovery or a fail-closed error without silently changing credential ownership. --- @@ -386,219 +269,37 @@ public enum CodexOAuthUsageFetcher { ### Step 3: CodexTokenRefresher.swift -```swift -import Foundation - -public enum CodexTokenRefresher { - private static let refreshEndpoint = URL(string: "https://auth.openai.com/oauth/token")! - private static let clientID = "app_EMoamEEZ73f0CkXaXp7hrann" - - public enum RefreshError: LocalizedError { - case expired - case revoked - case reused - case networkError(Error) - case invalidResponse(String) - - public var errorDescription: String? { - switch self { - case .expired: - "Refresh token expired. Please run `codex` to log in again." - case .revoked: - "Refresh token was revoked. Please run `codex` to log in again." - case .reused: - "Refresh token was already used. Please run `codex` to log in again." - case .networkError(let error): - "Network error during token refresh: \(error.localizedDescription)" - case .invalidResponse(let msg): - "Invalid refresh response: \(msg)" - } - } - } - - public static func refresh(_ credentials: CodexOAuthCredentials) async throws -> CodexOAuthCredentials { - guard !credentials.refreshToken.isEmpty else { - // API key auth - no refresh needed - return credentials - } - - var request = URLRequest(url: refreshEndpoint) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - - let body: [String: String] = [ - "client_id": clientID, - "grant_type": "refresh_token", - "refresh_token": credentials.refreshToken, - "scope": "openid profile email" - ] - request.httpBody = try JSONSerialization.data(withJSONObject: body) - - let (data, response): (Data, URLResponse) - do { - (data, response) = try await URLSession.shared.data(for: request) - } catch { - throw RefreshError.networkError(error) - } - - guard let http = response as? HTTPURLResponse else { - throw RefreshError.invalidResponse("No HTTP response") - } - - if http.statusCode == 401 { - // Parse error code to classify failure - if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let errorCode = (json["error"] as? [String: Any])?["code"] as? String - ?? json["error"] as? String - ?? json["code"] as? String { - switch errorCode.lowercased() { - case "refresh_token_expired": throw RefreshError.expired - case "refresh_token_reused": throw RefreshError.reused - case "refresh_token_invalidated": throw RefreshError.revoked - default: throw RefreshError.expired - } - } - throw RefreshError.expired - } - - guard http.statusCode == 200 else { - throw RefreshError.invalidResponse("Status \(http.statusCode)") - } - - guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { - throw RefreshError.invalidResponse("Invalid JSON") - } +The usage path must not call `CodexTokenRefresher.refresh` for a credential loaded from native, +legacy, or OpenCode auth files. The type is retained for refresh-error classification and isolated +transport tests, but ownership is handled as follows: - let newAccessToken = json["access_token"] as? String ?? credentials.accessToken - let newRefreshToken = json["refresh_token"] as? String ?? credentials.refreshToken - let newIdToken = json["id_token"] as? String ?? credentials.idToken - - return CodexOAuthCredentials( - accessToken: newAccessToken, - refreshToken: newRefreshToken, - idToken: newIdToken, - accountId: credentials.accountId, - lastRefresh: Date()) - } -} -``` +- `CodexOAuthFetchStrategy` throws `nativeRefreshRequired` for stale native credentials; +- `CodexOAuthNativeRefreshCLIStrategy` delegates that recovery to Codex CLI; +- stale external credentials throw `readOnlySource` and never reach a refresh request; and +- no refresh response is published back to a shared `auth.json` by CodexBar. --- ### Step 4: Update CodexProviderDescriptor.swift -Add OAuth to `sourceModes` and create new strategy: - -```swift -// In makeDescriptor(), update fetchPlan: -fetchPlan: ProviderFetchPlan( - sourceModes: [.auto, .oauth, .web, .cli], // Add .oauth - pipeline: ProviderFetchPipeline(resolveStrategies: self.resolveStrategies)), - -// Update resolveStrategies: -private static func resolveStrategies(context: ProviderFetchContext) async -> [any ProviderFetchStrategy] { - let oauth = CodexOAuthFetchStrategy() - let cli = CodexCLIUsageStrategy() - let web = CodexWebDashboardStrategy() - - switch context.sourceMode { - case .oauth: - return [oauth] - case .web: - return [web] - case .cli: - return [cli] - case .auto: - // OAuth first (fast), CLI fallback - if context.runtime == .cli { - return [web, cli] - } - return [oauth, cli] - } -} - -// Add new strategy: -struct CodexOAuthFetchStrategy: ProviderFetchStrategy { - let id: String = "codex.oauth" - let kind: ProviderFetchKind = .oauth - - func isAvailable(_ context: ProviderFetchContext) async -> Bool { - (try? CodexOAuthCredentialsStore.load()) != nil - } - - func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { - var creds = try CodexOAuthCredentialsStore.load() - - // Refresh if needed (8+ days old) - if creds.needsRefresh && !creds.refreshToken.isEmpty { - creds = try await CodexTokenRefresher.refresh(creds) - try CodexOAuthCredentialsStore.save(creds) - } - - let usage = try await CodexOAuthUsageFetcher.fetchUsage( - accessToken: creds.accessToken, - accountId: creds.accountId) - - return makeResult( - usage: Self.mapUsage(usage), - credits: Self.mapCredits(usage.credits), - sourceLabel: "oauth") - } - - func shouldFallback(on error: Error, context: ProviderFetchContext) -> Bool { - // Fallback to CLI on auth errors - if let fetchError = error as? CodexOAuthFetchError { - switch fetchError { - case .unauthorized: return true - default: return false - } - } - if error is CodexOAuthCredentialsError { return true } - if error is CodexTokenRefresher.RefreshError { return true } - return false - } - - private static func mapUsage(_ response: CodexUsageResponse) -> UsageSnapshot { - let primary: RateWindow? = response.rateLimit?.primaryWindow.map { window in - RateWindow( - usedPercent: Double(window.usedPercent), - windowMinutes: window.limitWindowSeconds / 60, - resetsAt: Date(timeIntervalSince1970: TimeInterval(window.resetAt)), - resetDescription: nil) - } - - let secondary: RateWindow? = response.rateLimit?.secondaryWindow.map { window in - RateWindow( - usedPercent: Double(window.usedPercent), - windowMinutes: window.limitWindowSeconds / 60, - resetsAt: Date(timeIntervalSince1970: TimeInterval(window.resetAt)), - resetDescription: nil) - } - - let identity = ProviderIdentitySnapshot( - providerID: .codex, - accountEmail: nil, - accountOrganization: nil, - loginMethod: response.planType.rawValue) - - return UsageSnapshot( - primary: primary ?? RateWindow(usedPercent: 0, windowMinutes: nil, resetsAt: nil, resetDescription: nil), - secondary: secondary, - tertiary: nil, - providerCost: nil, - updatedAt: Date(), - identity: identity) - } - - private static func mapCredits(_ credits: CodexUsageResponse.CreditDetails?) -> CreditsSnapshot? { - guard let credits else { return nil } - return CreditsSnapshot( - hasCredits: credits.hasCredits, - unlimited: credits.unlimited, - balance: credits.balance) - } -} -``` +The production strategy is source-aware. Keep the following flow in sync with the provider +implementation instead of copying an OAuth-only fetch example: + +1. `CodexOAuthCredentialsStore.loadForUsage` reads the ambient `CODEX_HOME` first. Legacy Codex + and OpenCode files are considered only when the explicit external-source setting is enabled. +2. `CodexOAuthFetchStrategy` uses that credential snapshot for the usage and reset-credit + requests. It never redeems or saves a refresh token from the usage path. +3. A stale native snapshot throws `CodexOAuthCredentialsError.nativeRefreshRequired`; the explicit + OAuth plan routes that state to `CodexOAuthNativeRefreshCLIStrategy`, which delegates recovery + to Codex CLI. A stale legacy/OpenCode snapshot throws `.readOnlySource` and fails closed because + there is no safe writer handoff. +4. Auto mode falls back to the CLI only for recoverable native OAuth or credential errors. Stale + external sources, managed workspace scope, transient API errors, decode failures, and network + failures remain visible instead of launching an unrelated or unscoped CLI recovery. + +The key invariant is that the credential snapshot used for the usage request is also passed to +reset-credit enrichment; reloading `auth.json` after a refresh would reintroduce the shared-file +race this design is intended to avoid. --- @@ -606,8 +307,8 @@ struct CodexOAuthFetchStrategy: ProviderFetchStrategy { | Constant | Value | Source | |----------|-------|--------| -| Client ID | `app_EMoamEEZ73f0CkXaXp7hrann` | `auth.rs:618` | -| Refresh URL | `https://auth.openai.com/oauth/token` | `auth.rs:66` | +| Refresh owner | Codex CLI | `auth.rs:66`, `auth.rs:618` | +| Refresh URL | `https://auth.openai.com/oauth/token` (CLI-owned; not a CodexBar usage action) | `auth.rs:66` | | Usage URL | `https://chatgpt.com/backend-api/wham/usage` (default) | `client.rs:163` | | Token refresh interval | 8 days | `auth.rs:59` | | Auth file | `~/.codex/auth.json` | `storage.rs` | @@ -616,11 +317,14 @@ struct CodexOAuthFetchStrategy: ProviderFetchStrategy { ## Testing -1. Ensure `~/.codex/auth.json` exists (run `codex` to log in first) -2. Run CodexBar with debug logging enabled -3. Verify OAuth strategy is selected and API calls succeed -4. Test token refresh by manually setting `last_refresh` to old date -5. Test fallback by temporarily renaming auth.json +1. Use fixture files or an isolated `CODEX_HOME`; never test by modifying a real shared auth file. +2. Verify native `CODEX_HOME` precedence and opt-in external-source discovery with + `CodexOAuthCredentialReadTests`. +3. Verify fresh credentials make usage and reset-credit requests from the same in-memory snapshot. +4. Verify stale native credentials select Codex CLI recovery and stale external credentials fail + closed without a refresh request or file write. +5. Verify missing, unauthorized, decode, and network errors follow the source-aware fallback + policy; run `swift test --filter CodexOAuth` and `make check`. --- @@ -628,8 +332,8 @@ struct CodexOAuthFetchStrategy: ProviderFetchStrategy { | Error | Behavior | |-------|----------| -| No auth.json | Fall back to CLI strategy | -| Token expired | Attempt refresh, fall back to CLI on failure | -| Refresh failed | Log error, fall back to CLI | -| API error | Fall back to CLI | -| Network error | Retry with backoff, then fall back | +| No native auth file | Auto mode may continue to its next configured strategy; explicit OAuth reports the credential error | +| Stale native credentials | Throw `nativeRefreshRequired` and delegate to Codex CLI; never refresh in-process | +| Stale legacy/OpenCode credentials | Throw `readOnlySource`; fail closed because there is no safe writer handoff | +| Unauthorized OAuth response | Fall back only when the active source mode permits a recoverable CLI strategy | +| Decode, server, or network error | Surface the original error; do not launch unrelated CLI recovery | diff --git a/docs/codex.md b/docs/codex.md index 9f7fe1f286..e63e7f8bf8 100644 --- a/docs/codex.md +++ b/docs/codex.md @@ -29,7 +29,9 @@ Usage source picker: ### OAuth API (preferred for the app) - Reads OAuth tokens from `~/.codex/auth.json` (or `$CODEX_HOME/auth.json`). -- Refreshes access tokens when `last_refresh` is older than 8 days. +- CodexBar never publishes refreshed native tokens into `auth.json`; when native credentials are stale, + the explicit OAuth path delegates recovery to the Codex CLI, which owns that file. If the CLI is unavailable, + the OAuth error is surfaced instead of mutating the shared file. - Calls `GET https://chatgpt.com/backend-api/wham/usage` (default) with `Authorization: Bearer `. - The app reads reset-credit inventory once per refresh with a best-effort `GET https://chatgpt.com/backend-api/wham/rate-limit-reset-credits` using the same account-scoped OAuth context; @@ -43,6 +45,19 @@ Usage source picker: - Preferences → Providers → Codex → Show Codex Spark usage hides only the Spark rows in menus and the provider preview. It does not change fetching, history, notifications, widgets, credits, or other extra limits. +### Optional external OAuth sources (off by default) +- **External Codex OAuth sources** is a provider setting that must be enabled explicitly before CodexBar reads + another application's OAuth file. It is off by default because this is a cross-application credential boundary. +- Without an explicit `$CODEX_HOME`, native Codex auth wins first, followed by legacy `~/.config/codex/auth.json`, + then OpenCode's `~/.local/share/opencode/auth.json` (or the equivalent `XDG_DATA_HOME` path). +- An explicit `$CODEX_HOME` remains isolated; it never borrows credentials from those external locations. +- External fallbacks accept OAuth token structures only; API-key entries are ignored. Usage probes never refresh or + publish OAuth token material into a shared `auth.json` without a cross-writer publication contract. Stale native + credentials can delegate to the CLI recovery path, while stale external credentials fail closed in every mode. + Automatic mode also suppresses unscoped CLI fallback whenever a managed workspace is selected. Explicit + managed-account workspace selection is stored in CodexBar's private managed-account metadata; it never edits the + source `auth.json` or publishes an `account_id` change back to another application's credential file. + ### Advanced profile-home accounts - Managed Codex accounts remain the default multi-account path. - Advanced users can add existing Codex homes to `~/.codexbar/config.json` with