From 08ff28ab4c80ef6eef66bdbbc75ccdd52bebfefe Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Sat, 15 Aug 2026 10:43:50 +0800 Subject: [PATCH 01/17] Harden Codex OAuth source and refresh lifecycle --- .../Codex/CodexProviderImplementation.swift | 16 + .../Providers/Codex/CodexSettingsStore.swift | 3 +- Sources/CodexBar/SettingsStore+Defaults.swift | 9 + Sources/CodexBar/SettingsStore.swift | 6 + Sources/CodexBar/SettingsStoreState.swift | 1 + .../CodexOAuth/CodexOAuthCredentials.swift | 339 ++++++++++- .../CodexOAuthRefreshCoordinator.swift | 152 +++++ .../CodexOAuth/CodexTokenRefresher.swift | 21 +- .../Codex/CodexProviderDescriptor.swift | 61 +- .../Codex/CodexProviderSettings.swift | 5 +- .../Codex/CodexProviderSettingsBuilder.swift | 8 +- .../CodexOAuthCredentialReadTests.swift | 575 ++++++++++++++++++ .../CodexOAuthRefreshCoordinationTests.swift | 127 ++++ Tests/CodexBarTests/CodexOAuthTests.swift | 3 + .../ProviderArchitectureGatekeeperTests.swift | 2 +- docs/codex.md | 10 + 16 files changed, 1292 insertions(+), 46 deletions(-) create mode 100644 Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthRefreshCoordinator.swift create mode 100644 Tests/CodexBarTests/CodexOAuthCredentialReadTests.swift create mode 100644 Tests/CodexBarTests/CodexOAuthRefreshCoordinationTests.swift 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 4c7f74c01f..6fdd6ab761 100644 --- a/Sources/CodexBar/SettingsStore+Defaults.swift +++ b/Sources/CodexBar/SettingsStore+Defaults.swift @@ -715,6 +715,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 ef5eaa3d7b..0cbfbe6535 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -514,6 +514,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 { @@ -616,6 +621,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 e83b03b1e9..a3e07a3318 100644 --- a/Sources/CodexBar/SettingsStoreState.swift +++ b/Sources/CodexBar/SettingsStoreState.swift @@ -60,6 +60,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/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthCredentials.swift b/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthCredentials.swift index f02d9e9d7f..6e78c2445e 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthCredentials.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthCredentials.swift @@ -5,49 +5,80 @@ import Glibc #elseif canImport(Musl) import Musl #endif +import Crypto 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 init( accessToken: String, refreshToken: String, idToken: String?, accountId: String?, - lastRefresh: Date?) + lastRefresh: Date?, + expiresAt: Date? = nil, + source: CodexOAuthCredentialSource = .codexHome) { self.accessToken = accessToken self.refreshToken = refreshToken self.idToken = idToken self.accountId = accountId self.lastRefresh = lastRefresh + self.expiresAt = expiresAt + self.source = source } public var needsRefresh: Bool { + 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 } } +struct CodexOAuthNativeSnapshot: Sendable { + let credentials: CodexOAuthCredentials + let rawData: Data +} + public enum CodexOAuthCredentialsError: LocalizedError, Sendable { case notFound + case unreadable case decodeFailed(String) case missingTokens + case readOnlySource public var errorDescription: String? { switch self { case .notFound: "Codex auth.json not found. Run `codex` to log in." + case .unreadable: + "Codex auth.json could not be read. Check its permissions or run `codex` to log in again." case let .decodeFailed(message): "Failed to decode Codex credentials: \(message)" case .missingTokens: "Codex auth.json exists but contains no tokens." + case .readOnlySource: + "This Codex credential source is read-only and cannot be refreshed in place." } } } @@ -55,64 +86,142 @@ 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 } + } + + 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) + } + + static func loadNativeSnapshot( + env: [String: String] = ProcessInfo.processInfo.environment) throws -> CodexOAuthNativeSnapshot + { + let data = try self.readAuthData(env: env) + return try CodexOAuthNativeSnapshot( + credentials: self.parse(data: data, source: .codexHome), + rawData: data) + } + + private static func parse( + data: Data, + source: CodexOAuthCredentialSource) throws -> CodexOAuthCredentials + { + let json = try self.decodeObject(data: data) - if let apiKeyCredentials = Self.apiKeyCredentials(in: json) { + 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, @@ -136,10 +245,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 +263,17 @@ public enum CodexOAuthCredentialsStore { refreshToken: "", idToken: nil, accountId: nil, - lastRefresh: nil) + lastRefresh: nil, + source: source) } 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] = [:] @@ -186,6 +303,147 @@ public enum CodexOAuthCredentialsStore { try CredentialFileWriter.writePrivate(data, to: url) } + /// Persist a refreshed native credential only if the token material read before refresh is + /// still the token material on disk. Callers should hold the native refresh lock while using + /// this precondition so another CodexBar process cannot win the same read-modify-write cycle. + @discardableResult + static func saveIfCurrent( + _ credentials: CodexOAuthCredentials, + expected: CodexOAuthCredentials, + env: [String: String] = ProcessInfo.processInfo.environment) throws -> Bool + { + guard credentials.source == .codexHome, expected.source == .codexHome else { + throw CodexOAuthCredentialsError.readOnlySource + } + let current = try self.loadNativeSnapshot(env: env).credentials + guard self.tokenMaterialMatches(current, expected) else { return false } + try self.save(credentials, env: env) + return true + } + + static func refreshLockURL( + env: [String: String] = ProcessInfo.processInfo.environment) -> URL + { + self.authFilePath(env: env) + .deletingLastPathComponent() + .appendingPathComponent(".auth.json.codexbar-refresh.lock", isDirectory: false) + } + + public static func credentialGeneration(_ credentials: CodexOAuthCredentials) -> String { + let material = [ + credentials.accessToken, + credentials.refreshToken, + credentials.idToken ?? "", + credentials.accountId ?? "", + ].joined(separator: "\u{0}") + return SHA256.hash(data: Data(material.utf8)) + .map { String(format: "%02x", $0) } + .joined() + } + + static func tokenMaterialMatches( + _ lhs: CodexOAuthCredentials, + _ rhs: CodexOAuthCredentials) -> Bool + { + lhs.accessToken == rhs.accessToken + && lhs.refreshToken == rhs.refreshToken + && lhs.idToken == rhs.idToken + && lhs.accountId == rhs.accountId + && lhs.source == rhs.source + } + + 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, .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() @@ -217,6 +475,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/CodexOAuthRefreshCoordinator.swift b/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthRefreshCoordinator.swift new file mode 100644 index 0000000000..50b77c153e --- /dev/null +++ b/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthRefreshCoordinator.swift @@ -0,0 +1,152 @@ +#if canImport(Darwin) +import Darwin +#elseif canImport(Glibc) +import Glibc +#elseif canImport(Musl) +import Musl +#endif +import Crypto +import Foundation + +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +/// Coordinates native Codex OAuth refreshes across concurrent CodexBar tasks and processes. +/// +/// OpenAI may rotate a refresh grant. A process-local single-flight prevents two in-process +/// callers from consuming the same grant, while the advisory `flock` prevents two CodexBar +/// processes from running the same read-modify-write cycle. The generation check remains the +/// final fence: a newer login on disk is never overwritten by an older refresh result. +actor CodexOAuthRefreshCoordinator { + static let shared = CodexOAuthRefreshCoordinator() + + private var flights: [String: Task] = [:] + + func refreshAndPersist( + _ credentials: CodexOAuthCredentials, + env: [String: String], + transport: any ProviderHTTPTransport) async throws -> CodexOAuthCredentials + { + guard credentials.source == .codexHome else { + if credentials.needsRefresh { + throw CodexOAuthCredentialsError.readOnlySource + } + return credentials + } + guard credentials.needsRefresh, !credentials.refreshToken.isEmpty else { + return credentials + } + + let key = Self.flightKey(credentials: credentials, env: env) + if let existing = self.flights[key] { + return try await existing.value + } + + let task = Task { [credentials, env, transport] in + try await Self.performRefreshAndPersist( + credentials, + env: env, + transport: transport) + } + self.flights[key] = task + do { + let result = try await task.value + self.flights[key] = nil + return result + } catch { + self.flights[key] = nil + throw error + } + } + + private static func flightKey( + credentials: CodexOAuthCredentials, + env: [String: String]) -> String + { + let lockPath = CodexOAuthCredentialsStore.refreshLockURL(env: env).path + let grantFingerprint = SHA256.hash(data: Data(credentials.refreshToken.utf8)) + .map { String(format: "%02x", $0) } + .joined() + return "\(lockPath)\u{0}\(grantFingerprint)" + } + + private static func performRefreshAndPersist( + _ credentials: CodexOAuthCredentials, + env: [String: String], + transport: any ProviderHTTPTransport) async throws -> CodexOAuthCredentials + { + try await self.withNativeRefreshLock(env: env) { + let locked = try CodexOAuthCredentialsStore.loadNativeSnapshot(env: env).credentials + + // Another caller may have completed the rotation before this process acquired the + // file lock. Reuse a fresh newer credential and never refresh the old grant again. + if !CodexOAuthCredentialsStore.tokenMaterialMatches(locked, credentials) { + guard locked.needsRefresh, !locked.refreshToken.isEmpty else { + return locked + } + } + guard locked.needsRefresh, !locked.refreshToken.isEmpty else { + return locked + } + + let updated = try await CodexTokenRefresher.refresh(locked, session: transport) + guard try CodexOAuthCredentialsStore.saveIfCurrent( + updated, + expected: locked, + env: env) + else { + let current = try CodexOAuthCredentialsStore.loadNativeSnapshot(env: env).credentials + if !current.needsRefresh { + return current + } + throw CodexTokenRefresher.RefreshError.generationConflict + } + return updated + } + } + + private static func withNativeRefreshLock( + env: [String: String], + operation: () async throws -> Value) async throws -> Value + { + let lockURL = CodexOAuthCredentialsStore.refreshLockURL(env: env) + let directory = lockURL.deletingLastPathComponent() + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + + let descriptor = lockURL.path.withCString { path in + open(path, O_CREAT | O_RDWR | O_CLOEXEC, mode_t(0o600)) + } + guard descriptor >= 0 else { + throw CodexTokenRefresher.RefreshError.lockUnavailable(Self.posixMessage( + errno, + path: lockURL.path)) + } + defer { + _ = flock(descriptor, LOCK_UN) + close(descriptor) + } + + guard fchmod(descriptor, mode_t(0o600)) == 0 else { + throw CodexTokenRefresher.RefreshError.lockUnavailable(Self.posixMessage( + errno, + path: lockURL.path)) + } + + while flock(descriptor, LOCK_EX | LOCK_NB) != 0 { + let code = errno + guard code == EWOULDBLOCK || code == EAGAIN else { + throw CodexTokenRefresher.RefreshError.lockUnavailable(Self.posixMessage( + code, + path: lockURL.path)) + } + try await Task.sleep(nanoseconds: 25_000_000) + } + + return try await operation() + } + + private static func posixMessage(_ code: Int32, path: String) -> String { + "\(String(cString: strerror(code))) (\(path))" + } +} diff --git a/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexTokenRefresher.swift b/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexTokenRefresher.swift index ff797642d1..1a072c97cc 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexTokenRefresher.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexTokenRefresher.swift @@ -11,6 +11,8 @@ public enum CodexTokenRefresher { case expired case revoked case reused + case generationConflict + case lockUnavailable(String) case networkError(Error) case invalidResponse(String) @@ -22,6 +24,10 @@ public enum CodexTokenRefresher { "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 .generationConflict: + "Codex credentials changed during refresh; the newer login was kept." + case let .lockUnavailable(message): + "Could not coordinate Codex credential refresh: \(message)" case let .networkError(error): "Network error during token refresh: \(error.localizedDescription)" case let .invalidResponse(message): @@ -34,6 +40,18 @@ public enum CodexTokenRefresher { try await self.refresh(credentials, session: CodexAuthenticatedHTTPTransport.current) } + /// Refresh and persist a native Codex credential with process-local single-flight, + /// cross-process locking, and a generation check before publishing the result. + public static func refreshAndPersist( + _ credentials: CodexOAuthCredentials, + env: [String: String] = ProcessInfo.processInfo.environment) async throws -> CodexOAuthCredentials + { + try await CodexOAuthRefreshCoordinator.shared.refreshAndPersist( + credentials, + env: env, + transport: CodexAuthenticatedHTTPTransport.current) + } + static func refresh( _ credentials: CodexOAuthCredentials, session transport: any ProviderHTTPTransport) async throws -> CodexOAuthCredentials @@ -77,7 +95,8 @@ public enum CodexTokenRefresher { refreshToken: newRefreshToken, idToken: newIdToken, accountId: credentials.accountId, - lastRefresh: Date()) + lastRefresh: Date(), + source: credentials.source) } 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..ada472f6f2 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift @@ -317,15 +317,28 @@ struct CodexOAuthFetchStrategy: ProviderFetchStrategy { 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 = initialCredentials + + if credentials.needsRefresh { + credentials = try await Self.prepareCredentialsForUsage(credentials) { credentials in + try await CodexTokenRefresher.refreshAndPersist(credentials, env: context.env) + } } let usage = try await CodexOAuthUsageFetcher.fetchUsage( @@ -350,6 +363,25 @@ struct CodexOAuthFetchStrategy: ProviderFetchStrategy { return try await Self.replacingWithCLIMonthlyLimitIfAvailable(spendControlsResult, context: context) } + private static func prepareCredentialsForUsage( + _ credentials: CodexOAuthCredentials, + refresher: @escaping @Sendable (CodexOAuthCredentials) async throws -> CodexOAuthCredentials) + async throws -> CodexOAuthCredentials + { + guard credentials.needsRefresh else { + return credentials + } + // External sources are intentionally read-only. Refreshing them can rotate or consume the + // source application's refresh token even when CodexBar cannot persist the replacement. + guard credentials.source.canPersistRefresh else { + throw CodexOAuthCredentialsError.readOnlySource + } + guard !credentials.refreshToken.isEmpty else { + return credentials + } + return try await refresher(credentials) + } + private static func shouldFetchResetCredits(_ context: ProviderFetchContext) -> Bool { guard case .cli = context.runtime else { return false } return context.includeCredits @@ -371,7 +403,7 @@ struct CodexOAuthFetchStrategy: ProviderFetchStrategy { } if let credentialsError = error as? CodexOAuthCredentialsError { switch credentialsError { - case .notFound, .missingTokens: + case .notFound, .unreadable, .missingTokens, .readOnlySource: return true case .decodeFailed: return false @@ -380,7 +412,7 @@ struct CodexOAuthFetchStrategy: ProviderFetchStrategy { switch error as? CodexTokenRefresher.RefreshError { case .expired, .revoked, .reused: return true - case .networkError, .invalidResponse, .none: + case .generationConflict, .lockUnavailable, .networkError, .invalidResponse, .none: return false } } @@ -638,6 +670,21 @@ 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, + refresher: @escaping @Sendable (CodexOAuthCredentials) async throws -> CodexOAuthCredentials) + async throws -> CodexOAuthCredentials + { + try await self.prepareCredentialsForUsage(credentials, refresher: refresher) + } + 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..8163a82ccb 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexProviderSettings.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexProviderSettings.swift @@ -9,6 +9,7 @@ public struct CodexProviderSettings: Sendable { public let profileAccountTargetUnavailable: Bool public let openAIWebCacheScope: CookieHeaderCache.Scope? public let dashboardAuthorityKnownOwners: [CodexDashboardKnownOwnerCandidate] + public let allowExternalOAuthSources: Bool public init( usageDataSource: CodexUsageDataSource, @@ -18,7 +19,8 @@ public struct CodexProviderSettings: Sendable { managedAccountTargetUnavailable: Bool = false, profileAccountTargetUnavailable: Bool = false, openAIWebCacheScope: CookieHeaderCache.Scope? = nil, - dashboardAuthorityKnownOwners: [CodexDashboardKnownOwnerCandidate] = []) + dashboardAuthorityKnownOwners: [CodexDashboardKnownOwnerCandidate] = [], + allowExternalOAuthSources: Bool = false) { self.usageDataSource = usageDataSource self.cookieSource = cookieSource @@ -28,6 +30,7 @@ public struct CodexProviderSettings: Sendable { self.profileAccountTargetUnavailable = profileAccountTargetUnavailable self.openAIWebCacheScope = openAIWebCacheScope self.dashboardAuthorityKnownOwners = dashboardAuthorityKnownOwners + self.allowExternalOAuthSources = allowExternalOAuthSources } } diff --git a/Sources/CodexBarCore/Providers/Codex/CodexProviderSettingsBuilder.swift b/Sources/CodexBarCore/Providers/Codex/CodexProviderSettingsBuilder.swift index 62de1552f5..90902f7b16 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 } } @@ -88,6 +91,7 @@ public enum CodexProviderSettingsBuilder { && snapshot.activeStoredAccount == nil, profileAccountTargetUnavailable: profileAccountTargetUnavailable, openAIWebCacheScope: openAIWebCacheScope, - dashboardAuthorityKnownOwners: CodexKnownOwnerCatalog.candidates(from: snapshot)) + dashboardAuthorityKnownOwners: CodexKnownOwnerCatalog.candidates(from: snapshot), + allowExternalOAuthSources: input.allowExternalOAuthSources) } } diff --git a/Tests/CodexBarTests/CodexOAuthCredentialReadTests.swift b/Tests/CodexBarTests/CodexOAuthCredentialReadTests.swift new file mode 100644 index 0000000000..bb564b101d --- /dev/null +++ b/Tests/CodexBarTests/CodexOAuthCredentialReadTests.swift @@ -0,0 +1,575 @@ +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 `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 never invoke 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 recorder = RefreshInvocationRecorder() + + let error = await #expect(throws: CodexOAuthCredentialsError.self) { + try await CodexOAuthFetchStrategy._prepareCredentialsForTesting(credentials) { _ in + await recorder.record() + return credentials + } + } + guard case .readOnlySource = error else { + Issue.record("Expired external credentials must fail before refresh") + return + } + #expect(await recorder.count() == 0) + } + + @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) { _ in + Issue.record("An expired read-only credential must not reach a refresh closure") + return credentials + } + } + guard case .readOnlySource = error else { + Issue.record("Expired external credentials without a refresh token must fail closed") + return + } + } + + @Test + func `expired read-only oauth credentials fail before the fetch sends a request`() async throws { + let credentials = CodexOAuthCredentials( + accessToken: "expired-access", + refreshToken: "external-refresh", + idToken: nil, + accountId: nil, + lastRefresh: nil, + expiresAt: Date().addingTimeInterval(-1), + source: .openCode) + let requests = RefreshInvocationRecorder() + let transport = ProviderHTTPTransportStub { _ in + await requests.record() + throw URLError(.badServerResponse) + } + + let error = await #expect(throws: CodexOAuthCredentialsError.self) { + try await CodexAuthenticatedHTTPTransport.$overrideForTesting.withValue(transport) { + try await CodexOAuthFetchStrategy._fetchForTesting( + context: Self.context(), + credentials: credentials) + } + } + guard case .readOnlySource = error else { + Issue.record("The fetch path must reject expired external credentials before transport") + return + } + #expect(await requests.count() == 0) + } + + @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 recorder = RefreshInvocationRecorder() + + let resolved = try await CodexOAuthFetchStrategy._prepareCredentialsForTesting(credentials) { _ in + await recorder.record() + return credentials + } + + #expect(resolved.accessToken == "valid-access") + #expect(resolved.source == .openCode) + #expect(await recorder.count() == 0) + } + + @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 + } + } + + 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 actor RefreshInvocationRecorder { + private var invocations = 0 + + func record() { + self.invocations += 1 + } + + func count() -> Int { + self.invocations + } +} diff --git a/Tests/CodexBarTests/CodexOAuthRefreshCoordinationTests.swift b/Tests/CodexBarTests/CodexOAuthRefreshCoordinationTests.swift new file mode 100644 index 0000000000..0a50e0148e --- /dev/null +++ b/Tests/CodexBarTests/CodexOAuthRefreshCoordinationTests.swift @@ -0,0 +1,127 @@ +import Foundation +import Testing +@testable import CodexBarCore + +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +@Suite(.serialized) +struct CodexOAuthRefreshCoordinationTests { + @Test + func `concurrent native refreshes share one token request`() async throws { + let home = try self.makeHome(prefix: "codex-oauth-refresh-single-flight") + defer { try? FileManager.default.removeItem(at: home) } + let environment = ["CODEX_HOME": home.path] + try self.writeExpiredCredentials(to: home) + let initial = try CodexOAuthCredentialsStore.load(env: environment) + let calls = RefreshCallCounter() + let transport = self.makeRefreshTransport(calls: calls) + + let results = try await CodexAuthenticatedHTTPTransport.$overrideForTesting + .withValue(transport) { + try await withThrowingTaskGroup(of: CodexOAuthCredentials.self) { group in + for _ in 0..<2 { + group.addTask { + try await CodexTokenRefresher.refreshAndPersist(initial, env: environment) + } + } + var values: [CodexOAuthCredentials] = [] + for try await value in group { + values.append(value) + } + return values + } + } + + #expect(results.count == 2) + #expect(results.allSatisfy { $0.accessToken == "refreshed-access" }) + #expect(await calls.count() == 1) + let persisted = try CodexOAuthCredentialsStore.load(env: environment) + #expect(persisted.accessToken == "refreshed-access") + #expect(persisted.refreshToken == "refreshed-refresh") + } + + @Test + func `newer native credentials win a refresh generation race`() async throws { + let home = try self.makeHome(prefix: "codex-oauth-refresh-generation") + defer { try? FileManager.default.removeItem(at: home) } + let environment = ["CODEX_HOME": home.path] + try self.writeExpiredCredentials(to: home) + let initial = try CodexOAuthCredentialsStore.load(env: environment) + let calls = RefreshCallCounter() + let transport = ProviderHTTPTransportHandler { _ in + await calls.record() + let newer = CodexOAuthCredentials( + accessToken: "newer-access", + refreshToken: "newer-refresh", + idToken: nil, + accountId: "account-newer", + lastRefresh: Date(), + source: .codexHome) + try CodexOAuthCredentialsStore.save(newer, env: environment) + let response = try #require(HTTPURLResponse( + url: URL(string: "https://auth.openai.com/oauth/token")!, + statusCode: 200, + httpVersion: nil, + headerFields: nil)) + return ( + Data(#"{"access_token":"stale-access","refresh_token":"stale-refresh","expires_in":3600}"#.utf8), + response) + } + + let resolved = try await CodexAuthenticatedHTTPTransport.$overrideForTesting + .withValue(transport) { + try await CodexTokenRefresher.refreshAndPersist(initial, env: environment) + } + + #expect(await calls.count() == 1) + #expect(resolved.accessToken == "newer-access") + #expect(try CodexOAuthCredentialsStore.load(env: environment).accessToken == "newer-access") + } + + private func makeHome(prefix: String) throws -> URL { + let home = FileManager.default.temporaryDirectory + .appendingPathComponent("\(prefix)-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: home, withIntermediateDirectories: true) + return home + } + + private func writeExpiredCredentials(to home: URL) throws { + let auth = #""" + { + "tokens":{"access_token":"expired-access","refresh_token":"expired-refresh"}, + "last_refresh":"2020-01-01T00:00:00Z" + } + """# + try Data(auth.utf8).write(to: home.appendingPathComponent("auth.json")) + } + + private func makeRefreshTransport(calls: RefreshCallCounter) -> any ProviderHTTPTransport { + ProviderHTTPTransportHandler { request in + await calls.record() + try await Task.sleep(nanoseconds: 50_000_000) + let response = try #require(HTTPURLResponse( + url: request.url ?? URL(string: "https://auth.openai.com/oauth/token")!, + statusCode: 200, + httpVersion: nil, + headerFields: nil)) + return ( + Data(#"{"access_token":"refreshed-access","refresh_token":"refreshed-refresh","expires_in":3600}"# + .utf8), + response) + } + } +} + +private actor RefreshCallCounter { + private var value = 0 + + func record() { + self.value += 1 + } + + func count() -> Int { + self.value + } +} diff --git a/Tests/CodexBarTests/CodexOAuthTests.swift b/Tests/CodexBarTests/CodexOAuthTests.swift index d6a338b038..4397efb804 100644 --- a/Tests/CodexBarTests/CodexOAuthTests.swift +++ b/Tests/CodexBarTests/CodexOAuthTests.swift @@ -736,7 +736,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)) @@ -785,6 +787,7 @@ struct CodexOAuthTests { let context = self.makeContext(sourceMode: .oauth) #expect(!strategy.shouldFallback(on: CodexOAuthFetchError.unauthorized, context: context)) + #expect(!strategy.shouldFallback(on: CodexOAuthCredentialsError.readOnlySource, context: context)) #expect(!strategy.shouldFallback(on: CodexTokenRefresher.RefreshError.expired, context: context)) } diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index 54eeb5affa..d371efdb39 100644 --- a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift +++ b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift @@ -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: 1027, + line: 1033, anchor: "if !seen.contains(.factory), let zaiIndex = ordered.firstIndex(of: .zai) {", expectedProviderIDs: ["factory", "minimax", "zai"], expectedReferenceCount: 8, diff --git a/docs/codex.md b/docs/codex.md index 9f7fe1f286..9ef75bc55e 100644 --- a/docs/codex.md +++ b/docs/codex.md @@ -43,6 +43,16 @@ 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. External credentials are + read-only: CodexBar never refreshes or writes them back. In Automatic mode an expired external credential lets + the existing CLI fallback run; explicit OAuth mode reports the read-only error instead. + ### 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 From e79d78909dbcf3a4e7365536b7e9fd37791db899 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Sat, 15 Aug 2026 10:43:55 +0800 Subject: [PATCH 02/17] Harden managed Codex account writes --- .../CodexBarCore/ManagedCodexAccountStore.swift | 14 ++++---------- .../ManagedCodexAccountStoreTests.swift | 3 +++ 2 files changed, 7 insertions(+), 10 deletions(-) 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/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 From ca7486d2ba15d4cef61f271289d92cbf7878e850 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Sat, 15 Aug 2026 11:20:13 +0800 Subject: [PATCH 03/17] Recover Codex account identity from JWT claims --- .../CodexOAuth/CodexOAuthCredentials.swift | 33 +++++++++++++ .../CodexOAuthCredentialReadTests.swift | 47 +++++++++++++++++++ 2 files changed, 80 insertions(+) diff --git a/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthCredentials.swift b/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthCredentials.swift index 6e78c2445e..f8348cc990 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthCredentials.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthCredentials.swift @@ -238,6 +238,7 @@ 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") + ?? Self.accountIDFromJWT(idToken: idToken, accessToken: accessToken) let lastRefresh = Self.parseLastRefresh(from: json["last_refresh"]) return CodexOAuthCredentials( @@ -467,6 +468,38 @@ 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 = Self.nonEmpty(organizations.first?["id"] as? String) + { + return accountID + } + } + return nil + } } #if DEBUG diff --git a/Tests/CodexBarTests/CodexOAuthCredentialReadTests.swift b/Tests/CodexBarTests/CodexOAuthCredentialReadTests.swift index bb564b101d..6071cfdda2 100644 --- a/Tests/CodexBarTests/CodexOAuthCredentialReadTests.swift +++ b/Tests/CodexBarTests/CodexOAuthCredentialReadTests.swift @@ -49,6 +49,41 @@ struct CodexOAuthCredentialReadTests { #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 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 `open code oauth credentials preserve expiry and remain read only`() throws { let expiresAt = Date().addingTimeInterval(3600) @@ -560,6 +595,18 @@ struct CodexOAuthCredentialReadTests { 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" + } } private actor RefreshInvocationRecorder { From 032f224509010b9dcf661fbdd57741c46fe1e47e Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Sat, 15 Aug 2026 11:25:11 +0800 Subject: [PATCH 04/17] Cover direct Codex JWT account claims --- .../CodexOAuthCredentialReadTests.swift | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/Tests/CodexBarTests/CodexOAuthCredentialReadTests.swift b/Tests/CodexBarTests/CodexOAuthCredentialReadTests.swift index 6071cfdda2..daa48b16c3 100644 --- a/Tests/CodexBarTests/CodexOAuthCredentialReadTests.swift +++ b/Tests/CodexBarTests/CodexOAuthCredentialReadTests.swift @@ -67,6 +67,24 @@ struct CodexOAuthCredentialReadTests { #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 `missing account id falls back to the first OpenAI organization`() throws { let accessToken = Self.jwt(payload: [ From 4db8972fc0d46985f59f5026d31b6dcb7545e408 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Sat, 15 Aug 2026 12:13:59 +0800 Subject: [PATCH 05/17] Keep shared Codex auth read-only during refresh --- .../CodexOAuth/CodexOAuthCredentials.swift | 73 +-------- .../CodexOAuthRefreshCoordinator.swift | 152 ------------------ .../CodexOAuth/CodexTokenRefresher.swift | 21 +-- .../Codex/CodexProviderDescriptor.swift | 35 ++-- .../CodexOAuthCredentialReadTests.swift | 49 +++--- .../CodexOAuthRefreshCoordinationTests.swift | 127 --------------- Tests/CodexBarTests/CodexOAuthTests.swift | 2 + docs/codex.md | 7 +- 8 files changed, 52 insertions(+), 414 deletions(-) delete mode 100644 Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthRefreshCoordinator.swift delete mode 100644 Tests/CodexBarTests/CodexOAuthRefreshCoordinationTests.swift diff --git a/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthCredentials.swift b/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthCredentials.swift index f8348cc990..9ac4ee17ca 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthCredentials.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthCredentials.swift @@ -5,7 +5,6 @@ import Glibc #elseif canImport(Musl) import Musl #endif -import Crypto import Foundation public enum CodexOAuthCredentialSource: String, Equatable, Sendable { @@ -26,6 +25,7 @@ public struct CodexOAuthCredentials: Equatable, Sendable { public let lastRefresh: Date? public let expiresAt: Date? public let source: CodexOAuthCredentialSource + public let isAPIKey: Bool public init( accessToken: String, @@ -34,7 +34,8 @@ public struct CodexOAuthCredentials: Equatable, Sendable { accountId: String?, lastRefresh: Date?, expiresAt: Date? = nil, - source: CodexOAuthCredentialSource = .codexHome) + source: CodexOAuthCredentialSource = .codexHome, + isAPIKey: Bool = false) { self.accessToken = accessToken self.refreshToken = refreshToken @@ -43,9 +44,11 @@ public struct CodexOAuthCredentials: Equatable, Sendable { 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 } @@ -55,11 +58,6 @@ public struct CodexOAuthCredentials: Equatable, Sendable { } } -struct CodexOAuthNativeSnapshot: Sendable { - let credentials: CodexOAuthCredentials - let rawData: Data -} - public enum CodexOAuthCredentialsError: LocalizedError, Sendable { case notFound case unreadable @@ -155,15 +153,6 @@ public enum CodexOAuthCredentialsStore { return try self.parse(data: data, source: .codexHome) } - static func loadNativeSnapshot( - env: [String: String] = ProcessInfo.processInfo.environment) throws -> CodexOAuthNativeSnapshot - { - let data = try self.readAuthData(env: env) - return try CodexOAuthNativeSnapshot( - credentials: self.parse(data: data, source: .codexHome), - rawData: data) - } - private static func parse( data: Data, source: CodexOAuthCredentialSource) throws -> CodexOAuthCredentials @@ -265,7 +254,8 @@ public enum CodexOAuthCredentialsStore { idToken: nil, accountId: nil, lastRefresh: nil, - source: source) + source: source, + isAPIKey: true) } public static func save( @@ -304,55 +294,6 @@ public enum CodexOAuthCredentialsStore { try CredentialFileWriter.writePrivate(data, to: url) } - /// Persist a refreshed native credential only if the token material read before refresh is - /// still the token material on disk. Callers should hold the native refresh lock while using - /// this precondition so another CodexBar process cannot win the same read-modify-write cycle. - @discardableResult - static func saveIfCurrent( - _ credentials: CodexOAuthCredentials, - expected: CodexOAuthCredentials, - env: [String: String] = ProcessInfo.processInfo.environment) throws -> Bool - { - guard credentials.source == .codexHome, expected.source == .codexHome else { - throw CodexOAuthCredentialsError.readOnlySource - } - let current = try self.loadNativeSnapshot(env: env).credentials - guard self.tokenMaterialMatches(current, expected) else { return false } - try self.save(credentials, env: env) - return true - } - - static func refreshLockURL( - env: [String: String] = ProcessInfo.processInfo.environment) -> URL - { - self.authFilePath(env: env) - .deletingLastPathComponent() - .appendingPathComponent(".auth.json.codexbar-refresh.lock", isDirectory: false) - } - - public static func credentialGeneration(_ credentials: CodexOAuthCredentials) -> String { - let material = [ - credentials.accessToken, - credentials.refreshToken, - credentials.idToken ?? "", - credentials.accountId ?? "", - ].joined(separator: "\u{0}") - return SHA256.hash(data: Data(material.utf8)) - .map { String(format: "%02x", $0) } - .joined() - } - - static func tokenMaterialMatches( - _ lhs: CodexOAuthCredentials, - _ rhs: CodexOAuthCredentials) -> Bool - { - lhs.accessToken == rhs.accessToken - && lhs.refreshToken == rhs.refreshToken - && lhs.idToken == rhs.idToken - && lhs.accountId == rhs.accountId - && lhs.source == rhs.source - } - private static func shouldTryExternalFallback( _ error: CodexOAuthCredentialsError, env: [String: String]) -> Bool diff --git a/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthRefreshCoordinator.swift b/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthRefreshCoordinator.swift deleted file mode 100644 index 50b77c153e..0000000000 --- a/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthRefreshCoordinator.swift +++ /dev/null @@ -1,152 +0,0 @@ -#if canImport(Darwin) -import Darwin -#elseif canImport(Glibc) -import Glibc -#elseif canImport(Musl) -import Musl -#endif -import Crypto -import Foundation - -#if canImport(FoundationNetworking) -import FoundationNetworking -#endif - -/// Coordinates native Codex OAuth refreshes across concurrent CodexBar tasks and processes. -/// -/// OpenAI may rotate a refresh grant. A process-local single-flight prevents two in-process -/// callers from consuming the same grant, while the advisory `flock` prevents two CodexBar -/// processes from running the same read-modify-write cycle. The generation check remains the -/// final fence: a newer login on disk is never overwritten by an older refresh result. -actor CodexOAuthRefreshCoordinator { - static let shared = CodexOAuthRefreshCoordinator() - - private var flights: [String: Task] = [:] - - func refreshAndPersist( - _ credentials: CodexOAuthCredentials, - env: [String: String], - transport: any ProviderHTTPTransport) async throws -> CodexOAuthCredentials - { - guard credentials.source == .codexHome else { - if credentials.needsRefresh { - throw CodexOAuthCredentialsError.readOnlySource - } - return credentials - } - guard credentials.needsRefresh, !credentials.refreshToken.isEmpty else { - return credentials - } - - let key = Self.flightKey(credentials: credentials, env: env) - if let existing = self.flights[key] { - return try await existing.value - } - - let task = Task { [credentials, env, transport] in - try await Self.performRefreshAndPersist( - credentials, - env: env, - transport: transport) - } - self.flights[key] = task - do { - let result = try await task.value - self.flights[key] = nil - return result - } catch { - self.flights[key] = nil - throw error - } - } - - private static func flightKey( - credentials: CodexOAuthCredentials, - env: [String: String]) -> String - { - let lockPath = CodexOAuthCredentialsStore.refreshLockURL(env: env).path - let grantFingerprint = SHA256.hash(data: Data(credentials.refreshToken.utf8)) - .map { String(format: "%02x", $0) } - .joined() - return "\(lockPath)\u{0}\(grantFingerprint)" - } - - private static func performRefreshAndPersist( - _ credentials: CodexOAuthCredentials, - env: [String: String], - transport: any ProviderHTTPTransport) async throws -> CodexOAuthCredentials - { - try await self.withNativeRefreshLock(env: env) { - let locked = try CodexOAuthCredentialsStore.loadNativeSnapshot(env: env).credentials - - // Another caller may have completed the rotation before this process acquired the - // file lock. Reuse a fresh newer credential and never refresh the old grant again. - if !CodexOAuthCredentialsStore.tokenMaterialMatches(locked, credentials) { - guard locked.needsRefresh, !locked.refreshToken.isEmpty else { - return locked - } - } - guard locked.needsRefresh, !locked.refreshToken.isEmpty else { - return locked - } - - let updated = try await CodexTokenRefresher.refresh(locked, session: transport) - guard try CodexOAuthCredentialsStore.saveIfCurrent( - updated, - expected: locked, - env: env) - else { - let current = try CodexOAuthCredentialsStore.loadNativeSnapshot(env: env).credentials - if !current.needsRefresh { - return current - } - throw CodexTokenRefresher.RefreshError.generationConflict - } - return updated - } - } - - private static func withNativeRefreshLock( - env: [String: String], - operation: () async throws -> Value) async throws -> Value - { - let lockURL = CodexOAuthCredentialsStore.refreshLockURL(env: env) - let directory = lockURL.deletingLastPathComponent() - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - - let descriptor = lockURL.path.withCString { path in - open(path, O_CREAT | O_RDWR | O_CLOEXEC, mode_t(0o600)) - } - guard descriptor >= 0 else { - throw CodexTokenRefresher.RefreshError.lockUnavailable(Self.posixMessage( - errno, - path: lockURL.path)) - } - defer { - _ = flock(descriptor, LOCK_UN) - close(descriptor) - } - - guard fchmod(descriptor, mode_t(0o600)) == 0 else { - throw CodexTokenRefresher.RefreshError.lockUnavailable(Self.posixMessage( - errno, - path: lockURL.path)) - } - - while flock(descriptor, LOCK_EX | LOCK_NB) != 0 { - let code = errno - guard code == EWOULDBLOCK || code == EAGAIN else { - throw CodexTokenRefresher.RefreshError.lockUnavailable(Self.posixMessage( - code, - path: lockURL.path)) - } - try await Task.sleep(nanoseconds: 25_000_000) - } - - return try await operation() - } - - private static func posixMessage(_ code: Int32, path: String) -> String { - "\(String(cString: strerror(code))) (\(path))" - } -} diff --git a/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexTokenRefresher.swift b/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexTokenRefresher.swift index 1a072c97cc..489b8cdfb9 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexTokenRefresher.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexTokenRefresher.swift @@ -11,8 +11,6 @@ public enum CodexTokenRefresher { case expired case revoked case reused - case generationConflict - case lockUnavailable(String) case networkError(Error) case invalidResponse(String) @@ -24,10 +22,6 @@ public enum CodexTokenRefresher { "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 .generationConflict: - "Codex credentials changed during refresh; the newer login was kept." - case let .lockUnavailable(message): - "Could not coordinate Codex credential refresh: \(message)" case let .networkError(error): "Network error during token refresh: \(error.localizedDescription)" case let .invalidResponse(message): @@ -40,18 +34,6 @@ public enum CodexTokenRefresher { try await self.refresh(credentials, session: CodexAuthenticatedHTTPTransport.current) } - /// Refresh and persist a native Codex credential with process-local single-flight, - /// cross-process locking, and a generation check before publishing the result. - public static func refreshAndPersist( - _ credentials: CodexOAuthCredentials, - env: [String: String] = ProcessInfo.processInfo.environment) async throws -> CodexOAuthCredentials - { - try await CodexOAuthRefreshCoordinator.shared.refreshAndPersist( - credentials, - env: env, - transport: CodexAuthenticatedHTTPTransport.current) - } - static func refresh( _ credentials: CodexOAuthCredentials, session transport: any ProviderHTTPTransport) async throws -> CodexOAuthCredentials @@ -96,7 +78,8 @@ public enum CodexTokenRefresher { idToken: newIdToken, accountId: credentials.accountId, lastRefresh: Date(), - source: credentials.source) + 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 ada472f6f2..fa4fc2020d 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift @@ -333,13 +333,7 @@ struct CodexOAuthFetchStrategy: ProviderFetchStrategy { context: ProviderFetchContext, credentials initialCredentials: CodexOAuthCredentials) async throws -> ProviderFetchResult { - var credentials = initialCredentials - - if credentials.needsRefresh { - credentials = try await Self.prepareCredentialsForUsage(credentials) { credentials in - try await CodexTokenRefresher.refreshAndPersist(credentials, env: context.env) - } - } + let credentials = try Self.prepareCredentialsForUsage(initialCredentials) let usage = try await CodexOAuthUsageFetcher.fetchUsage( accessToken: credentials.accessToken, @@ -364,22 +358,15 @@ struct CodexOAuthFetchStrategy: ProviderFetchStrategy { } private static func prepareCredentialsForUsage( - _ credentials: CodexOAuthCredentials, - refresher: @escaping @Sendable (CodexOAuthCredentials) async throws -> CodexOAuthCredentials) - async throws -> CodexOAuthCredentials + _ credentials: CodexOAuthCredentials) throws -> CodexOAuthCredentials { - guard credentials.needsRefresh else { - return credentials - } - // External sources are intentionally read-only. Refreshing them can rotate or consume the - // source application's refresh token even when CodexBar cannot persist the replacement. - guard credentials.source.canPersistRefresh else { + guard !credentials.needsRefresh else { + // auth.json is owned by Codex CLI and may be replaced by a login at any time. Without + // a cross-writer compare-and-swap contract, CodexBar must not refresh and publish new + // OAuth token material into this shared file. Automatic mode can continue with CLI. throw CodexOAuthCredentialsError.readOnlySource } - guard !credentials.refreshToken.isEmpty else { - return credentials - } - return try await refresher(credentials) + return credentials } private static func shouldFetchResetCredits(_ context: ProviderFetchContext) -> Bool { @@ -412,7 +399,7 @@ struct CodexOAuthFetchStrategy: ProviderFetchStrategy { switch error as? CodexTokenRefresher.RefreshError { case .expired, .revoked, .reused: return true - case .generationConflict, .lockUnavailable, .networkError, .invalidResponse, .none: + case .networkError, .invalidResponse, .none: return false } } @@ -678,11 +665,9 @@ extension CodexOAuthFetchStrategy { } static func _prepareCredentialsForTesting( - _ credentials: CodexOAuthCredentials, - refresher: @escaping @Sendable (CodexOAuthCredentials) async throws -> CodexOAuthCredentials) - async throws -> CodexOAuthCredentials + _ credentials: CodexOAuthCredentials) throws -> CodexOAuthCredentials { - try await self.prepareCredentialsForUsage(credentials, refresher: refresher) + try self.prepareCredentialsForUsage(credentials) } static func _applySpendControlsMonthlyLimitForTesting( diff --git a/Tests/CodexBarTests/CodexOAuthCredentialReadTests.swift b/Tests/CodexBarTests/CodexOAuthCredentialReadTests.swift index daa48b16c3..cfc0fa87dc 100644 --- a/Tests/CodexBarTests/CodexOAuthCredentialReadTests.swift +++ b/Tests/CodexBarTests/CodexOAuthCredentialReadTests.swift @@ -150,7 +150,7 @@ struct CodexOAuthCredentialReadTests { } @Test - func `expired read-only oauth credentials never invoke refresh`() async throws { + func `expired read-only oauth credentials fail before refresh`() throws { let credentials = CodexOAuthCredentials( accessToken: "expired-access", refreshToken: "external-refresh", @@ -159,23 +159,18 @@ struct CodexOAuthCredentialReadTests { lastRefresh: nil, expiresAt: Date().addingTimeInterval(-1), source: .openCode) - let recorder = RefreshInvocationRecorder() - let error = await #expect(throws: CodexOAuthCredentialsError.self) { - try await CodexOAuthFetchStrategy._prepareCredentialsForTesting(credentials) { _ in - await recorder.record() - return credentials - } + let error = #expect(throws: CodexOAuthCredentialsError.self) { + try CodexOAuthFetchStrategy._prepareCredentialsForTesting(credentials) } guard case .readOnlySource = error else { Issue.record("Expired external credentials must fail before refresh") return } - #expect(await recorder.count() == 0) } @Test - func `expired read-only oauth credentials without a refresh token are rejected`() async throws { + func `expired read-only oauth credentials without a refresh token are rejected`() throws { let credentials = CodexOAuthCredentials( accessToken: "expired-access", refreshToken: "", @@ -185,11 +180,8 @@ struct CodexOAuthCredentialReadTests { expiresAt: Date().addingTimeInterval(-1), source: .legacyCodexHome) - let error = await #expect(throws: CodexOAuthCredentialsError.self) { - try await CodexOAuthFetchStrategy._prepareCredentialsForTesting(credentials) { _ in - Issue.record("An expired read-only credential must not reach a refresh closure") - return credentials - } + let error = #expect(throws: CodexOAuthCredentialsError.self) { + try CodexOAuthFetchStrategy._prepareCredentialsForTesting(credentials) } guard case .readOnlySource = error else { Issue.record("Expired external credentials without a refresh token must fail closed") @@ -228,7 +220,7 @@ struct CodexOAuthCredentialReadTests { } @Test - func `valid read-only oauth credentials pass through without refresh`() async throws { + func `valid read-only oauth credentials pass through without refresh`() throws { let credentials = CodexOAuthCredentials( accessToken: "valid-access", refreshToken: "external-refresh", @@ -237,16 +229,29 @@ struct CodexOAuthCredentialReadTests { lastRefresh: Date(), expiresAt: Date().addingTimeInterval(3600), source: .openCode) - let recorder = RefreshInvocationRecorder() - - let resolved = try await CodexOAuthFetchStrategy._prepareCredentialsForTesting(credentials) { _ in - await recorder.record() - return credentials - } + let resolved = try CodexOAuthFetchStrategy._prepareCredentialsForTesting(credentials) #expect(resolved.accessToken == "valid-access") #expect(resolved.source == .openCode) - #expect(await recorder.count() == 0) + } + + @Test + func `expired native oauth credentials fail closed before shared auth publication`() throws { + let credentials = CodexOAuthCredentials( + accessToken: "expired-access", + refreshToken: "native-refresh", + idToken: nil, + accountId: nil, + lastRefresh: Date(timeIntervalSince1970: 0), + source: .codexHome) + + let error = #expect(throws: CodexOAuthCredentialsError.self) { + try CodexOAuthFetchStrategy._prepareCredentialsForTesting(credentials) + } + guard case .readOnlySource = error else { + Issue.record("Stale native credentials must not be published back to Codex auth.json") + return + } } @Test diff --git a/Tests/CodexBarTests/CodexOAuthRefreshCoordinationTests.swift b/Tests/CodexBarTests/CodexOAuthRefreshCoordinationTests.swift deleted file mode 100644 index 0a50e0148e..0000000000 --- a/Tests/CodexBarTests/CodexOAuthRefreshCoordinationTests.swift +++ /dev/null @@ -1,127 +0,0 @@ -import Foundation -import Testing -@testable import CodexBarCore - -#if canImport(FoundationNetworking) -import FoundationNetworking -#endif - -@Suite(.serialized) -struct CodexOAuthRefreshCoordinationTests { - @Test - func `concurrent native refreshes share one token request`() async throws { - let home = try self.makeHome(prefix: "codex-oauth-refresh-single-flight") - defer { try? FileManager.default.removeItem(at: home) } - let environment = ["CODEX_HOME": home.path] - try self.writeExpiredCredentials(to: home) - let initial = try CodexOAuthCredentialsStore.load(env: environment) - let calls = RefreshCallCounter() - let transport = self.makeRefreshTransport(calls: calls) - - let results = try await CodexAuthenticatedHTTPTransport.$overrideForTesting - .withValue(transport) { - try await withThrowingTaskGroup(of: CodexOAuthCredentials.self) { group in - for _ in 0..<2 { - group.addTask { - try await CodexTokenRefresher.refreshAndPersist(initial, env: environment) - } - } - var values: [CodexOAuthCredentials] = [] - for try await value in group { - values.append(value) - } - return values - } - } - - #expect(results.count == 2) - #expect(results.allSatisfy { $0.accessToken == "refreshed-access" }) - #expect(await calls.count() == 1) - let persisted = try CodexOAuthCredentialsStore.load(env: environment) - #expect(persisted.accessToken == "refreshed-access") - #expect(persisted.refreshToken == "refreshed-refresh") - } - - @Test - func `newer native credentials win a refresh generation race`() async throws { - let home = try self.makeHome(prefix: "codex-oauth-refresh-generation") - defer { try? FileManager.default.removeItem(at: home) } - let environment = ["CODEX_HOME": home.path] - try self.writeExpiredCredentials(to: home) - let initial = try CodexOAuthCredentialsStore.load(env: environment) - let calls = RefreshCallCounter() - let transport = ProviderHTTPTransportHandler { _ in - await calls.record() - let newer = CodexOAuthCredentials( - accessToken: "newer-access", - refreshToken: "newer-refresh", - idToken: nil, - accountId: "account-newer", - lastRefresh: Date(), - source: .codexHome) - try CodexOAuthCredentialsStore.save(newer, env: environment) - let response = try #require(HTTPURLResponse( - url: URL(string: "https://auth.openai.com/oauth/token")!, - statusCode: 200, - httpVersion: nil, - headerFields: nil)) - return ( - Data(#"{"access_token":"stale-access","refresh_token":"stale-refresh","expires_in":3600}"#.utf8), - response) - } - - let resolved = try await CodexAuthenticatedHTTPTransport.$overrideForTesting - .withValue(transport) { - try await CodexTokenRefresher.refreshAndPersist(initial, env: environment) - } - - #expect(await calls.count() == 1) - #expect(resolved.accessToken == "newer-access") - #expect(try CodexOAuthCredentialsStore.load(env: environment).accessToken == "newer-access") - } - - private func makeHome(prefix: String) throws -> URL { - let home = FileManager.default.temporaryDirectory - .appendingPathComponent("\(prefix)-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory(at: home, withIntermediateDirectories: true) - return home - } - - private func writeExpiredCredentials(to home: URL) throws { - let auth = #""" - { - "tokens":{"access_token":"expired-access","refresh_token":"expired-refresh"}, - "last_refresh":"2020-01-01T00:00:00Z" - } - """# - try Data(auth.utf8).write(to: home.appendingPathComponent("auth.json")) - } - - private func makeRefreshTransport(calls: RefreshCallCounter) -> any ProviderHTTPTransport { - ProviderHTTPTransportHandler { request in - await calls.record() - try await Task.sleep(nanoseconds: 50_000_000) - let response = try #require(HTTPURLResponse( - url: request.url ?? URL(string: "https://auth.openai.com/oauth/token")!, - statusCode: 200, - httpVersion: nil, - headerFields: nil)) - return ( - Data(#"{"access_token":"refreshed-access","refresh_token":"refreshed-refresh","expires_in":3600}"# - .utf8), - response) - } - } -} - -private actor RefreshCallCounter { - private var value = 0 - - func record() { - self.value += 1 - } - - func count() -> Int { - self.value - } -} diff --git a/Tests/CodexBarTests/CodexOAuthTests.swift b/Tests/CodexBarTests/CodexOAuthTests.swift index 4397efb804..ec6eff7a32 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 diff --git a/docs/codex.md b/docs/codex.md index 9ef75bc55e..bb210d9786 100644 --- a/docs/codex.md +++ b/docs/codex.md @@ -49,9 +49,10 @@ Usage source picker: - 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. External credentials are - read-only: CodexBar never refreshes or writes them back. In Automatic mode an expired external credential lets - the existing CLI fallback run; explicit OAuth mode reports the read-only error instead. +- 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. In Automatic + mode a stale credential lets the existing CLI fallback run; explicit OAuth mode reports the read-only error + instead. Explicit managed-account workspace selection may still update its `account_id` metadata. ### Advanced profile-home accounts - Managed Codex accounts remain the default multi-account path. From 921ac429ad2008bfc55f862b87d861ddddfe1179 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Sat, 15 Aug 2026 15:06:59 +0800 Subject: [PATCH 06/17] Preserve explicit OAuth recovery --- .../CodexOAuth/CodexOAuthCredentials.swift | 5 +- .../Codex/CodexProviderDescriptor.swift | 47 +++++++++++++++++-- .../CodexBaselineCharacterizationTests.swift | 4 +- .../CodexOAuthCredentialReadTests.swift | 6 +-- Tests/CodexBarTests/CodexOAuthTests.swift | 11 ++++- docs/codex.md | 9 ++-- 6 files changed, 67 insertions(+), 15 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthCredentials.swift b/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthCredentials.swift index 9ac4ee17ca..f31a152aa8 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthCredentials.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthCredentials.swift @@ -63,6 +63,7 @@ public enum CodexOAuthCredentialsError: LocalizedError, Sendable { case unreadable case decodeFailed(String) case missingTokens + case nativeRefreshRequired case readOnlySource public var errorDescription: String? { @@ -75,6 +76,8 @@ public enum CodexOAuthCredentialsError: LocalizedError, Sendable { "Failed to decode Codex credentials: \(message)" case .missingTokens: "Codex auth.json exists but contains no tokens." + case .nativeRefreshRequired: + "Codex auth.json needs refresh; retrying through the Codex CLI without writing from CodexBar." case .readOnlySource: "This Codex credential source is read-only and cannot be refreshed in place." } @@ -304,7 +307,7 @@ public enum CodexOAuthCredentialsStore { // invitation to silently substitute another application's session. case .notFound: return true - case .unreadable, .decodeFailed, .missingTokens, .readOnlySource: + case .unreadable, .decodeFailed, .missingTokens, .nativeRefreshRequired, .readOnlySource: return false } } diff --git a/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift index fa4fc2020d..756fade8ec 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift @@ -127,7 +127,7 @@ public enum CodexProviderDescriptor { case .cli: switch context.sourceMode { case .oauth: - return [oauth] + return [oauth, CodexOAuthNativeRefreshCLIStrategy()] case .web: return [web] case .cli: @@ -140,7 +140,7 @@ public enum CodexProviderDescriptor { case .app: switch context.sourceMode { case .oauth: - return [oauth] + return [oauth, CodexOAuthNativeRefreshCLIStrategy()] case .cli: return [cli] case .web: @@ -312,6 +312,30 @@ 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 + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + guard context.sourceMode == .oauth, + 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 @@ -363,7 +387,11 @@ struct CodexOAuthFetchStrategy: ProviderFetchStrategy { guard !credentials.needsRefresh else { // auth.json is owned by Codex CLI and may be replaced by a login at any time. Without // a cross-writer compare-and-swap contract, CodexBar must not refresh and publish new - // OAuth token material into this shared file. Automatic mode can continue with CLI. + // OAuth token material into this shared file. The CLI owns the recovery path for + // native credentials; external sources remain strictly read-only. + if credentials.source == .codexHome { + throw CodexOAuthCredentialsError.nativeRefreshRequired + } throw CodexOAuthCredentialsError.readOnlySource } return credentials @@ -375,7 +403,16 @@ struct CodexOAuthFetchStrategy: ProviderFetchStrategy { } 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` @@ -390,7 +427,7 @@ struct CodexOAuthFetchStrategy: ProviderFetchStrategy { } if let credentialsError = error as? CodexOAuthCredentialsError { switch credentialsError { - case .notFound, .unreadable, .missingTokens, .readOnlySource: + case .notFound, .unreadable, .missingTokens, .nativeRefreshRequired, .readOnlySource: return true case .decodeFailed: return false 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 index cfc0fa87dc..2dcc2c0403 100644 --- a/Tests/CodexBarTests/CodexOAuthCredentialReadTests.swift +++ b/Tests/CodexBarTests/CodexOAuthCredentialReadTests.swift @@ -236,7 +236,7 @@ struct CodexOAuthCredentialReadTests { } @Test - func `expired native oauth credentials fail closed before shared auth publication`() throws { + func `expired native oauth credentials delegate refresh to the CLI before shared publication`() throws { let credentials = CodexOAuthCredentials( accessToken: "expired-access", refreshToken: "native-refresh", @@ -248,8 +248,8 @@ struct CodexOAuthCredentialReadTests { let error = #expect(throws: CodexOAuthCredentialsError.self) { try CodexOAuthFetchStrategy._prepareCredentialsForTesting(credentials) } - guard case .readOnlySource = error else { - Issue.record("Stale native credentials must not be published back to Codex auth.json") + guard case .nativeRefreshRequired = error else { + Issue.record("Stale native credentials must delegate refresh without publishing to Codex auth.json") return } } diff --git a/Tests/CodexBarTests/CodexOAuthTests.swift b/Tests/CodexBarTests/CodexOAuthTests.swift index ec6eff7a32..6512832f7d 100644 --- a/Tests/CodexBarTests/CodexOAuthTests.swift +++ b/Tests/CodexBarTests/CodexOAuthTests.swift @@ -784,15 +784,24 @@ 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 `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 `resolves chat GPT usage URL from config`() { let config = "chatgpt_base_url = \"https://chatgpt.com/backend-api/\"\n" diff --git a/docs/codex.md b/docs/codex.md index bb210d9786..b91689fc0f 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; @@ -51,8 +53,9 @@ Usage source picker: - 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. In Automatic - mode a stale credential lets the existing CLI fallback run; explicit OAuth mode reports the read-only error - instead. Explicit managed-account workspace selection may still update its `account_id` metadata. + mode a stale credential lets the existing CLI fallback run; explicit OAuth mode delegates only stale native + credentials to the same CLI recovery path, while stale external credentials remain read-only errors. Explicit + managed-account workspace selection may still update its `account_id` metadata. ### Advanced profile-home accounts - Managed Codex accounts remain the default multi-account path. From 666a72bfffa9197546c03a93f28f446daeb6ec3e Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Sat, 15 Aug 2026 15:41:14 +0800 Subject: [PATCH 07/17] Gate native OAuth CLI recovery --- .../Codex/CodexProviderDescriptor.swift | 1 + Tests/CodexBarTests/CodexOAuthTests.swift | 34 +++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift index 756fade8ec..cbf8944805 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift @@ -320,6 +320,7 @@ struct CodexOAuthNativeRefreshCLIStrategy: ProviderFetchStrategy { func isAvailable(_ context: ProviderFetchContext) async -> Bool { guard context.sourceMode == .oauth, + CodexCLIUsageStrategy.resolvedBinary(env: context.env) != nil, let credentials = try? CodexOAuthCredentialsStore.loadForUsage( env: context.env, allowExternalSources: context.settings?.codex?.allowExternalOAuthSources == true) diff --git a/Tests/CodexBarTests/CodexOAuthTests.swift b/Tests/CodexBarTests/CodexOAuthTests.swift index 6512832f7d..5cbd246bc4 100644 --- a/Tests/CodexBarTests/CodexOAuthTests.swift +++ b/Tests/CodexBarTests/CodexOAuthTests.swift @@ -802,6 +802,40 @@ struct CodexOAuthTests { #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().isAvailable(context) + #expect(!isAvailable) + } + @Test func `resolves chat GPT usage URL from config`() { let config = "chatgpt_base_url = \"https://chatgpt.com/backend-api/\"\n" From 5cf31027a43f019e0f03c43503f35e3938203065 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Sun, 16 Aug 2026 02:06:06 +0800 Subject: [PATCH 08/17] Keep Codex OAuth refresh in memory --- .../CodexBar/ManagedCodexAccountService.swift | 16 - .../UsageStore+CodexResetCredits.swift | 20 +- Sources/CodexBar/UsageStore+Refresh.swift | 2 +- .../CodexBar/UsageStore+TokenAccounts.swift | 9 +- .../CodexOAuth/CodexOAuthCredentials.swift | 51 +++- .../CodexOAuthInMemoryRefreshCache.swift | 86 ++++++ .../Codex/CodexProviderDescriptor.swift | 70 ++++- .../Codex/CodexProviderSettings.swift | 7 +- .../Codex/CodexProviderSettingsBuilder.swift | 10 +- .../CodexOAuthCredentialReadTests.swift | 289 +++++++++++++++--- Tests/CodexBarTests/CodexOAuthTests.swift | 3 +- .../CodexResetCreditOutcomeTests.swift | 16 + .../ManagedCodexAccountServiceTests.swift | 4 +- .../ProviderArchitectureGatekeeperTests.swift | 42 +-- 14 files changed, 528 insertions(+), 97 deletions(-) create mode 100644 Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthInMemoryRefreshCache.swift 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/UsageStore+CodexResetCredits.swift b/Sources/CodexBar/UsageStore+CodexResetCredits.swift index c9634bdfe2..b7b6c28192 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) } } @@ -68,13 +70,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 +90,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 +98,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) } } 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/Providers/Codex/CodexOAuth/CodexOAuthCredentials.swift b/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthCredentials.swift index f31a152aa8..3989a3df95 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthCredentials.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthCredentials.swift @@ -5,6 +5,7 @@ import Glibc #elseif canImport(Musl) import Musl #endif +import CryptoKit import Foundation public enum CodexOAuthCredentialSource: String, Equatable, Sendable { @@ -289,7 +290,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() @@ -297,6 +302,50 @@ public enum CodexOAuthCredentialsStore { try CredentialFileWriter.writePrivate(data, to: url) } + /// Stable, non-secret identity for the in-memory refresh cache. + /// + /// The refresh token is hashed so the cache key cannot disclose token material if it is ever + /// surfaced by diagnostics. Both token values identify the source snapshot: a CLI login may + /// rotate the access token while retaining the same refresh token, and that new source read + /// must not reuse an older in-memory rotation. + static func refreshCacheKey( + env: [String: String], + source: CodexOAuthCredentialSource, + accessToken: String, + refreshToken: String) -> String + { + let sourceURL: URL = { + switch source { + case .codexHome: + return self.authFilePath(env: env) + case .legacyCodexHome: + return (FileManager.default.homeDirectoryForCurrentUser) + .appendingPathComponent(".config", isDirectory: true) + .appendingPathComponent("codex", isDirectory: true) + .appendingPathComponent("auth.json") + case .openCode: + let root = if let configured = self.nonEmpty(env["XDG_DATA_HOME"]) { + URL(fileURLWithPath: configured, isDirectory: true) + } else { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".local", isDirectory: true) + .appendingPathComponent("share", isDirectory: true) + } + // Provider-specific by design: OpenCode's auth.json lives under its own data directory. + return root + .appendingPathComponent("opencode", isDirectory: true) + .appendingPathComponent("auth.json") + } + }() + var fingerprint = Data(accessToken.utf8) + fingerprint.append(0) + fingerprint.append(contentsOf: Data(refreshToken.utf8)) + let digest = SHA256.hash(data: fingerprint) + .map { String(format: "%02x", $0) } + .joined() + return "\(source.rawValue)|\(sourceURL.standardizedFileURL.path)|\(digest)" + } + private static func shouldTryExternalFallback( _ error: CodexOAuthCredentialsError, env: [String: String]) -> Bool diff --git a/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthInMemoryRefreshCache.swift b/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthInMemoryRefreshCache.swift new file mode 100644 index 0000000000..b4b2f03802 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthInMemoryRefreshCache.swift @@ -0,0 +1,86 @@ +import Foundation + +/// Coordinates OAuth refreshes inside CodexBar without publishing token material to a source file. +/// +/// Codex CLI owns its native `auth.json` and may atomically replace it while a usage probe is in +/// flight. Keeping the rotated credentials in this process avoids both a cross-writer race and a +/// second refresh request from concurrent provider/account probes. A subsequent read with a new +/// source token snapshot (for example after a CLI login) naturally bypasses the old entry. +actor CodexOAuthInMemoryRefreshCache { + static let shared = CodexOAuthInMemoryRefreshCache() + + private struct Entry { + let sourceRefreshToken: String + let credentials: CodexOAuthCredentials + let lastUsed: UInt64 + } + + private let maximumEntries = 16 + private var entries: [String: Entry] = [:] + private var inFlight: [String: Task] = [:] + private var useCounter: UInt64 = 0 + + func refreshIfNeeded( + credentials: CodexOAuthCredentials, + cacheKey: String, + refresh: @escaping @Sendable () async throws -> CodexOAuthCredentials) async throws + -> CodexOAuthCredentials + { + guard !credentials.isAPIKey, credentials.needsRefresh else { return credentials } + guard !credentials.refreshToken.isEmpty else { return credentials } + + if let entry = self.entries[cacheKey], entry.sourceRefreshToken == credentials.refreshToken { + // The source read is stale by construction here. Retain the in-memory rotation until + // the source publishes a new token snapshot; a fresh snapshot has a new cache key. + if !entry.credentials.needsRefresh { + self.useCounter &+= 1 + self.entries[cacheKey] = Entry( + sourceRefreshToken: entry.sourceRefreshToken, + credentials: entry.credentials, + lastUsed: self.useCounter) + return entry.credentials + } + } + + if let task = self.inFlight[cacheKey] { + return try await task.value + } + + let task = Task { try await refresh() } + self.inFlight[cacheKey] = task + do { + let refreshed = try await task.value + self.inFlight.removeValue(forKey: cacheKey) + self.useCounter &+= 1 + self.entries[cacheKey] = Entry( + sourceRefreshToken: credentials.refreshToken, + credentials: refreshed, + lastUsed: self.useCounter) + self.trimToMaximumEntries() + return refreshed + } catch { + self.inFlight.removeValue(forKey: cacheKey) + throw error + } + } + + #if DEBUG + func _resetForTesting() { + self.entries.removeAll() + self.inFlight.removeAll() + self.useCounter = 0 + } + #endif + + private func trimToMaximumEntries() { + guard self.entries.count > self.maximumEntries else { return } + let removeCount = self.entries.count - self.maximumEntries + let keysToRemove = self.entries + .sorted { $0.value.lastUsed < $1.value.lastUsed } + .prefix(removeCount) + .map(\.key) + for key in keysToRemove { + self.entries.removeValue(forKey: key) + } + } +} diff --git a/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift index cbf8944805..533d8c4099 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() @@ -317,10 +324,19 @@ struct CodexCLIUsageStrategy: ProviderFetchStrategy { 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 { guard context.sourceMode == .oauth, - CodexCLIUsageStrategy.resolvedBinary(env: context.env) != nil, + self.binaryResolver(context) != nil, let credentials = try? CodexOAuthCredentialsStore.loadForUsage( env: context.env, allowExternalSources: context.settings?.codex?.allowExternalOAuthSources == true) @@ -358,7 +374,22 @@ struct CodexOAuthFetchStrategy: ProviderFetchStrategy { context: ProviderFetchContext, credentials initialCredentials: CodexOAuthCredentials) async throws -> ProviderFetchResult { - let credentials = try Self.prepareCredentialsForUsage(initialCredentials) + 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, @@ -383,19 +414,35 @@ struct CodexOAuthFetchStrategy: ProviderFetchStrategy { } private static func prepareCredentialsForUsage( - _ credentials: CodexOAuthCredentials) throws -> CodexOAuthCredentials + _ credentials: CodexOAuthCredentials, + env: [String: String]) async throws -> CodexOAuthCredentials { - guard !credentials.needsRefresh else { - // auth.json is owned by Codex CLI and may be replaced by a login at any time. Without - // a cross-writer compare-and-swap contract, CodexBar must not refresh and publish new - // OAuth token material into this shared file. The CLI owns the recovery path for - // native credentials; external sources remain strictly read-only. + guard credentials.needsRefresh else { return credentials } + guard !credentials.refreshToken.isEmpty else { + // A native credential without a refresh token can still be repaired by the Codex CLI. + // External sources have no safe writer handoff, so they fail closed. if credentials.source == .codexHome { throw CodexOAuthCredentialsError.nativeRefreshRequired } throw CodexOAuthCredentialsError.readOnlySource } - return credentials + + let cacheKey = CodexOAuthCredentialsStore.refreshCacheKey( + env: env, + source: credentials.source, + accessToken: credentials.accessToken, + refreshToken: credentials.refreshToken) + do { + return try await CodexOAuthInMemoryRefreshCache.shared.refreshIfNeeded( + credentials: credentials, + cacheKey: cacheKey, + refresh: { try await CodexTokenRefresher.refresh(credentials) }) + } catch is CodexTokenRefresher.RefreshError where credentials.source == .codexHome { + // If the native refresh endpoint rejects the token, retain the existing explicit-OAuth + // CLI handoff. The CLI is optional for the normal in-memory refresh path, but remains + // the recovery authority for revoked/invalid native credentials. + throw CodexOAuthCredentialsError.nativeRefreshRequired + } } private static func shouldFetchResetCredits(_ context: ProviderFetchContext) -> Bool { @@ -703,9 +750,10 @@ extension CodexOAuthFetchStrategy { } static func _prepareCredentialsForTesting( - _ credentials: CodexOAuthCredentials) throws -> CodexOAuthCredentials + _ credentials: CodexOAuthCredentials, + env: [String: String] = [:]) async throws -> CodexOAuthCredentials { - try self.prepareCredentialsForUsage(credentials) + try await self.prepareCredentialsForUsage(credentials, env: env) } static func _applySpendControlsMonthlyLimitForTesting( diff --git a/Sources/CodexBarCore/Providers/Codex/CodexProviderSettings.swift b/Sources/CodexBarCore/Providers/Codex/CodexProviderSettings.swift index 8163a82ccb..58bec97bae 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexProviderSettings.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexProviderSettings.swift @@ -10,6 +10,9 @@ public struct CodexProviderSettings: Sendable { 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, @@ -20,7 +23,8 @@ public struct CodexProviderSettings: Sendable { profileAccountTargetUnavailable: Bool = false, openAIWebCacheScope: CookieHeaderCache.Scope? = nil, dashboardAuthorityKnownOwners: [CodexDashboardKnownOwnerCandidate] = [], - allowExternalOAuthSources: Bool = false) + allowExternalOAuthSources: Bool = false, + managedWorkspaceAccountID: String? = nil) { self.usageDataSource = usageDataSource self.cookieSource = cookieSource @@ -31,6 +35,7 @@ public struct CodexProviderSettings: Sendable { 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 90902f7b16..8cef5c7bf2 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexProviderSettingsBuilder.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexProviderSettingsBuilder.swift @@ -80,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, @@ -92,6 +99,7 @@ public enum CodexProviderSettingsBuilder { profileAccountTargetUnavailable: profileAccountTargetUnavailable, openAIWebCacheScope: openAIWebCacheScope, dashboardAuthorityKnownOwners: CodexKnownOwnerCatalog.candidates(from: snapshot), - allowExternalOAuthSources: input.allowExternalOAuthSources) + allowExternalOAuthSources: input.allowExternalOAuthSources, + managedWorkspaceAccountID: managedWorkspaceAccountID) } } diff --git a/Tests/CodexBarTests/CodexOAuthCredentialReadTests.swift b/Tests/CodexBarTests/CodexOAuthCredentialReadTests.swift index 2dcc2c0403..44d03fd997 100644 --- a/Tests/CodexBarTests/CodexOAuthCredentialReadTests.swift +++ b/Tests/CodexBarTests/CodexOAuthCredentialReadTests.swift @@ -150,7 +150,7 @@ struct CodexOAuthCredentialReadTests { } @Test - func `expired read-only oauth credentials fail before refresh`() throws { + func `expired read-only oauth credentials refresh in memory`() async throws { let credentials = CodexOAuthCredentials( accessToken: "expired-access", refreshToken: "external-refresh", @@ -159,18 +159,31 @@ struct CodexOAuthCredentialReadTests { lastRefresh: nil, expiresAt: Date().addingTimeInterval(-1), source: .openCode) - - let error = #expect(throws: CodexOAuthCredentialsError.self) { - try CodexOAuthFetchStrategy._prepareCredentialsForTesting(credentials) + let transport = ProviderHTTPTransportStub { request in + #expect(request.url?.absoluteString == "https://auth.openai.com/oauth/token") + guard let url = request.url, + let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: nil) + else { throw URLError(.badURL) } + return (Data(#"{"access_token":"refreshed-access","refresh_token":"refreshed-refresh"}"#.utf8), response) } - guard case .readOnlySource = error else { - Issue.record("Expired external credentials must fail before refresh") - return + + let resolved = try await CodexAuthenticatedHTTPTransport.$overrideForTesting.withValue(transport) { + try await CodexOAuthFetchStrategy._prepareCredentialsForTesting( + credentials, + env: ["XDG_DATA_HOME": FileManager.default.temporaryDirectory.path]) } + + #expect(resolved.accessToken == "refreshed-access") + #expect(resolved.refreshToken == "refreshed-refresh") + #expect(resolved.source == .openCode) } @Test - func `expired read-only oauth credentials without a refresh token are rejected`() throws { + func `expired read-only oauth credentials without a refresh token are rejected`() async throws { let credentials = CodexOAuthCredentials( accessToken: "expired-access", refreshToken: "", @@ -180,8 +193,8 @@ struct CodexOAuthCredentialReadTests { expiresAt: Date().addingTimeInterval(-1), source: .legacyCodexHome) - let error = #expect(throws: CodexOAuthCredentialsError.self) { - try CodexOAuthFetchStrategy._prepareCredentialsForTesting(credentials) + 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") @@ -190,7 +203,22 @@ struct CodexOAuthCredentialReadTests { } @Test - func `expired read-only oauth credentials fail before the fetch sends a request`() async throws { + func `expired external oauth fetch refreshes 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 = CodexOAuthCredentials( accessToken: "expired-access", refreshToken: "external-refresh", @@ -199,28 +227,52 @@ struct CodexOAuthCredentialReadTests { lastRefresh: nil, expiresAt: Date().addingTimeInterval(-1), source: .openCode) - let requests = RefreshInvocationRecorder() - let transport = ProviderHTTPTransportStub { _ in - await requests.record() - throw URLError(.badServerResponse) - } - - let error = await #expect(throws: CodexOAuthCredentialsError.self) { - try await CodexAuthenticatedHTTPTransport.$overrideForTesting.withValue(transport) { - try await CodexOAuthFetchStrategy._fetchForTesting( - context: Self.context(), - credentials: credentials) + let settings = ProviderSettingsSnapshot.make(codex: CodexProviderSettings( + usageDataSource: .oauth, + cookieSource: .off, + manualCookieHeader: nil, + allowExternalOAuthSources: true)) + let transport = ProviderHTTPTransportStub { request in + if request.url?.absoluteString == "https://auth.openai.com/oauth/token" { + guard let url = request.url, + let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: nil) + else { throw URLError(.badURL) } + return ( + Data(#"{"access_token":"refreshed-access","refresh_token":"refreshed-refresh"}"#.utf8), + response) } + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer refreshed-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}}} + """# + return (Data(body.utf8), response) } - guard case .readOnlySource = error else { - Issue.record("The fetch path must reject expired external credentials before transport") - return + + 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(await requests.count() == 0) + + #expect(result.usage.primary?.usedPercent == 12) + #expect(try Data(contentsOf: authURL) == authData) } @Test - func `valid read-only oauth credentials pass through without refresh`() throws { + func `valid read-only oauth credentials pass through without refresh`() async throws { let credentials = CodexOAuthCredentials( accessToken: "valid-access", refreshToken: "external-refresh", @@ -229,14 +281,52 @@ struct CodexOAuthCredentialReadTests { lastRefresh: Date(), expiresAt: Date().addingTimeInterval(3600), source: .openCode) - let resolved = try CodexOAuthFetchStrategy._prepareCredentialsForTesting(credentials) + let resolved = try await CodexOAuthFetchStrategy._prepareCredentialsForTesting(credentials) #expect(resolved.accessToken == "valid-access") #expect(resolved.source == .openCode) } @Test - func `expired native oauth credentials delegate refresh to the CLI before shared publication`() throws { + 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 refresh in memory before shared publication`() async throws { let credentials = CodexOAuthCredentials( accessToken: "expired-access", refreshToken: "native-refresh", @@ -244,14 +334,123 @@ struct CodexOAuthCredentialReadTests { accountId: nil, lastRefresh: Date(timeIntervalSince1970: 0), source: .codexHome) + let transport = ProviderHTTPTransportStub { request in + #expect(request.url?.absoluteString == "https://auth.openai.com/oauth/token") + guard let url = request.url, + let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: nil) + else { throw URLError(.badURL) } + return (Data(#"{"access_token":"native-refreshed","refresh_token":"native-refresh-next"}"#.utf8), response) + } - let error = #expect(throws: CodexOAuthCredentialsError.self) { - try CodexOAuthFetchStrategy._prepareCredentialsForTesting(credentials) + let resolved = try await CodexAuthenticatedHTTPTransport.$overrideForTesting.withValue(transport) { + try await CodexOAuthFetchStrategy._prepareCredentialsForTesting( + credentials, + env: ["CODEX_HOME": "/tmp/codexbar-native-refresh-memory"]) } - guard case .nativeRefreshRequired = error else { - Issue.record("Stale native credentials must delegate refresh without publishing to Codex auth.json") - return + + #expect(resolved.accessToken == "native-refreshed") + #expect(resolved.refreshToken == "native-refresh-next") + #expect(resolved.source == .codexHome) + } + + @Test + func `concurrent stale native probes share one in-memory refresh`() async throws { + let credentials = CodexOAuthCredentials( + accessToken: "expired-access", + refreshToken: "native-refresh-concurrent", + idToken: nil, + accountId: nil, + lastRefresh: Date(timeIntervalSince1970: 0), + source: .codexHome) + let home = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-oauth-concurrent-\(UUID().uuidString)", isDirectory: true) + let recorder = RefreshInvocationRecorder() + let transport = ProviderHTTPTransportStub { request in + await recorder.record() + try await Task.sleep(for: .milliseconds(50)) + guard let url = request.url, + let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: nil) + else { throw URLError(.badURL) } + return (Data(#"{"access_token":"concurrent-access","refresh_token":"concurrent-refresh"}"#.utf8), response) + } + + let values = try await CodexAuthenticatedHTTPTransport.$overrideForTesting.withValue(transport) { + try await withThrowingTaskGroup( + of: CodexOAuthCredentials.self, + returning: [CodexOAuthCredentials].self) + { group in + for _ in 0..<2 { + group.addTask { + try await CodexOAuthFetchStrategy._prepareCredentialsForTesting( + credentials, + env: ["CODEX_HOME": home.path]) + } + } + var values: [CodexOAuthCredentials] = [] + for try await value in group { + values.append(value) + } + return values + } + } + + #expect(values.count == 2) + #expect(values.allSatisfy { $0.accessToken == "concurrent-access" }) + #expect(await recorder.count() == 1) + } + + @Test + func `new source access token bypasses an older cached rotation`() async throws { + let env = [ + "CODEX_HOME": FileManager.default.temporaryDirectory + .appendingPathComponent("codex-oauth-access-rotation-\(UUID().uuidString)").path, + ] + let firstCredentials = CodexOAuthCredentials( + accessToken: "expired-access-before-login", + refreshToken: "same-refresh-token", + idToken: nil, + accountId: nil, + lastRefresh: Date(timeIntervalSince1970: 0), + source: .codexHome) + let secondCredentials = CodexOAuthCredentials( + accessToken: "expired-access-after-login", + refreshToken: "same-refresh-token", + idToken: nil, + accountId: nil, + lastRefresh: Date(timeIntervalSince1970: 0), + source: .codexHome) + let recorder = RefreshInvocationRecorder() + let transport = ProviderHTTPTransportStub { request in + await recorder.record() + guard let url = request.url, + let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: nil) + else { throw URLError(.badURL) } + let count = await recorder.count() + let accessToken = count == 1 ? "rotated-before-login" : "rotated-after-login" + return ( + Data(#"{"access_token":"\#(accessToken)","refresh_token":"same-refresh-token"}"#.utf8), + response) } + + let resolved = try await CodexAuthenticatedHTTPTransport.$overrideForTesting.withValue(transport) { + _ = try await CodexOAuthFetchStrategy._prepareCredentialsForTesting(firstCredentials, env: env) + return try await CodexOAuthFetchStrategy._prepareCredentialsForTesting(secondCredentials, env: env) + } + + #expect(resolved.accessToken == "rotated-after-login") + #expect(await recorder.count() == 2) } @Test @@ -330,6 +529,28 @@ struct CodexOAuthCredentialReadTests { } } + @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 diff --git a/Tests/CodexBarTests/CodexOAuthTests.swift b/Tests/CodexBarTests/CodexOAuthTests.swift index 5cbd246bc4..12f065fa02 100644 --- a/Tests/CodexBarTests/CodexOAuthTests.swift +++ b/Tests/CodexBarTests/CodexOAuthTests.swift @@ -832,7 +832,8 @@ struct CodexOAuthTests { claudeFetcher: context.claudeFetcher, browserDetection: context.browserDetection) - let isAvailable = await CodexOAuthNativeRefreshCLIStrategy().isAvailable(context) + let isAvailable = await CodexOAuthNativeRefreshCLIStrategy(binaryResolver: { _ in nil }) + .isAvailable(context) #expect(!isAvailable) } diff --git a/Tests/CodexBarTests/CodexResetCreditOutcomeTests.swift b/Tests/CodexBarTests/CodexResetCreditOutcomeTests.swift index 6aae6f23e2..30046fced9 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) 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/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index ab16c6f4d1..d565325351 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."), @@ -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, From b0c62e8cc745ee8a8d79d7b62fa9e58ca632e2c7 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Sun, 16 Aug 2026 02:33:24 +0800 Subject: [PATCH 09/17] Keep shared OAuth refresh owner-safe --- .../CodexOAuth/CodexOAuthCredentials.swift | 45 ---- .../CodexOAuthInMemoryRefreshCache.swift | 86 ------- .../Codex/CodexProviderDescriptor.swift | 45 ++-- .../CodexOAuthCredentialReadTests.swift | 217 +++--------------- .../CodexOAuthResetCreditFetchTests.swift | 10 +- 5 files changed, 59 insertions(+), 344 deletions(-) delete mode 100644 Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthInMemoryRefreshCache.swift diff --git a/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthCredentials.swift b/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthCredentials.swift index 3989a3df95..d2529f029e 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthCredentials.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthCredentials.swift @@ -5,7 +5,6 @@ import Glibc #elseif canImport(Musl) import Musl #endif -import CryptoKit import Foundation public enum CodexOAuthCredentialSource: String, Equatable, Sendable { @@ -302,50 +301,6 @@ public enum CodexOAuthCredentialsStore { try CredentialFileWriter.writePrivate(data, to: url) } - /// Stable, non-secret identity for the in-memory refresh cache. - /// - /// The refresh token is hashed so the cache key cannot disclose token material if it is ever - /// surfaced by diagnostics. Both token values identify the source snapshot: a CLI login may - /// rotate the access token while retaining the same refresh token, and that new source read - /// must not reuse an older in-memory rotation. - static func refreshCacheKey( - env: [String: String], - source: CodexOAuthCredentialSource, - accessToken: String, - refreshToken: String) -> String - { - let sourceURL: URL = { - switch source { - case .codexHome: - return self.authFilePath(env: env) - case .legacyCodexHome: - return (FileManager.default.homeDirectoryForCurrentUser) - .appendingPathComponent(".config", isDirectory: true) - .appendingPathComponent("codex", isDirectory: true) - .appendingPathComponent("auth.json") - case .openCode: - let root = if let configured = self.nonEmpty(env["XDG_DATA_HOME"]) { - URL(fileURLWithPath: configured, isDirectory: true) - } else { - FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".local", isDirectory: true) - .appendingPathComponent("share", isDirectory: true) - } - // Provider-specific by design: OpenCode's auth.json lives under its own data directory. - return root - .appendingPathComponent("opencode", isDirectory: true) - .appendingPathComponent("auth.json") - } - }() - var fingerprint = Data(accessToken.utf8) - fingerprint.append(0) - fingerprint.append(contentsOf: Data(refreshToken.utf8)) - let digest = SHA256.hash(data: fingerprint) - .map { String(format: "%02x", $0) } - .joined() - return "\(source.rawValue)|\(sourceURL.standardizedFileURL.path)|\(digest)" - } - private static func shouldTryExternalFallback( _ error: CodexOAuthCredentialsError, env: [String: String]) -> Bool diff --git a/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthInMemoryRefreshCache.swift b/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthInMemoryRefreshCache.swift deleted file mode 100644 index b4b2f03802..0000000000 --- a/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthInMemoryRefreshCache.swift +++ /dev/null @@ -1,86 +0,0 @@ -import Foundation - -/// Coordinates OAuth refreshes inside CodexBar without publishing token material to a source file. -/// -/// Codex CLI owns its native `auth.json` and may atomically replace it while a usage probe is in -/// flight. Keeping the rotated credentials in this process avoids both a cross-writer race and a -/// second refresh request from concurrent provider/account probes. A subsequent read with a new -/// source token snapshot (for example after a CLI login) naturally bypasses the old entry. -actor CodexOAuthInMemoryRefreshCache { - static let shared = CodexOAuthInMemoryRefreshCache() - - private struct Entry { - let sourceRefreshToken: String - let credentials: CodexOAuthCredentials - let lastUsed: UInt64 - } - - private let maximumEntries = 16 - private var entries: [String: Entry] = [:] - private var inFlight: [String: Task] = [:] - private var useCounter: UInt64 = 0 - - func refreshIfNeeded( - credentials: CodexOAuthCredentials, - cacheKey: String, - refresh: @escaping @Sendable () async throws -> CodexOAuthCredentials) async throws - -> CodexOAuthCredentials - { - guard !credentials.isAPIKey, credentials.needsRefresh else { return credentials } - guard !credentials.refreshToken.isEmpty else { return credentials } - - if let entry = self.entries[cacheKey], entry.sourceRefreshToken == credentials.refreshToken { - // The source read is stale by construction here. Retain the in-memory rotation until - // the source publishes a new token snapshot; a fresh snapshot has a new cache key. - if !entry.credentials.needsRefresh { - self.useCounter &+= 1 - self.entries[cacheKey] = Entry( - sourceRefreshToken: entry.sourceRefreshToken, - credentials: entry.credentials, - lastUsed: self.useCounter) - return entry.credentials - } - } - - if let task = self.inFlight[cacheKey] { - return try await task.value - } - - let task = Task { try await refresh() } - self.inFlight[cacheKey] = task - do { - let refreshed = try await task.value - self.inFlight.removeValue(forKey: cacheKey) - self.useCounter &+= 1 - self.entries[cacheKey] = Entry( - sourceRefreshToken: credentials.refreshToken, - credentials: refreshed, - lastUsed: self.useCounter) - self.trimToMaximumEntries() - return refreshed - } catch { - self.inFlight.removeValue(forKey: cacheKey) - throw error - } - } - - #if DEBUG - func _resetForTesting() { - self.entries.removeAll() - self.inFlight.removeAll() - self.useCounter = 0 - } - #endif - - private func trimToMaximumEntries() { - guard self.entries.count > self.maximumEntries else { return } - let removeCount = self.entries.count - self.maximumEntries - let keysToRemove = self.entries - .sorted { $0.value.lastUsed < $1.value.lastUsed } - .prefix(removeCount) - .map(\.key) - for key in keysToRemove { - self.entries.removeValue(forKey: key) - } - } -} diff --git a/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift index 533d8c4099..d6ab6bddca 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift @@ -415,39 +415,32 @@ struct CodexOAuthFetchStrategy: ProviderFetchStrategy { private static func prepareCredentialsForUsage( _ credentials: CodexOAuthCredentials, - env: [String: String]) async throws -> CodexOAuthCredentials + env _: [String: String]) async throws -> CodexOAuthCredentials { guard credentials.needsRefresh else { return credentials } - guard !credentials.refreshToken.isEmpty else { - // A native credential without a refresh token can still be repaired by the Codex CLI. - // External sources have no safe writer handoff, so they fail closed. - if credentials.source == .codexHome { - throw CodexOAuthCredentialsError.nativeRefreshRequired - } - throw CodexOAuthCredentialsError.readOnlySource - } - - let cacheKey = CodexOAuthCredentialsStore.refreshCacheKey( - env: env, - source: credentials.source, - accessToken: credentials.accessToken, - refreshToken: credentials.refreshToken) - do { - return try await CodexOAuthInMemoryRefreshCache.shared.refreshIfNeeded( - credentials: credentials, - cacheKey: cacheKey, - refresh: { try await CodexTokenRefresher.refresh(credentials) }) - } catch is CodexTokenRefresher.RefreshError where credentials.source == .codexHome { - // If the native refresh endpoint rejects the token, retain the existing explicit-OAuth - // CLI handoff. The CLI is optional for the normal in-memory refresh path, but remains - // the recovery authority for revoked/invalid native 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 { diff --git a/Tests/CodexBarTests/CodexOAuthCredentialReadTests.swift b/Tests/CodexBarTests/CodexOAuthCredentialReadTests.swift index 44d03fd997..9bfd097b26 100644 --- a/Tests/CodexBarTests/CodexOAuthCredentialReadTests.swift +++ b/Tests/CodexBarTests/CodexOAuthCredentialReadTests.swift @@ -150,7 +150,7 @@ struct CodexOAuthCredentialReadTests { } @Test - func `expired read-only oauth credentials refresh in memory`() async throws { + func `expired read-only oauth credentials fail closed without refresh`() async throws { let credentials = CodexOAuthCredentials( accessToken: "expired-access", refreshToken: "external-refresh", @@ -159,27 +159,13 @@ struct CodexOAuthCredentialReadTests { lastRefresh: nil, expiresAt: Date().addingTimeInterval(-1), source: .openCode) - let transport = ProviderHTTPTransportStub { request in - #expect(request.url?.absoluteString == "https://auth.openai.com/oauth/token") - guard let url = request.url, - let response = HTTPURLResponse( - url: url, - statusCode: 200, - httpVersion: nil, - headerFields: nil) - else { throw URLError(.badURL) } - return (Data(#"{"access_token":"refreshed-access","refresh_token":"refreshed-refresh"}"#.utf8), response) + let error = await #expect(throws: CodexOAuthCredentialsError.self) { + try await CodexOAuthFetchStrategy._prepareCredentialsForTesting(credentials) } - - let resolved = try await CodexAuthenticatedHTTPTransport.$overrideForTesting.withValue(transport) { - try await CodexOAuthFetchStrategy._prepareCredentialsForTesting( - credentials, - env: ["XDG_DATA_HOME": FileManager.default.temporaryDirectory.path]) + guard case .readOnlySource = error else { + Issue.record("Expired external credentials must not consume an owner refresh token") + return } - - #expect(resolved.accessToken == "refreshed-access") - #expect(resolved.refreshToken == "refreshed-refresh") - #expect(resolved.source == .openCode) } @Test @@ -203,7 +189,7 @@ struct CodexOAuthCredentialReadTests { } @Test - func `expired external oauth fetch refreshes without mutating its source`() async throws { + 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 @@ -219,55 +205,19 @@ struct CodexOAuthCredentialReadTests { .utf8) let authURL = openCodeDirectory.appendingPathComponent("auth.json") try authData.write(to: authURL) - let credentials = CodexOAuthCredentials( - accessToken: "expired-access", - refreshToken: "external-refresh", - idToken: nil, - accountId: nil, - lastRefresh: nil, - expiresAt: Date().addingTimeInterval(-1), - source: .openCode) - let settings = ProviderSettingsSnapshot.make(codex: CodexProviderSettings( - usageDataSource: .oauth, - cookieSource: .off, - manualCookieHeader: nil, - allowExternalOAuthSources: true)) - let transport = ProviderHTTPTransportStub { request in - if request.url?.absoluteString == "https://auth.openai.com/oauth/token" { - guard let url = request.url, - let response = HTTPURLResponse( - url: url, - statusCode: 200, - httpVersion: nil, - headerFields: nil) - else { throw URLError(.badURL) } - return ( - Data(#"{"access_token":"refreshed-access","refresh_token":"refreshed-refresh"}"#.utf8), - response) - } - #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer refreshed-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}}} - """# - return (Data(body.utf8), response) + 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]) } - - 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) + guard case .readOnlySource = error else { + Issue.record("Expired external credentials must fail closed") + return } - - #expect(result.usage.primary?.usedPercent == 12) #expect(try Data(contentsOf: authURL) == authData) } @@ -326,7 +276,7 @@ struct CodexOAuthCredentialReadTests { } @Test - func `expired native oauth credentials refresh in memory before shared publication`() async throws { + func `expired native oauth credentials delegate refresh to the Codex CLI`() async throws { let credentials = CodexOAuthCredentials( accessToken: "expired-access", refreshToken: "native-refresh", @@ -334,123 +284,38 @@ struct CodexOAuthCredentialReadTests { accountId: nil, lastRefresh: Date(timeIntervalSince1970: 0), source: .codexHome) - let transport = ProviderHTTPTransportStub { request in - #expect(request.url?.absoluteString == "https://auth.openai.com/oauth/token") - guard let url = request.url, - let response = HTTPURLResponse( - url: url, - statusCode: 200, - httpVersion: nil, - headerFields: nil) - else { throw URLError(.badURL) } - return (Data(#"{"access_token":"native-refreshed","refresh_token":"native-refresh-next"}"#.utf8), response) - } - - let resolved = try await CodexAuthenticatedHTTPTransport.$overrideForTesting.withValue(transport) { + let error = await #expect(throws: CodexOAuthCredentialsError.self) { try await CodexOAuthFetchStrategy._prepareCredentialsForTesting( credentials, env: ["CODEX_HOME": "/tmp/codexbar-native-refresh-memory"]) } - - #expect(resolved.accessToken == "native-refreshed") - #expect(resolved.refreshToken == "native-refresh-next") - #expect(resolved.source == .codexHome) + guard case .nativeRefreshRequired = error else { + Issue.record("Native stale credentials must be handed to Codex CLI") + return + } } @Test - func `concurrent stale native probes share one in-memory refresh`() async throws { + func `stale native probes never redeem a shared refresh token`() async throws { let credentials = CodexOAuthCredentials( accessToken: "expired-access", - refreshToken: "native-refresh-concurrent", + refreshToken: "native-refresh", idToken: nil, accountId: nil, lastRefresh: Date(timeIntervalSince1970: 0), source: .codexHome) - let home = FileManager.default.temporaryDirectory - .appendingPathComponent("codex-oauth-concurrent-\(UUID().uuidString)", isDirectory: true) - let recorder = RefreshInvocationRecorder() - let transport = ProviderHTTPTransportStub { request in - await recorder.record() - try await Task.sleep(for: .milliseconds(50)) - guard let url = request.url, - let response = HTTPURLResponse( - url: url, - statusCode: 200, - httpVersion: nil, - headerFields: nil) - else { throw URLError(.badURL) } - return (Data(#"{"access_token":"concurrent-access","refresh_token":"concurrent-refresh"}"#.utf8), response) - } - - let values = try await CodexAuthenticatedHTTPTransport.$overrideForTesting.withValue(transport) { - try await withThrowingTaskGroup( - of: CodexOAuthCredentials.self, - returning: [CodexOAuthCredentials].self) - { group in - for _ in 0..<2 { - group.addTask { - try await CodexOAuthFetchStrategy._prepareCredentialsForTesting( - credentials, - env: ["CODEX_HOME": home.path]) - } - } - var values: [CodexOAuthCredentials] = [] - for try await value in group { - values.append(value) - } - return values - } + let first = await #expect(throws: CodexOAuthCredentialsError.self) { + try await CodexOAuthFetchStrategy._prepareCredentialsForTesting(credentials) } - - #expect(values.count == 2) - #expect(values.allSatisfy { $0.accessToken == "concurrent-access" }) - #expect(await recorder.count() == 1) - } - - @Test - func `new source access token bypasses an older cached rotation`() async throws { - let env = [ - "CODEX_HOME": FileManager.default.temporaryDirectory - .appendingPathComponent("codex-oauth-access-rotation-\(UUID().uuidString)").path, - ] - let firstCredentials = CodexOAuthCredentials( - accessToken: "expired-access-before-login", - refreshToken: "same-refresh-token", - idToken: nil, - accountId: nil, - lastRefresh: Date(timeIntervalSince1970: 0), - source: .codexHome) - let secondCredentials = CodexOAuthCredentials( - accessToken: "expired-access-after-login", - refreshToken: "same-refresh-token", - idToken: nil, - accountId: nil, - lastRefresh: Date(timeIntervalSince1970: 0), - source: .codexHome) - let recorder = RefreshInvocationRecorder() - let transport = ProviderHTTPTransportStub { request in - await recorder.record() - guard let url = request.url, - let response = HTTPURLResponse( - url: url, - statusCode: 200, - httpVersion: nil, - headerFields: nil) - else { throw URLError(.badURL) } - let count = await recorder.count() - let accessToken = count == 1 ? "rotated-before-login" : "rotated-after-login" - return ( - Data(#"{"access_token":"\#(accessToken)","refresh_token":"same-refresh-token"}"#.utf8), - response) + let second = await #expect(throws: CodexOAuthCredentialsError.self) { + try await CodexOAuthFetchStrategy._prepareCredentialsForTesting(credentials) } - - let resolved = try await CodexAuthenticatedHTTPTransport.$overrideForTesting.withValue(transport) { - _ = try await CodexOAuthFetchStrategy._prepareCredentialsForTesting(firstCredentials, env: env) - return try await CodexOAuthFetchStrategy._prepareCredentialsForTesting(secondCredentials, env: env) + guard case .nativeRefreshRequired = first, + case .nativeRefreshRequired = second + else { + Issue.record("Every stale native probe must hand refresh to Codex CLI") + return } - - #expect(resolved.accessToken == "rotated-after-login") - #expect(await recorder.count() == 2) } @Test @@ -852,15 +717,3 @@ struct CodexOAuthCredentialReadTests { return "\(header).\(body).signature" } } - -private actor RefreshInvocationRecorder { - private var invocations = 0 - - func record() { - self.invocations += 1 - } - - func count() -> Int { - self.invocations - } -} 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)) } From c7ab5e5094c039f3cb33e81198eac6cc00d77055 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Sun, 16 Aug 2026 04:05:16 +0800 Subject: [PATCH 10/17] Align OAuth guide with read-only auth --- docs/codex-oauth.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/codex-oauth.md b/docs/codex-oauth.md index 85211d9d1a..57b64411be 100644 --- a/docs/codex-oauth.md +++ b/docs/codex-oauth.md @@ -527,13 +527,13 @@ struct CodexOAuthFetchStrategy: ProviderFetchStrategy { } func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { - var creds = try CodexOAuthCredentialsStore.load() + let creds = try CodexOAuthCredentialsStore.loadForUsage( + env: context.env, + allowExternalSources: context.settings?.codex?.allowExternalOAuthSources == true) - // Refresh if needed (8+ days old) - if creds.needsRefresh && !creds.refreshToken.isEmpty { - creds = try await CodexTokenRefresher.refresh(creds) - try CodexOAuthCredentialsStore.save(creds) - } + // The usage path never refreshes or saves credentials. Codex CLI owns the native + // auth.json refresh lifecycle; stale native credentials delegate to CLI recovery, while + // stale legacy/OpenCode credentials fail closed because those files are read-only. let usage = try await CodexOAuthUsageFetcher.fetchUsage( accessToken: creds.accessToken, From 47d03bedc18f6dc1bf408389fb71dc7f456b88f5 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Sun, 16 Aug 2026 05:18:21 +0800 Subject: [PATCH 11/17] Skip blank JWT organization IDs --- .../CodexOAuth/CodexOAuthCredentials.swift | 4 +++- .../CodexOAuthCredentialReadTests.swift | 17 +++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthCredentials.swift b/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthCredentials.swift index d2529f029e..694536263c 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthCredentials.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthCredentials.swift @@ -441,7 +441,9 @@ public enum CodexOAuthCredentialsStore { return accountID } if let organizations = payload["organizations"] as? [[String: Any]], - let accountID = Self.nonEmpty(organizations.first?["id"] as? String) + let accountID = organizations + .compactMap({ Self.nonEmpty($0["id"] as? String) }) + .first { return accountID } diff --git a/Tests/CodexBarTests/CodexOAuthCredentialReadTests.swift b/Tests/CodexBarTests/CodexOAuthCredentialReadTests.swift index 9bfd097b26..7933b02ce0 100644 --- a/Tests/CodexBarTests/CodexOAuthCredentialReadTests.swift +++ b/Tests/CodexBarTests/CodexOAuthCredentialReadTests.swift @@ -102,6 +102,23 @@ struct CodexOAuthCredentialReadTests { #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) From 15acee507d6ec337ef11c7c22c80d270bb2503a3 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Sun, 16 Aug 2026 06:19:17 +0800 Subject: [PATCH 12/17] Treat whitespace account IDs as absent --- .../CodexOAuth/CodexOAuthCredentials.swift | 3 ++- .../CodexOAuthCredentialReadTests.swift | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthCredentials.swift b/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthCredentials.swift index 694536263c..e85e18e413 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthCredentials.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthCredentials.swift @@ -229,7 +229,8 @@ 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"]) diff --git a/Tests/CodexBarTests/CodexOAuthCredentialReadTests.swift b/Tests/CodexBarTests/CodexOAuthCredentialReadTests.swift index 7933b02ce0..f3c7ae9de7 100644 --- a/Tests/CodexBarTests/CodexOAuthCredentialReadTests.swift +++ b/Tests/CodexBarTests/CodexOAuthCredentialReadTests.swift @@ -85,6 +85,24 @@ struct CodexOAuthCredentialReadTests { #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: [ From 273c9a40c1485892d1d9d75decdacddaf8fa4aa8 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Sun, 16 Aug 2026 07:09:39 +0800 Subject: [PATCH 13/17] Align OAuth guide with recovery flow --- docs/codex-oauth.md | 129 ++++++-------------------------------------- 1 file changed, 17 insertions(+), 112 deletions(-) diff --git a/docs/codex-oauth.md b/docs/codex-oauth.md index 57b64411be..ac5dcd3dc2 100644 --- a/docs/codex-oauth.md +++ b/docs/codex-oauth.md @@ -487,118 +487,23 @@ public enum CodexTokenRefresher { ### 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 { - let creds = try CodexOAuthCredentialsStore.loadForUsage( - env: context.env, - allowExternalSources: context.settings?.codex?.allowExternalOAuthSources == true) - - // The usage path never refreshes or saves credentials. Codex CLI owns the native - // auth.json refresh lifecycle; stale native credentials delegate to CLI recovery, while - // stale legacy/OpenCode credentials fail closed because those files are read-only. - - 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 OAuth or credential errors. Transient API, + decode, and network failures remain visible instead of launching an unrelated 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. --- From 87b2b36d45a1ef8290ba6291abf7201e7d4a59ef Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Sun, 16 Aug 2026 08:12:37 +0800 Subject: [PATCH 14/17] Remove stale OAuth refresh examples --- docs/codex-oauth.md | 303 ++++++++------------------------------------ 1 file changed, 51 insertions(+), 252 deletions(-) diff --git a/docs/codex-oauth.md b/docs/codex-oauth.md index ac5dcd3dc2..8ecb542447 100644 --- a/docs/codex-oauth.md +++ b/docs/codex-oauth.md @@ -1,5 +1,5 @@ --- -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 @@ -8,7 +8,8 @@ read_when: # Codex OAuth Resolver Implementation Plan -> 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" -} -``` - -**Response:** -```json -{ - "id_token": "eyJ...", - "access_token": "eyJ...", - "refresh_token": "..." -} -``` +- 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. -**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 @@ -124,7 +114,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 +126,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,102 +270,14 @@ 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) - } +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: - 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") - } - - 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. --- @@ -511,8 +307,8 @@ race this design is intended to avoid. | 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` | @@ -521,11 +317,14 @@ race this design is intended to avoid. ## 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`. --- @@ -533,8 +332,8 @@ race this design is intended to avoid. | 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 | From d58b1beb4588f529b4a4f3ae7eb84937df2343eb Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Sun, 16 Aug 2026 09:58:59 +0800 Subject: [PATCH 15/17] Preserve OAuth snapshot across credit enrichment --- .../UsageStore+CodexResetCredits.swift | 9 ++++ .../Codex/CodexProviderDescriptor.swift | 37 ++++++++++++++-- .../Providers/ProviderFetchPlan.swift | 6 +++ .../CodexResetCreditOutcomeTests.swift | 43 ++++++++++++++++--- .../ProviderArchitectureGatekeeperTests.swift | 2 +- 5 files changed, 87 insertions(+), 10 deletions(-) diff --git a/Sources/CodexBar/UsageStore+CodexResetCredits.swift b/Sources/CodexBar/UsageStore+CodexResetCredits.swift index b7b6c28192..918edcf4d9 100644 --- a/Sources/CodexBar/UsageStore+CodexResetCredits.swift +++ b/Sources/CodexBar/UsageStore+CodexResetCredits.swift @@ -37,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() @@ -127,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/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift index d6ab6bddca..2bda4b32f5 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift @@ -395,6 +395,7 @@ struct CodexOAuthFetchStrategy: ProviderFetchStrategy { accessToken: credentials.accessToken, accountId: credentials.accountId, env: context.env) + let resetCreditsAttempted = Self.shouldFetchResetCredits(context) let resetCredits = try await Self.fetchResetCreditsIfRequested( context: context, credentials: credentials) @@ -404,7 +405,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, @@ -501,7 +503,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( @@ -514,12 +517,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 @@ -531,7 +535,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, @@ -543,6 +547,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( @@ -579,6 +606,7 @@ struct CodexOAuthFetchStrategy: ProviderFetchStrategy { sourceLabel: oauthResult.sourceLabel, strategyID: oauthResult.strategyID, strategyKind: oauthResult.strategyKind, + codexResetCreditsAttempted: oauthResult.codexResetCreditsAttempted, diagnostic: oauthResult.diagnostic) } @@ -658,6 +686,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, 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/CodexResetCreditOutcomeTests.swift b/Tests/CodexBarTests/CodexResetCreditOutcomeTests.swift index 30046fced9..231478428a 100644 --- a/Tests/CodexBarTests/CodexResetCreditOutcomeTests.swift +++ b/Tests/CodexBarTests/CodexResetCreditOutcomeTests.swift @@ -118,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) @@ -134,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 @@ -189,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, @@ -207,7 +239,8 @@ struct CodexResetCreditOutcomeTests { dashboard: nil, sourceLabel: "test", strategyID: strategyID, - strategyKind: .cli)), + strategyKind: .cli, + codexResetCreditsAttempted: codexResetCreditsAttempted)), attempts: []) } diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index d565325351..e3634696fc 100644 --- a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift +++ b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift @@ -3580,7 +3580,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, From 7204480e75a6cd88b83e70381695609a6bf09869 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Sun, 16 Aug 2026 10:21:46 +0800 Subject: [PATCH 16/17] Clarify private workspace metadata boundary --- docs/codex.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/codex.md b/docs/codex.md index b91689fc0f..452f6aaac2 100644 --- a/docs/codex.md +++ b/docs/codex.md @@ -55,7 +55,8 @@ Usage source picker: publish OAuth token material into a shared `auth.json` without a cross-writer publication contract. In Automatic mode a stale credential lets the existing CLI fallback run; explicit OAuth mode delegates only stale native credentials to the same CLI recovery path, while stale external credentials remain read-only errors. Explicit - managed-account workspace selection may still update its `account_id` metadata. + 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. From 91ed021fcbd6f9a716a28d2b7b3a8cbfaf7d8060 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Sun, 16 Aug 2026 11:26:50 +0800 Subject: [PATCH 17/17] Fail closed CLI recovery for managed workspace scope --- .../Codex/CodexProviderDescriptor.swift | 7 ++- ...exOAuthManagedWorkspaceRecoveryTests.swift | 45 +++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 Tests/CodexBarTests/CodexOAuthManagedWorkspaceRecoveryTests.swift diff --git a/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift index 2bda4b32f5..139f64bb4e 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift @@ -335,7 +335,12 @@ struct CodexOAuthNativeRefreshCLIStrategy: ProviderFetchStrategy { } func isAvailable(_ context: ProviderFetchContext) async -> Bool { - guard context.sourceMode == .oauth, + // 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, diff --git a/Tests/CodexBarTests/CodexOAuthManagedWorkspaceRecoveryTests.swift b/Tests/CodexBarTests/CodexOAuthManagedWorkspaceRecoveryTests.swift new file mode 100644 index 0000000000..654ab2e562 --- /dev/null +++ b/Tests/CodexBarTests/CodexOAuthManagedWorkspaceRecoveryTests.swift @@ -0,0 +1,45 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct CodexOAuthManagedWorkspaceRecoveryTests { + @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 env = ["CODEX_HOME": home.path] + let browserDetection = BrowserDetection(cacheTTL: 0) + let settings = ProviderSettingsSnapshot.make(codex: CodexProviderSettings( + usageDataSource: .oauth, + cookieSource: .off, + manualCookieHeader: nil, + managedWorkspaceAccountID: "workspace-team")) + let context = 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) + + let isAvailable = await CodexOAuthNativeRefreshCLIStrategy(binaryResolver: { _ in "/usr/bin/codex" }) + .isAvailable(context) + #expect(!isAvailable) + } +}