diff --git a/Sources/OpenUsage/App/AppContainer.swift b/Sources/OpenUsage/App/AppContainer.swift index 318d73511..3e9e6b012 100644 --- a/Sources/OpenUsage/App/AppContainer.swift +++ b/Sources/OpenUsage/App/AppContainer.swift @@ -72,7 +72,7 @@ final class AppContainer { let providers = ProviderCatalog.make( claudeCards: accountAssembly.claudeCards, - codexCards: accountAssembly.codexCards, + codex: accountAssembly.codex, claudeIdentityKeys: accountAssembly.identityKeysByCard ) let registry = WidgetRegistry.from(providers) diff --git a/Sources/OpenUsage/Providers/Codex/CodexAuthStore+Accounts.swift b/Sources/OpenUsage/Providers/Codex/CodexAuthStore+Accounts.swift index e27d42e82..ba5281602 100644 --- a/Sources/OpenUsage/Providers/Codex/CodexAuthStore+Accounts.swift +++ b/Sources/OpenUsage/Providers/Codex/CodexAuthStore+Accounts.swift @@ -7,7 +7,7 @@ extension CodexAuthStore { guard candidate.hasUsableAccessToken, CodexAccountIdentity(auth: candidate.auth) == expectedIdentity else { return nil } candidate.auth.tokens?.accountID = expectedIdentity.accountID.nilIfEmpty - } else if CodexSwapAccount.discover(environment: environment, files: files, + } else if !isDiscovered(candidate.source), CodexSwapAccount.discover(environment: environment, files: files, home: FileManager.default.homeDirectoryForCurrentUser).isEmpty { return candidate } @@ -19,10 +19,21 @@ extension CodexAuthStore { return candidate } + /// Sibling homes and pi entries reach the plain card only through account discovery; that pass + /// promised them read-only, so only the configured home may still be refreshed and written. + private func isDiscovered(_ source: CodexAuthState.Source) -> Bool { + switch source { + case .file(let path): additionalAuthHomes.contains { path == $0.trimmingTrailingSlashes + "/auth.json" } + case .keychain: false + case .pi: true + } + } + func isCurrent(_ candidate: CodexAuthState) async -> Bool { switch candidate.source { case .file(let path): loadAuth(at: path) == candidate case .keychain(let account): await loadOffMainActor { loadKeychainAuth(account: account) } == candidate + case .pi(let source): loadPiAuth(source) == candidate } } } diff --git a/Sources/OpenUsage/Providers/Codex/CodexAuthStore.swift b/Sources/OpenUsage/Providers/Codex/CodexAuthStore.swift index f3290cea1..eca4dee98 100644 --- a/Sources/OpenUsage/Providers/Codex/CodexAuthStore.swift +++ b/Sources/OpenUsage/Providers/Codex/CodexAuthStore.swift @@ -28,10 +28,16 @@ struct CodexAuth: Codable, Hashable, Sendable { } } +struct CodexPiCredentialSource: Hashable, Sendable { + let path: String + let providerID: String +} + struct CodexAuthState: Hashable, Sendable { enum Source: Hashable, Sendable { case file(path: String) case keychain(account: String) + case pi(CodexPiCredentialSource) } var auth: CodexAuth @@ -90,7 +96,6 @@ struct CodexAuthStore: Sendable { /// the `codex` CLI itself uses, so OpenUsage rotates on the same schedule rather than guessing. static let accessTokenRefreshWindow: TimeInterval = 5 * 60 private static let authFile = "auth.json" - private static let defaultAuthHomes = ["~/.config/codex", "~/.codex"] var environment: EnvironmentReading var files: TextFileAccessing @@ -98,6 +103,7 @@ struct CodexAuthStore: Sendable { var now: @Sendable () -> Date var expectedIdentity: CodexAccountIdentity? var additionalAuthHomes: [String] + var piCredentialSources: [CodexPiCredentialSource] init( environment: EnvironmentReading = ProcessEnvironmentReader(), @@ -105,7 +111,8 @@ struct CodexAuthStore: Sendable { keychain: KeychainAccessing = SecurityKeychainAccessor(), now: @escaping @Sendable () -> Date = Date.init, expectedIdentity: CodexAccountIdentity? = nil, - additionalAuthHomes: [String] = [] + additionalAuthHomes: [String] = [], + piCredentialSources: [CodexPiCredentialSource] = [] ) { self.environment = environment self.files = files @@ -113,10 +120,19 @@ struct CodexAuthStore: Sendable { self.now = now self.expectedIdentity = expectedIdentity self.additionalAuthHomes = additionalAuthHomes + self.piCredentialSources = piCredentialSources } func loadAuthCandidates() -> [CodexAuthState] { authPaths().compactMap { loadAuth(at: $0) } + + piCredentialSources.compactMap(loadPiAuth) + } + + func loadPiAuth(_ source: CodexPiCredentialSource) -> CodexAuthState? { + guard let auth = PiCodexLoginScanner.loadAuth( + files: files, path: source.path, providerID: source.providerID + ) else { return nil } + return scoped(CodexAuthState(auth: auth, source: .pi(source), readOnly: true)) } /// Reads the credential from a single on-disk auth file — the targeted counterpart to @@ -163,6 +179,8 @@ struct CodexAuthStore: Sendable { try files.writeText(path, text) case .keychain(let account): try keychain.writeGenericPassword(service: Self.keychainService, account: account, value: text) + case .pi: + throw CodexAuthError.tokenConflict } } @@ -196,9 +214,13 @@ struct CodexAuthStore: Sendable { } func authPaths() -> [String] { - let homes = (codexHome().map { [$0] } ?? Self.defaultAuthHomes) + additionalAuthHomes + let homes = CodexHomeScanner.configuredHomeValues(environment: environment) + additionalAuthHomes var seen = Set() - return homes.map { joinPath($0, Self.authFile) }.filter { seen.insert($0).inserted } + return homes.compactMap { home in + let path = joinPath(home, Self.authFile) + let key = URL(fileURLWithPath: (path as NSString).expandingTildeInPath).standardizedFileURL.path + return seen.insert(key).inserted ? path : nil + } } func codexHome() -> String? { diff --git a/Sources/OpenUsage/Providers/Codex/CodexHomeScanner.swift b/Sources/OpenUsage/Providers/Codex/CodexHomeScanner.swift new file mode 100644 index 000000000..71fdad5b8 --- /dev/null +++ b/Sources/OpenUsage/Providers/Codex/CodexHomeScanner.swift @@ -0,0 +1,130 @@ +import Foundation + +/// A Codex home whose `auth.json` names the account signed in there. +struct CodexHomeLogin: Equatable, Sendable { + let home: String + let identity: CodexAccountIdentity + + var authPath: String { home + "/auth.json" } +} + +struct CodexHomeScan: Equatable, Sendable { + let logins: [CodexHomeLogin] + /// A home holds a token that names no account at all. Such a login may belong to a second account, + /// so history with no provable owner must not be counted while it exists. + let hasIncompleteLogin: Bool +} + +/// Finds the Codex homes on this Mac — the configured default (`CODEX_HOME`, else `~/.config/codex` +/// and `~/.codex`) plus sibling `~/.codex-*` and `~/.config/codex-*` folders — and reads which +/// account is signed in at each. Read-only. +struct CodexHomeScanner: Sendable { + var environment: EnvironmentReading + var files: TextFileAccessing + var homeDirectory: @Sendable () -> URL + var listDirectories: @Sendable (String) -> [String] + + init( + environment: EnvironmentReading = ProcessEnvironmentReader(), + files: TextFileAccessing = LocalTextFileAccessor(), + homeDirectory: @escaping @Sendable () -> URL = { FileManager.default.homeDirectoryForCurrentUser }, + listDirectories: @escaping @Sendable (String) -> [String] = Self.listSubdirectories + ) { + self.environment = environment + self.files = files + self.homeDirectory = homeDirectory + self.listDirectories = listDirectories + } + + static func listSubdirectories(_ path: String) -> [String] { + let urls = (try? FileManager.default.contentsOfDirectory( + at: URL(fileURLWithPath: path), + includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey] + )) ?? [] + return urls.compactMap { url in + guard let values = try? url.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey]), + values.isDirectory == true, values.isSymbolicLink != true + else { return nil } + return url.lastPathComponent + } + } + + /// `CODEX_HOME` is one path, as the Codex CLI reads it. (The log scanner's comma-list parsing of + /// the same variable is a separate ccusage compatibility.) + static func configuredHomeValues(environment: EnvironmentReading) -> [String] { + if let home = environment.value(for: "CODEX_HOME")?.trimmingCharacters(in: .whitespacesAndNewlines), + !home.isEmpty + { + return [home] + } + return ["~/.config/codex", "~/.codex"] + } + + static func configuredHomes(environment: EnvironmentReading, homeDirectory: URL) -> [String] { + uniqueHomes(configuredHomeValues(environment: environment), homeDirectory: homeDirectory) + } + + func candidateHomes() -> [String] { + let home = homeDirectory() + let siblingHomes = listDirectories(home.path) + .filter { $0.hasPrefix(".codex-") } + .sorted() + .map { "~/\($0)" } + + listDirectories(home.appendingPathComponent(".config").path) + .filter { $0.hasPrefix("codex-") } + .sorted() + .map { "~/.config/\($0)" } + return Self.uniqueHomes( + Self.configuredHomes(environment: environment, homeDirectory: home) + ["~/.config/codex", "~/.codex"] + siblingHomes, + homeDirectory: home + ) + } + + /// Every candidate home (plus `additionalHomes`) holding a token login that names its account. + func logins(additionalHomes: [String] = []) -> [CodexHomeLogin] { + scan(additionalHomes: additionalHomes).logins + } + + func scan(additionalHomes: [String] = []) -> CodexHomeScan { + let homes = Self.uniqueHomes(candidateHomes() + additionalHomes, homeDirectory: homeDirectory()) + var logins: [CodexHomeLogin] = [] + var hasIncompleteLogin = false + for home in homes { + let text: String? + do { + text = try files.readTextIfPresent(home + "/auth.json") + } catch { + AppLog.warn(.config, "accounts: Codex home \(home) has an unreadable auth.json; skipping it") + continue + } + guard let text, + let auth = CodexAuthStore.parseAuth(text), + auth.tokens?.accessToken?.nilIfEmpty != nil + else { continue } + if let identity = CodexAccountIdentity(auth: auth) { + logins.append(CodexHomeLogin(home: home, identity: identity)) + } else { + hasIncompleteLogin = true + } + } + return CodexHomeScan(logins: logins, hasIncompleteLogin: hasIncompleteLogin) + } + + static func standardizedHome(_ raw: String, homeDirectory: URL) -> String { + let expanded = expandTilde(raw, homeDirectory: homeDirectory).trimmingTrailingSlashes + return URL(fileURLWithPath: expanded).standardizedFileURL.path + } + + static func expandTilde(_ path: String, homeDirectory: URL) -> String { + guard path == "~" || path.hasPrefix("~/") else { return path } + return homeDirectory.path + String(path.dropFirst(1)) + } + + private static func uniqueHomes(_ homes: [String], homeDirectory: URL) -> [String] { + var seen = Set() + return homes.compactMap { raw in + let standardized = standardizedHome(raw, homeDirectory: homeDirectory) + return seen.insert(standardized).inserted ? standardized : nil + } + } +} diff --git a/Sources/OpenUsage/Providers/Codex/CodexLogUsageScanner.swift b/Sources/OpenUsage/Providers/Codex/CodexLogUsageScanner.swift index a3e51b049..2ec0810f3 100644 --- a/Sources/OpenUsage/Providers/Codex/CodexLogUsageScanner.swift +++ b/Sources/OpenUsage/Providers/Codex/CodexLogUsageScanner.swift @@ -123,8 +123,9 @@ actor CodexLogUsageScanner { // MARK: - Discovery - /// `CODEX_HOME` entries (comma-separated) when set, else `~/.codex` — same as ccusage. - private func codexHomes() -> [URL] { + /// `CODEX_HOME` entries (comma-separated) when set, else `~/.codex` — same as ccusage — plus the + /// discovered homes handed in. + func codexHomes() -> [URL] { let extra = additionalHomes.map { URL(fileURLWithPath: expandHome($0)) } if let raw = environment.value(for: "CODEX_HOME")?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty { diff --git a/Sources/OpenUsage/Providers/Codex/CodexProvider+Accounts.swift b/Sources/OpenUsage/Providers/Codex/CodexProvider+Accounts.swift index 4385fdedc..e26398c49 100644 --- a/Sources/OpenUsage/Providers/Codex/CodexProvider+Accounts.swift +++ b/Sources/OpenUsage/Providers/Codex/CodexProvider+Accounts.swift @@ -53,6 +53,6 @@ extension CodexProvider { private struct CodexSwapLoginError: LocalizedError, CategorizedError { var errorCategory: ErrorCategory { .authExpired } var errorDescription: String? { - "No valid login for this Codex account. Sign in with Codex or use `xswap login `, then refresh." + "No valid login for this Codex account. Sign in with Codex, xswap, or pi, then refresh." } } diff --git a/Sources/OpenUsage/Providers/Codex/CodexProvider.swift b/Sources/OpenUsage/Providers/Codex/CodexProvider.swift index 997bdc37a..ee77d7406 100644 --- a/Sources/OpenUsage/Providers/Codex/CodexProvider.swift +++ b/Sources/OpenUsage/Providers/Codex/CodexProvider.swift @@ -261,6 +261,8 @@ final class CodexProvider: ProviderRuntime { return authStore.loadAuth(at: path) case .keychain(let account): return authStore.loadKeychainAuth(account: account) + case .pi(let source): + return authStore.loadPiAuth(source) } } diff --git a/Sources/OpenUsage/Providers/Codex/CodexSwapAccount.swift b/Sources/OpenUsage/Providers/Codex/CodexSwapAccount.swift index ef42b3881..7784d12d5 100644 --- a/Sources/OpenUsage/Providers/Codex/CodexSwapAccount.swift +++ b/Sources/OpenUsage/Providers/Codex/CodexSwapAccount.swift @@ -24,12 +24,16 @@ struct CodexAccountIdentity: Equatable, Hashable, Sendable { } init?(auth: CodexAuth) { - let payload = auth.tokens?.idToken.flatMap(ProviderParse.jwtPayload) - let claimID = DefaultAccountObserver.chatGPTAccountID(inIDTokenPayload: payload) + let idPayload = auth.tokens?.idToken.flatMap(ProviderParse.jwtPayload) + let accessPayload = auth.tokens?.accessToken.flatMap(ProviderParse.jwtPayload) + let claimID = DefaultAccountObserver.chatGPTAccountID(inIDTokenPayload: idPayload) + ?? DefaultAccountObserver.chatGPTAccountID(inIDTokenPayload: accessPayload) let storedID = auth.tokens?.accountID?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty if let storedID, let claimID, storedID.caseInsensitiveCompare(claimID) != .orderedSame { return nil } - self.init(accountID: storedID ?? claimID, email: payload?["email"] as? String) + let email = DefaultAccountObserver.email(inTokenPayload: idPayload) + ?? DefaultAccountObserver.email(inTokenPayload: accessPayload) + self.init(accountID: storedID ?? claimID, email: email) } private static func validComponent(_ value: String) -> Bool { diff --git a/Sources/OpenUsage/Providers/Codex/PiCodexLoginScanner.swift b/Sources/OpenUsage/Providers/Codex/PiCodexLoginScanner.swift new file mode 100644 index 000000000..15f9851f3 --- /dev/null +++ b/Sources/OpenUsage/Providers/Codex/PiCodexLoginScanner.swift @@ -0,0 +1,136 @@ +import Foundation + +/// An `openai-codex` / `openai-codex-N` OAuth login in pi's `auth.json`. +struct PiCodexLogin: Equatable, Sendable { + let providerID: String + let identity: CodexAccountIdentity + let label: String? + let authPath: String +} + +struct PiCodexLoginScan: Equatable, Sendable { + let logins: [PiCodexLogin] + /// A Codex OAuth entry whose token names neither its workspace nor its user. It can't become a + /// card, but it proves another account may own some of the local pi history. + let hasIncompleteLogin: Bool + + static let empty = PiCodexLoginScan(logins: [], hasIncompleteLogin: false) +} + +/// Reads pi's Codex logins. Never writes: pi rotates its own tokens under a lockfile. +struct PiCodexLoginScanner: Sendable { + static let providerPrefix = "openai-codex" + + var environment: EnvironmentReading + var files: TextFileAccessing + var homeDirectory: @Sendable () -> URL + + init( + environment: EnvironmentReading = ProcessEnvironmentReader(), + files: TextFileAccessing = LocalTextFileAccessor(), + homeDirectory: @escaping @Sendable () -> URL = { FileManager.default.homeDirectoryForCurrentUser } + ) { + self.environment = environment + self.files = files + self.homeDirectory = homeDirectory + } + + /// `openai-codex`, or `openai-codex-N` for pi's multi-pass pool (`openai-codex-work` is not pi's). + static func isCodexProvider(_ providerID: String) -> Bool { + guard providerID.hasPrefix(providerPrefix) else { return false } + let suffix = providerID.dropFirst(providerPrefix.count) + if suffix.isEmpty { return true } + guard suffix.first == "-" else { return false } + let digits = suffix.dropFirst() + return !digits.isEmpty && digits.allSatisfy(\.isNumber) + } + + static func providerIndex(_ providerID: String) -> Int { + let suffix = providerID.dropFirst(providerPrefix.count) + guard suffix.hasPrefix("-"), let index = Int(suffix.dropFirst()) else { return 1 } + return index + } + + func agentDirectory() -> String { + if let configDir = environment.value(for: "PI_CODING_AGENT_DIR")? + .trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty { + return CodexHomeScanner.expandTilde(configDir, homeDirectory: homeDirectory()).trimmingTrailingSlashes + } + return homeDirectory().appendingPathComponent(".pi/agent").path + } + + func scan() -> PiCodexLoginScan { + let agentDir = agentDirectory() + let authPath = agentDir + "/auth.json" + let object: [String: Any] + do { + guard let text = try files.readTextIfPresent(authPath) else { return .empty } + guard let parsed = try JSONSerialization.jsonObject(with: Data(text.utf8)) as? [String: Any] else { + AppLog.error(.config, "accounts: pi auth.json is not a JSON object; pi Codex logins skipped") + return .empty + } + object = parsed + } catch { + AppLog.error(.config, "accounts: pi auth.json could not be read; pi Codex logins skipped") + return .empty + } + let labels = subscriptionLabels(agentDir: agentDir) + let providerIDs = object.keys + .filter(Self.isCodexProvider) + .sorted { Self.providerIndex($0) < Self.providerIndex($1) } + var logins: [PiCodexLogin] = [] + var hasIncompleteLogin = false + for providerID in providerIDs { + guard let auth = Self.auth(in: object, providerID: providerID) else { continue } + guard let identity = CodexAccountIdentity(auth: auth), + CodexAccountIdentity.isComplete(key: identity.key) + else { + hasIncompleteLogin = true + continue + } + logins.append(PiCodexLogin( + providerID: providerID, identity: identity, label: labels[providerID], authPath: authPath + )) + } + return PiCodexLoginScan(logins: logins, hasIncompleteLogin: hasIncompleteLogin) + } + + /// The live credential for one pi provider entry — re-read on every use, never cached. + static func loadAuth(files: TextFileAccessing, path: String, providerID: String) -> CodexAuth? { + guard let text = try? files.readTextIfPresent(path), + let object = try? JSONSerialization.jsonObject(with: Data(text.utf8)) as? [String: Any] + else { return nil } + return auth(in: object, providerID: providerID) + } + + private static func auth(in object: [String: Any], providerID: String) -> CodexAuth? { + guard let entry = object[providerID] as? [String: Any], + entry["type"] as? String == "oauth", + let accessToken = (entry["access"] as? String)?.nilIfEmpty + else { return nil } + return CodexAuth(tokens: CodexTokens( + accessToken: accessToken, + refreshToken: nil, + idToken: nil, + accountID: (entry["accountId"] as? String)?.nilIfEmpty + ), lastRefresh: nil, apiKey: nil) + } + + private func subscriptionLabels(agentDir: String) -> [String: String] { + guard let text = try? files.readTextIfPresent(agentDir + "/multi-pass.json"), + let object = try? JSONSerialization.jsonObject(with: Data(text.utf8)) as? [String: Any], + let subscriptions = object["subscriptions"] as? [[String: Any]] + else { return [:] } + var labels: [String: String] = [:] + for subscription in subscriptions { + guard subscription["provider"] as? String == Self.providerPrefix, + let index = ProviderParse.number(subscription["index"]).map({ Int($0) }), + let label = (subscription["label"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty + else { continue } + let providerID = index <= 1 ? Self.providerPrefix : "\(Self.providerPrefix)-\(index)" + labels[providerID] = label + } + return labels + } +} diff --git a/Sources/OpenUsage/Providers/DefaultAccountObserver.swift b/Sources/OpenUsage/Providers/DefaultAccountObserver.swift index c49af7f38..bafb97822 100644 --- a/Sources/OpenUsage/Providers/DefaultAccountObserver.swift +++ b/Sources/OpenUsage/Providers/DefaultAccountObserver.swift @@ -117,13 +117,7 @@ struct DefaultAccountObserver: Sendable { /// the CLI itself copies into `account_id`). No path-derived fallback: an auth file that can't /// name its account (and keyring-mode logins, whose secret we never read here) stays unresolved. func observeCodex() -> Outcome { - let homes: [String] - if let raw = environment.value(for: "CODEX_HOME")? - .trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty { - homes = [raw] - } else { - homes = ["~/.config/codex", "~/.codex"] - } + let homes = CodexHomeScanner.configuredHomeValues(environment: environment) // `CodexProvider.refresh` falls back to the keychain credential when file auth fails, so // while a keychain item exists the file's identity is not provably the account that will @@ -154,14 +148,9 @@ struct DefaultAccountObserver: Sendable { guard let auth = CodexAuthStore.parseAuth(text), auth.tokens?.accessToken?.nilIfEmpty != nil else { continue } - let payload = auth.tokens?.idToken.flatMap { ProviderParse.jwtPayload($0) } - let email = (payload?["email"] as? String)?.nilIfEmpty - if let accountID = auth.tokens?.accountID? - .trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty { - return .resolved(identityKey: accountID.lowercased(), label: email, anchor: anchor) - } - if let claimID = Self.chatGPTAccountID(inIDTokenPayload: payload) { - return .resolved(identityKey: claimID.lowercased(), label: email, anchor: anchor) + if let identity = CodexAccountIdentity(auth: auth), + let accountID = identity.accountID.nilIfEmpty { + return .resolved(identityKey: accountID, label: identity.email, anchor: anchor) } } return sawFootprint @@ -178,4 +167,13 @@ struct DefaultAccountObserver: Sendable { let raw = (authClaim?["chatgpt_account_id"] ?? payload["chatgpt_account_id"]) as? String return raw?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty } + + /// The signed-in user's email: `email` under the `https://api.openai.com/profile` claim (access + /// tokens), with the bare top-level spelling id_tokens use. Lowercased. + static func email(inTokenPayload payload: [String: Any]?) -> String? { + guard let payload else { return nil } + let profile = payload["https://api.openai.com/profile"] as? [String: Any] + return ((profile?["email"] ?? payload["email"]) as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty?.lowercased() + } } diff --git a/Sources/OpenUsage/Providers/ProviderCatalog.swift b/Sources/OpenUsage/Providers/ProviderCatalog.swift index b98f628c1..7ceb2ff87 100644 --- a/Sources/OpenUsage/Providers/ProviderCatalog.swift +++ b/Sources/OpenUsage/Providers/ProviderCatalog.swift @@ -7,7 +7,7 @@ enum ProviderCatalog { static func make( defaults: UserDefaults = .standard, claudeCards: [ClaudeAccountCard] = [], - codexCards: [CodexAccountCard] = [], + codex: CodexAccountDiscovery = CodexAccountDiscovery(), claudeIdentityKeys: [String: String] = [:] ) -> [ProviderRuntime] { // Default provider order (see AGENTS.md "## Providers"): the three established providers first, @@ -42,13 +42,27 @@ enum ProviderCatalog { ) } } - if codexCards.isEmpty { - providers.append(CodexProvider()) + if codex.cards.isEmpty { + providers.append(CodexProvider( + authStore: CodexAuthStore( + additionalAuthHomes: codex.plainAuthHomes, + piCredentialSources: codex.plainPiCredentialSources + ), + logUsageScanner: CodexLogUsageScanner( + allowsUnattributedHistory: codex.allowsUnattributedHistory, + additionalHomes: codex.plainAuthHomes + ), + allowsUnattributedHistory: codex.allowsUnattributedHistory + )) } else { - providers += codexCards.map { card in + providers += codex.cards.map { card in CodexProvider( provider: CodexProvider.makeProvider(id: card.id, displayName: card.displayName), - authStore: CodexAuthStore(expectedIdentity: card.identity, additionalAuthHomes: card.authHomes), + authStore: CodexAuthStore( + expectedIdentity: card.identity, + additionalAuthHomes: card.authHomes, + piCredentialSources: card.piCredentialSources + ), logUsageScanner: CodexLogUsageScanner( allowsUnattributedHistory: card.allowsUnattributedHistory, additionalHomes: card.logHomes diff --git a/Sources/OpenUsage/Services/ProviderAccountAssembly+Codex.swift b/Sources/OpenUsage/Services/ProviderAccountAssembly+Codex.swift index 9573f80c1..553056226 100644 --- a/Sources/OpenUsage/Services/ProviderAccountAssembly+Codex.swift +++ b/Sources/OpenUsage/Services/ProviderAccountAssembly+Codex.swift @@ -5,32 +5,90 @@ struct CodexAccountCard: Equatable, Sendable { let identity: CodexAccountIdentity let displayName: String let authHomes: [String] + let piCredentialSources: [CodexPiCredentialSource] let logHomes: [String] let allowsUnattributedHistory: Bool } +/// What the Codex account pass found: account cards once more than one account is known, otherwise the +/// read-only logins the plain `codex` card may fall back to when its only account lives in a sibling +/// home or in pi, plus the local-history policy either way. +struct CodexAccountDiscovery: Equatable, Sendable { + var cards: [CodexAccountCard] = [] + var plainAuthHomes: [String] = [] + var plainPiCredentialSources: [CodexPiCredentialSource] = [] + var allowsUnattributedHistory = true +} + extension ProviderAccountAssembly { + /// One card per ChatGPT account found across Codex homes, pi logins, Swap slots, and the Keychain. + /// A lone account stays on the plain `codex` provider; cards appear once a second account is + /// known, or once Swap or the saved registry already made this a multi-account install. static func makeCodexCards( - observer: DefaultAccountObserver, accountsStore: ProviderAccountsStore - ) async -> [CodexAccountCard] { + observer: DefaultAccountObserver, + accountsStore: ProviderAccountsStore, + listDirectories: @escaping @Sendable (String) -> [String] = CodexHomeScanner.listSubdirectories + ) async -> CodexAccountDiscovery { + let homeDirectory = observer.homeDirectory() let swaps = CodexSwapAccount.discover( - environment: observer.environment, files: observer.files, home: observer.homeDirectory() + environment: observer.environment, files: observer.files, home: homeDirectory ) - guard !swaps.isEmpty || accountsStore.records.contains(where: { - $0.family == "codex" && $0.identityKey.contains("|") - }) else { return [] } - let home = observer.homeDirectory().path - func expanded(_ path: String) -> String { - path.hasPrefix("~/") ? home + String(path.dropFirst()) : path - } + let homeScan = CodexHomeScanner( + environment: observer.environment, + files: observer.files, + homeDirectory: observer.homeDirectory, + listDirectories: listDirectories + ).scan(additionalHomes: swaps.map(\.mainHome)) + let homeLogins = homeScan.logins + let piScan = PiCodexLoginScanner( + environment: observer.environment, files: observer.files, homeDirectory: observer.homeDirectory + ).scan() + // Keychain can hold a different default login with no auth.json or saved Swap slot. let auth = CodexAuthStore(environment: observer.environment, files: observer.files, keychain: observer.keychain) - let defaultPaths = auth.authPaths().map(expanded) - let mainPaths = swaps.map { $0.mainHome + "/auth.json" } + let keychainIdentity: CodexAccountIdentity? = await loadOffMainActor { + guard let state = auth.loadKeychainAuth(), state.hasUsableAccessToken else { return nil } + return CodexAccountIdentity(auth: state.auth) + } + let configuredHomes = Set(CodexHomeScanner.configuredHomes( + environment: observer.environment, homeDirectory: homeDirectory + )) + let hasEstablishedAccounts = accountsStore.records.contains { + $0.family == "codex" && $0.identityKey.contains("|") + } + let knownIdentities = Set( + homeLogins.map(\.identity) + piScan.logins.map(\.identity) + swaps.map(\.identity) + + [keychainIdentity].compactMap { $0 } + ) + // History with no provable owner counts only while exactly one account exists and no login + // is too incomplete to rule out a second one. + let hasUnidentifiedLogin = piScan.hasIncompleteLogin || homeScan.hasIncompleteLogin + let hasIncompleteLogin = hasUnidentifiedLogin + || homeLogins.contains { !CodexAccountIdentity.isComplete(key: $0.identity.key) } + guard !swaps.isEmpty || hasEstablishedAccounts || knownIdentities.count > 1 else { + return CodexAccountDiscovery( + plainAuthHomes: homeLogins.map(\.home).filter { !configuredHomes.contains($0) }, + plainPiCredentialSources: piScan.logins.map { + CodexPiCredentialSource(path: $0.authPath, providerID: $0.providerID) + }, + allowsUnattributedHistory: knownIdentities.isEmpty || !hasUnidentifiedLogin + ) + } + var observations: [ProviderAccountsStore.Observation] = [] var identities: [CodexAccountIdentity] = [] var labels: [String: String] = [:] - func observe(_ identity: CodexAccountIdentity, label: String, source: ProviderAccountSource) { + var namedByTool = Set() + + func label(for identity: CodexAccountIdentity, preferred: String? = nil) -> String { + if let preferred = preferred?.nilIfEmpty { return "Codex: \(preferred)" } + let workspace = identity.accountID.isEmpty ? "Unknown" : String(identity.accountID.prefix(8)) + return "Codex: Workspace \(workspace) (\(identity.email ?? identity.accountID))" + } + + /// A name the user chose in xswap or pi beats the generic workspace label; the first such name wins. + func observe(_ identity: CodexAccountIdentity, label: String, named: Bool = false, + source: ProviderAccountSource) { accountsStore.upgradeCodexIdentity(identity) if let index = observations.firstIndex(where: { $0.identityKey == identity.key }) { if !observations[index].sources.contains(source) { observations[index].sources.append(source) } @@ -39,44 +97,58 @@ extension ProviderAccountAssembly { observations.append(.init(family: "codex", identityKey: identity.key, label: identity.email, sources: [source])) } - labels[identity.key] = label + if named ? namedByTool.insert(identity.key).inserted : labels[identity.key] == nil { + labels[identity.key] = label + } } - var seenPaths = Set() - for path in defaultPaths + mainPaths where seenPaths.insert(path).inserted { - guard let state = auth.loadAuth(at: path), state.hasUsableAccessToken, - let identity = CodexAccountIdentity(auth: state.auth) else { continue } - let workspace = identity.accountID.isEmpty ? "Unknown" : String(identity.accountID.prefix(8)) - observe(identity, label: "Codex: Workspace \(workspace) (\(identity.email ?? identity.accountID))", - source: .init(kind: .defaultHome, anchor: URL(fileURLWithPath: path).deletingLastPathComponent().path, - holdsDefaultSource: observations.isEmpty)) + + var assignedDefault = false + for login in homeLogins { + let isConfigured = configuredHomes.contains(login.home) + let holdsDefault = isConfigured && !assignedDefault + if holdsDefault { assignedDefault = true } + observe(login.identity, label: label(for: login.identity), + source: .init(kind: isConfigured ? .defaultHome : .codexHome, anchor: login.home, + holdsDefaultSource: holdsDefault)) } - // Keychain can hold a different default login with no auth.json or saved Swap slot. - // Discover it before saved slots so it retains the default card on a first launch. - if let state = await loadOffMainActor({ auth.loadKeychainAuth() }), state.hasUsableAccessToken, - let identity = CodexAccountIdentity(auth: state.auth) { - let workspace = identity.accountID.isEmpty ? "Unknown" : String(identity.accountID.prefix(8)) - observe(identity, label: "Codex: Workspace \(workspace) (\(identity.email ?? identity.accountID))", - source: .init(kind: .defaultHome, anchor: nil, holdsDefaultSource: observations.isEmpty)) + // The Keychain login comes before saved slots so it retains the default card on a first launch. + if let keychainIdentity { + observe(keychainIdentity, label: label(for: keychainIdentity), + source: .init(kind: .defaultHome, anchor: nil, holdsDefaultSource: !assignedDefault)) + assignedDefault = true } for swap in swaps { - observe(swap.identity, label: swap.displayName, + observe(swap.identity, label: swap.displayName, named: swap.alias != nil, source: .init(kind: .codexSwap, anchor: swap.home, holdsDefaultSource: false)) } + for login in piScan.logins { + observe(login.identity, label: label(for: login.identity, preferred: login.label ?? login.identity.email), + named: login.label != nil, + source: .init(kind: .pi, anchor: login.providerID, holdsDefaultSource: false)) + } + let records = accountsStore.reconcile(with: observations) - let allowsUnattributed = records.count { $0.family == "codex" } == 1 - let logHomes = Array(Set(swaps.flatMap { [$0.mainHome, $0.home] })).sorted() + let allowsUnattributed = !hasIncompleteLogin && records.count { $0.family == "codex" } == 1 + let swapHomes = swaps.flatMap { [$0.mainHome, $0.home] } + .map { CodexHomeScanner.standardizedHome($0, homeDirectory: homeDirectory) } + let logHomes = Set(homeLogins.map(\.home)).union(swapHomes).sorted() // Registry order is persistent; observation order follows the current default login. // Even an uncustomized layout must keep its cards in place after a switch and relaunch. - return records.compactMap { record in + let cards = records.compactMap { record -> CodexAccountCard? in guard record.family == "codex", !record.removedTombstone, let identity = identities.first(where: { $0.key == record.identityKey }) else { return nil } - let matching = swaps.filter { $0.identity == identity } - let observedHomes = observations.first { $0.identityKey == identity.key }?.sources.compactMap(\.anchor) ?? [] + let matchingHomes = homeLogins.filter { $0.identity == identity }.map(\.home) + let matchingSwapHomes = swaps.filter { $0.identity == identity }.flatMap { [$0.mainHome, $0.home] } + let matchingPi = piScan.logins.filter { $0.identity == identity }.map { + CodexPiCredentialSource(path: $0.authPath, providerID: $0.providerID) + } return CodexAccountCard(id: record.id, identity: identity, displayName: labels[identity.key] ?? "Codex", - authHomes: Array(Set(observedHomes + matching.flatMap { [$0.mainHome, $0.home] })).sorted(), + authHomes: Set(matchingHomes + matchingSwapHomes).sorted(), + piCredentialSources: matchingPi, logHomes: logHomes, allowsUnattributedHistory: allowsUnattributed) } + return CodexAccountDiscovery(cards: cards, allowsUnattributedHistory: allowsUnattributed) } } diff --git a/Sources/OpenUsage/Services/ProviderAccountAssembly.swift b/Sources/OpenUsage/Services/ProviderAccountAssembly.swift index e725a4549..3d137987a 100644 --- a/Sources/OpenUsage/Services/ProviderAccountAssembly.swift +++ b/Sources/OpenUsage/Services/ProviderAccountAssembly.swift @@ -22,7 +22,8 @@ struct ProviderAccountAssembly { /// homes, so the keys are the bare family ids; a family whose identity didn't resolve is absent. let identityKeysByCard: [String: String] var claudeCards: [ClaudeAccountCard] = [] - var codexCards: [CodexAccountCard] = [] + var codex = CodexAccountDiscovery() + var codexCards: [CodexAccountCard] { codex.cards } /// `waitsForLoginShell`: true for the menu-bar app (a Finder/Dock launch inherits no shell /// exports, so the pass leans on the login-shell layers), false for the one-shot CLI (a terminal @@ -73,6 +74,7 @@ struct ProviderAccountAssembly { observer: DefaultAccountObserver, accountsStore: ProviderAccountsStore, families: Set = ProviderAccountID.families, + listCodexHomeDirectories: @escaping @Sendable (String) -> [String] = CodexHomeScanner.listSubdirectories, desktop: ClaudeDesktopAuthStore? = nil, listDesktopOrganizationDirectories: @escaping @Sendable (URL) -> [String] = { root in let urls = (try? FileManager.default.contentsOfDirectory( @@ -87,8 +89,11 @@ struct ProviderAccountAssembly { } } ) async -> ProviderAccountAssembly { - let codexCards = families.contains("codex") - ? await makeCodexCards(observer: observer, accountsStore: accountsStore) : [] + let codex = families.contains("codex") + ? await makeCodexCards(observer: observer, accountsStore: accountsStore, + listDirectories: listCodexHomeDirectories) + : CodexAccountDiscovery() + let codexCards = codex.cards var identityKeys = Dictionary(uniqueKeysWithValues: codexCards.map { ($0.id, $0.identity.key) }) var observations: [ProviderAccountsStore.Observation] = [] @@ -119,7 +124,7 @@ struct ProviderAccountAssembly { guard families.contains("claude") else { accountsStore.reconcile(with: observations) - return ProviderAccountAssembly(identityKeysByCard: identityKeys, codexCards: codexCards) + return ProviderAccountAssembly(identityKeysByCard: identityKeys, codex: codex) } let swapAccounts = ClaudeSwapAccount.discover(files: observer.files, home: observer.homeDirectory()) @@ -141,7 +146,7 @@ struct ProviderAccountAssembly { if let claudeIdentity = identityKeys["claude"], !claudeIdentity.contains("|"), swapAccounts.isEmpty { accountsStore.reconcile(with: observations) - return ProviderAccountAssembly(identityKeysByCard: identityKeys, codexCards: codexCards) + return ProviderAccountAssembly(identityKeysByCard: identityKeys, codex: codex) } let desktop = desktop ?? ClaudeDesktopAuthStore( @@ -246,7 +251,7 @@ struct ProviderAccountAssembly { for index in cards.indices { cards[index].additionalLogDirectories = swapAccounts.map(\.sessionDirectory) } - return ProviderAccountAssembly(identityKeysByCard: identityKeys, claudeCards: cards, codexCards: codexCards) + return ProviderAccountAssembly(identityKeysByCard: identityKeys, claudeCards: cards, codex: codex) } private struct DesktopOrganization { diff --git a/Sources/OpenUsage/Services/UsageReader.swift b/Sources/OpenUsage/Services/UsageReader.swift index 684f40eb6..06fc56492 100644 --- a/Sources/OpenUsage/Services/UsageReader.swift +++ b/Sources/OpenUsage/Services/UsageReader.swift @@ -58,7 +58,7 @@ public struct UsageReader { let providers = providersOverride ?? ProviderCatalog.make( defaults: defaults, claudeCards: accountAssembly.claudeCards, - codexCards: accountAssembly.codexCards, + codex: accountAssembly.codex, claudeIdentityKeys: accountAssembly.identityKeysByCard ) let registry = WidgetRegistry.from(providers) diff --git a/Sources/OpenUsage/Stores/ProviderAccountsStore.swift b/Sources/OpenUsage/Stores/ProviderAccountsStore.swift index 28f39ba38..c7d4cbf76 100644 --- a/Sources/OpenUsage/Stores/ProviderAccountsStore.swift +++ b/Sources/OpenUsage/Stores/ProviderAccountsStore.swift @@ -32,6 +32,8 @@ struct ProviderAccountSource: Codable, Equatable, Sendable { case defaultHome case claudeSwap case codexSwap + case codexHome + case pi } var kind: Kind diff --git a/Tests/OpenUsageTests/CodexDiscoveryGateTests.swift b/Tests/OpenUsageTests/CodexDiscoveryGateTests.swift new file mode 100644 index 000000000..250155ecf --- /dev/null +++ b/Tests/OpenUsageTests/CodexDiscoveryGateTests.swift @@ -0,0 +1,170 @@ +import XCTest +@testable import OpenUsage + +/// Whether discovery keeps the plain `codex` card or makes account cards, and what each receives. +@MainActor +final class CodexDiscoveryGateTests: XCTestCase { + private typealias Fixtures = CodexMultiAccountFixtures + private nonisolated static let now = Date(timeIntervalSince1970: 1_800_000_000) + private let piAuthPath = "/Users/dev/.pi/agent/auth.json" + + private func assemble( + files: FakeFiles, + directories: [String: [String]] = [:], + environment: [String: String] = [:], + keychain: String? = nil + ) async -> ProviderAccountAssembly { + let observer = DefaultAccountObserver( + environment: FakeEnvironment(environment), + files: files, + keychain: FakeKeychain(keychain), + homeDirectory: { Fixtures.home } + ) + return await ProviderAccountAssembly.make( + observer: observer, + accountsStore: ProviderAccountsStore(defaults: makeScratchDefaults()), + families: ["codex"], + listCodexHomeDirectories: { directories[$0] ?? [] } + ) + } + + /// The plain card exactly as `ProviderCatalog` builds it, on the test file system. + private func plainProvider(_ discovery: CodexAccountDiscovery, files: FakeFiles, http: RoutingHTTPClient) -> CodexProvider { + CodexProvider( + authStore: CodexAuthStore( + environment: FakeEnvironment([:]), files: files, keychain: FakeKeychain(), now: { Self.now }, + additionalAuthHomes: discovery.plainAuthHomes, + piCredentialSources: discovery.plainPiCredentialSources + ), + usageClient: CodexUsageClient(http: http), + now: { Self.now } + ) + } + + // MARK: - A lone account outside the configured home + + func testLonePiLoginFeedsThePlainCodexCard() async throws { + let valid = Fixtures.token(accountID: "ACCT-A", email: "alice@test", exp: Self.now.addingTimeInterval(3600)) + let piAuth = #"{"openai-codex":{"type":"oauth","access":"\#(valid)","refresh":"pi-rt","accountId":"ACCT-A"}}"# + let files = FakeFiles([piAuthPath: piAuth]) + + let assembly = await assemble(files: files) + + XCTAssertTrue(assembly.codexCards.isEmpty) + XCTAssertEqual(assembly.codex.plainPiCredentialSources, [.init(path: piAuthPath, providerID: "openai-codex")]) + XCTAssertTrue(assembly.codex.allowsUnattributedHistory) + + let http = RoutingHTTPClient { _ in Fixtures.usageResponse() } + let provider = plainProvider(assembly.codex, files: files, http: http) + let hasCredentials = await provider.hasLocalCredentials() + let snapshot = await provider.refresh() + + XCTAssertTrue(hasCredentials) + XCTAssertNil(snapshot.errorCategory) + XCTAssertEqual(http.requests.first?.headers["Authorization"], "Bearer \(valid)") + XCTAssertEqual(files.files[piAuthPath], piAuth, "pi's login is never rewritten") + } + + func testLoneSiblingHomeFeedsThePlainCodexCard() async throws { + let valid = Fixtures.token(accountID: "ACCT-A", email: "alice@test", exp: Self.now.addingTimeInterval(3600)) + let files = FakeFiles(["/Users/dev/.codex-work/auth.json": Fixtures.codexAuth(accountID: "ACCT-A", email: "alice@test", accessToken: valid)]) + + let assembly = await assemble(files: files, directories: ["/Users/dev": [".codex-work"]]) + + XCTAssertTrue(assembly.codexCards.isEmpty) + XCTAssertEqual(assembly.codex.plainAuthHomes, ["/Users/dev/.codex-work"]) + + let http = RoutingHTTPClient { _ in Fixtures.usageResponse() } + let snapshot = await plainProvider(assembly.codex, files: files, http: http).refresh() + + XCTAssertNil(snapshot.errorCategory) + XCTAssertEqual(http.requests.first?.headers["Authorization"], "Bearer \(valid)") + } + + func testLoneSiblingHomeIsScannedForThePlainCodexCardsSpend() async throws { + let valid = Fixtures.token(accountID: "ACCT-A", email: "alice@test", exp: Self.now.addingTimeInterval(3600)) + let files = FakeFiles(["/Users/dev/.codex-work/auth.json": Fixtures.codexAuth(accountID: "ACCT-A", email: "alice@test", accessToken: valid)]) + let assembly = await assemble(files: files, directories: ["/Users/dev": [".codex-work"]]) + + let providers = ProviderCatalog.make(defaults: makeScratchDefaults(), codex: assembly.codex) + .compactMap { $0 as? CodexProvider } + let scanner = try XCTUnwrap(providers.first?.logUsageScanner) + + XCTAssertEqual(providers.map(\.provider.id), ["codex"]) + let homes = await scanner.codexHomes().map(\.path) + XCTAssertTrue(homes.contains("/Users/dev/.codex-work"), "\(homes)") + } + + func testLoneSiblingHomeStaysReadOnlyOnThePlainCodexCard() async throws { + let expired = Fixtures.token(accountID: "ACCT-A", email: "alice@test", exp: Self.now.addingTimeInterval(-60)) + let credential = Fixtures.codexAuth(accountID: "ACCT-A", email: "alice@test", accessToken: expired) + let files = FakeFiles(["/Users/dev/.codex-work/auth.json": credential]) + let assembly = await assemble(files: files, directories: ["/Users/dev": [".codex-work"]]) + + let http = RoutingHTTPClient { _ in HTTPResponse(statusCode: 401, headers: [:], body: Data()) } + let snapshot = await plainProvider(assembly.codex, files: files, http: http).refresh() + + XCTAssertEqual(snapshot.errorCategory, .authExpired) + XCTAssertFalse(http.requests.contains { $0.url.host == "auth.openai.com" }) + XCTAssertEqual(files.files["/Users/dev/.codex-work/auth.json"], credential) + } + + // MARK: - The Keychain counts toward the second account + + func testKeychainLoginBesideAHomeLoginMakesTwoCardsOnAFreshInstall() async { + let files = FakeFiles(["/Users/dev/.codex/auth.json": Fixtures.codexAuth(accountID: "A", email: "a@test")]) + + let assembly = await assemble(files: files, keychain: Fixtures.codexAuth(accountID: "B", email: "b@test")) + + XCTAssertEqual(Set(assembly.codexCards.map(\.identity.key)), ["a|a@test", "b|b@test"]) + XCTAssertTrue(assembly.codexCards.allSatisfy { !$0.allowsUnattributedHistory }) + } + + func testKeychainLoginBesideAPiLoginMakesTwoCardsOnAFreshInstall() async throws { + let files = FakeFiles([piAuthPath: Fixtures.piAuth([("openai-codex", "A", "a@test")])]) + + let assembly = await assemble(files: files, keychain: Fixtures.codexAuth(accountID: "B", email: "b@test")) + + XCTAssertEqual(Set(assembly.codexCards.map(\.identity.key)), ["a|a@test", "b|b@test"]) + let piCard = try XCTUnwrap(assembly.codexCards.first { $0.identity.accountID == "a" }) + XCTAssertEqual(piCard.piCredentialSources, [.init(path: piAuthPath, providerID: "openai-codex")]) + } + + // MARK: - Logins that name no account at all + + func testTokenHomeWithoutAnyIdentityDisablesUnattributedHistory() async throws { + let anonymous = Fixtures.codexAuth(accountID: nil, email: "", accessToken: Fixtures.token()) + let files = FakeFiles([ + "/Users/dev/.codex/auth.json": Fixtures.codexAuth(accountID: "A", email: "a@test"), + "/Users/dev/.codex-old/auth.json": anonymous, + ]) + let directories = ["/Users/dev": [".codex-old"]] + + let plain = await assemble(files: files, directories: directories) + let providers = ProviderCatalog.make(defaults: makeScratchDefaults(), codex: plain.codex) + .compactMap { $0 as? CodexProvider } + + XCTAssertTrue(plain.codexCards.isEmpty) + XCTAssertEqual(providers.map(\.allowsUnattributedHistory), [false]) + + files.files["/Users/dev/.xswap/accounts.json"] = #"{"schemaVersion":1,"mainHome":"/Users/dev/.codex","accounts":[{"number":1,"alias":"Personal","home":"/Users/dev/.xswap/a","identity":{"accountId":"A","email":"a@test"}}]}"# + let cards = await assemble(files: files, directories: directories, environment: ["XSWAP_HOME": "/Users/dev/.xswap"]) + + XCTAssertEqual(cards.codexCards.map(\.allowsUnattributedHistory), [false]) + } + + // MARK: - Labels + + func testPiLabelNamesASwapSlotThatHasNoAlias() async { + let files = FakeFiles([ + "/Users/dev/.xswap/accounts.json": #"{"schemaVersion":1,"mainHome":"/Users/dev/.codex","accounts":[{"number":1,"home":"/Users/dev/.xswap/a","identity":{"accountId":"ACCT-A","email":"alice@test"}}]}"#, + "/Users/dev/.codex/auth.json": Fixtures.codexAuth(accountID: "ACCT-A", email: "alice@test"), + piAuthPath: Fixtures.piAuth([("openai-codex", "ACCT-A", "alice@test")]), + "/Users/dev/.pi/agent/multi-pass.json": #"{"subscriptions":[{"provider":"openai-codex","index":1,"label":"Work"}]}"#, + ]) + + let assembly = await assemble(files: files, environment: ["XSWAP_HOME": "/Users/dev/.xswap"]) + + XCTAssertEqual(assembly.codexCards.map(\.displayName), ["Codex: Work"]) + } +} diff --git a/Tests/OpenUsageTests/CodexHomeDiscoveryTests.swift b/Tests/OpenUsageTests/CodexHomeDiscoveryTests.swift new file mode 100644 index 000000000..ecd883fc6 --- /dev/null +++ b/Tests/OpenUsageTests/CodexHomeDiscoveryTests.swift @@ -0,0 +1,237 @@ +import XCTest +@testable import OpenUsage + +/// Finding Codex logins on disk — Codex homes, pi's auth.json — and loading them read-only. +@MainActor +final class CodexHomeDiscoveryTests: XCTestCase { + private typealias Fixtures = CodexMultiAccountFixtures + private let home = Fixtures.home + + private func scanner( + environment: [String: String] = [:], + files: FakeFiles, + directories: [String: [String]] = [:] + ) -> CodexHomeScanner { + CodexHomeScanner( + environment: FakeEnvironment(environment), + files: files, + homeDirectory: { [home] in home }, + listDirectories: { directories[$0] ?? [] } + ) + } + + private func piScanner(files: FakeFiles) -> PiCodexLoginScanner { + PiCodexLoginScanner(environment: FakeEnvironment(), files: files, homeDirectory: { [home] in home }) + } + + func testCandidateHomesTreatConfiguredCommaPathAsSingleHomeAndKeepSiblingDirectories() { + let scanner = scanner( + environment: ["CODEX_HOME": " ~/custom,codex "], + files: FakeFiles(), + directories: [ + "/Users/dev": [".codex-work", ".codex-personal", ".config", "Documents"], + "/Users/dev/.config": ["codex-ci", "codex", "gh"], + ] + ) + + XCTAssertEqual(scanner.candidateHomes(), [ + "/Users/dev/custom,codex", "/Users/dev/.config/codex", "/Users/dev/.codex", + "/Users/dev/.codex-personal", "/Users/dev/.codex-work", "/Users/dev/.config/codex-ci", + ]) + } + + func testSiblingDiscoveryIncludesHiddenCodexDirectories() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + addTeardownBlock { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: root.appendingPathComponent(".codex-work"), withIntermediateDirectories: false) + try Data().write(to: root.appendingPathComponent(".codex-file")) + + let names = CodexHomeScanner.listSubdirectories(root.path) + + XCTAssertTrue(names.contains(".codex-work")) + XCTAssertFalse(names.contains(".codex-file")) + } + + func testHomeLoginsSkipHomesWithoutAnIdentityAndDeduplicateSwapMainHome() { + let files = FakeFiles([ + "/Users/dev/.codex/auth.json": Fixtures.codexAuth(accountID: "ACCT-A", email: "a@test"), + "/Users/dev/.codex-work/auth.json": Fixtures.codexAuth(accountID: "ACCT-B", email: "b@test"), + "/Users/dev/.codex-apikey/auth.json": #"{"OPENAI_API_KEY":"sk-test"}"#, + ]) + + let logins = scanner(files: files, directories: ["/Users/dev": [".codex-work", ".codex-apikey"]]) + .logins(additionalHomes: ["~/.codex/"]) + + XCTAssertEqual(logins.map(\.home), ["/Users/dev/.codex", "/Users/dev/.codex-work"]) + XCTAssertEqual(logins.map(\.identity.key), ["acct-a|a@test", "acct-b|b@test"]) + } + + func testIdentityFallsBackFromIncompleteIDTokenToAccessToken() throws { + let auth = try XCTUnwrap(CodexAuthStore.parseAuth(Fixtures.codexAuth( + accountID: nil, email: "ME@WORK.TEST", accessAccountID: "ACCT-WORK" + ))) + + XCTAssertEqual(CodexAccountIdentity(auth: auth)?.key, "acct-work|me@work.test") + } + + func testPiDiscoveryKeepsEveryMatchingIdentityWithoutSnapshottingTokens() { + let files = FakeFiles([ + "/Users/dev/.pi/agent/auth.json": Fixtures.piAuth([ + ("openai-codex-2", "ACCT-WORK", "me@work.test"), + ("openai-codex", "ACCT-HOME", "me@home.test"), + ]), + "/Users/dev/.pi/agent/multi-pass.json": #"{"subscriptions":[{"provider":"openai-codex","index":2,"label":"work"}]}"#, + ]) + + let scan = piScanner(files: files).scan() + + XCTAssertFalse(scan.hasIncompleteLogin) + XCTAssertEqual(scan.logins.map(\.providerID), ["openai-codex", "openai-codex-2"]) + XCTAssertEqual(scan.logins.map(\.identity.key), ["acct-home|me@home.test", "acct-work|me@work.test"]) + XCTAssertEqual(scan.logins.map(\.label), [nil, "work"]) + XCTAssertEqual(Set(scan.logins.map(\.authPath)), ["/Users/dev/.pi/agent/auth.json"]) + } + + func testPiDiscoveryFlagsLoginsThatCannotNameTheirAccount() { + let files = FakeFiles([ + "/Users/dev/.pi/agent/auth.json": Fixtures.piAuth([ + ("openai-codex", "ACCT-HOME", "me@home.test"), + ("openai-codex-2", "ACCT-WORK", ""), + ]), + ]) + + let scan = piScanner(files: files).scan() + + XCTAssertTrue(scan.hasIncompleteLogin) + XCTAssertEqual(scan.logins.map(\.providerID), ["openai-codex"]) + } + + func testPiCodexProviderIDShape() { + XCTAssertTrue(PiCodexLoginScanner.isCodexProvider("openai-codex")) + XCTAssertTrue(PiCodexLoginScanner.isCodexProvider("openai-codex-2")) + XCTAssertTrue(PiCodexLoginScanner.isCodexProvider("openai-codex-12")) + XCTAssertFalse(PiCodexLoginScanner.isCodexProvider("openai-codex-")) + XCTAssertFalse(PiCodexLoginScanner.isCodexProvider("openai-codex-work")) + XCTAssertNil(PiProviderMapping.cardID(forPiProvider: "openai-codex-3")) + } + + func testDefaultObserverUsesConfiguredCommaPathAsSingleHomeAndAccessTokenIdentity() { + let files = FakeFiles([ + "/missing, /second/auth.json": Fixtures.codexAuth(accountID: nil, email: "me@test", accessAccountID: "ACCT"), + ]) + let observer = DefaultAccountObserver( + environment: FakeEnvironment(["CODEX_HOME": "/missing, /second"]), + files: files, keychain: FakeKeychain(), homeDirectory: { [home] in home } + ) + + XCTAssertEqual(observer.observeCodex(), + .resolved(identityKey: "acct", label: "me@test", anchor: "/missing, /second")) + } + + func testDefaultObserverLeavesEmailOnlyLoginUnresolved() { + let files = FakeFiles([ + "/Users/dev/.codex/auth.json": Fixtures.codexAuth(accountID: nil, email: "a@test"), + ]) + let observer = DefaultAccountObserver( + environment: FakeEnvironment([:]), files: files, keychain: FakeKeychain(), homeDirectory: { [home] in home } + ) + + XCTAssertEqual(observer.observeCodex(), .unresolved(reason: "credentials present but no account identity")) + } + + // MARK: - Scoped auth store + + func testDiscoveredHomeCredentialsAreReadOnly() throws { + let credential = Fixtures.codexAuth(accountID: "A", email: "a@test") + let files = FakeFiles(["/Users/dev/.codex-work/auth.json": credential]) + let store = CodexAuthStore( + environment: FakeEnvironment(["CODEX_HOME": "/Users/dev/.codex"]), + files: files, keychain: FakeKeychain(), + expectedIdentity: try XCTUnwrap(CodexAccountIdentity(accountID: "A", email: "a@test")), + additionalAuthHomes: ["/Users/dev/.codex-work"] + ) + + let candidate = try XCTUnwrap(store.loadAuthCandidates().first) + + XCTAssertTrue(candidate.readOnly) + XCTAssertNil(candidate.auth.tokens?.refreshToken) + XCTAssertThrowsError(try store.save(candidate)) + XCTAssertEqual(files.files["/Users/dev/.codex-work/auth.json"], credential) + } + + func testChangedHomeLoginIsRejectedBeforeUse() throws { + let files = FakeFiles([ + "/home/auth.json": Fixtures.codexAuth(accountID: "A", email: "a@test"), + ]) + let store = CodexAuthStore( + environment: FakeEnvironment(["CODEX_HOME": "/home"]), + files: files, keychain: FakeKeychain(), + expectedIdentity: try XCTUnwrap(CodexAccountIdentity(accountID: "A", email: "a@test")), + additionalAuthHomes: ["/home"] + ) + + XCTAssertNotNil(store.loadAuthCandidates().first) + files.files["/home/auth.json"] = Fixtures.codexAuth(accountID: "B", email: "b@test") + XCTAssertTrue(store.loadAuthCandidates().isEmpty) + } + + func testPiCredentialsReloadAndFallBackAcrossMatchingProviderIDs() throws { + let expired = Fixtures.token(accountID: "ACCT", email: "me@test") + let current = Fixtures.token(accountID: "ACCT", email: "me@test", exp: Date(timeIntervalSince1970: 2_000_000_000)) + let files = FakeFiles([ + "/pi/auth.json": #"{"openai-codex":{"type":"oauth","access":"\#(expired)","accountId":"ACCT"},"openai-codex-2":{"type":"oauth","access":"\#(current)","accountId":"ACCT"}}"#, + ]) + let store = CodexAuthStore( + files: files, keychain: FakeKeychain(), + expectedIdentity: try XCTUnwrap(CodexAccountIdentity(accountID: "ACCT", email: "me@test")), + piCredentialSources: [ + CodexPiCredentialSource(path: "/pi/auth.json", providerID: "openai-codex"), + CodexPiCredentialSource(path: "/pi/auth.json", providerID: "openai-codex-2"), + ] + ) + + let candidates = store.loadAuthCandidates() + XCTAssertEqual(candidates.map { $0.auth.tokens?.accessToken }, [expired, current]) + XCTAssertTrue(candidates.allSatisfy(\.readOnly)) + XCTAssertThrowsError(try store.save(candidates[0])) + + let renewed = Fixtures.token(accountID: "ACCT", email: "me@test", exp: Date(timeIntervalSince1970: 2_100_000_000)) + files.files["/pi/auth.json"] = #"{"openai-codex":{"type":"oauth","access":"\#(renewed)","accountId":"ACCT"}}"# + + XCTAssertEqual(store.loadAuthCandidates().map { $0.auth.tokens?.accessToken }, [renewed]) + } + + func testProviderFallsBackAcrossMatchingPiCredentials() async throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let first = Fixtures.token(accountID: "A", email: "a@test", exp: now.addingTimeInterval(3600)) + let second = Fixtures.token(accountID: "A", email: "a@test", exp: now.addingTimeInterval(7200)) + let files = FakeFiles([ + "/pi/auth.json": #"{"openai-codex":{"type":"oauth","access":"\#(first)","accountId":"A"},"openai-codex-2":{"type":"oauth","access":"\#(second)","accountId":"A"}}"#, + ]) + let http = RoutingHTTPClient { request in + if request.headers["Authorization"] == "Bearer \(first)" { + return HTTPResponse(statusCode: 401, headers: [:], body: Data()) + } + return Fixtures.usageResponse() + } + let provider = CodexProvider( + authStore: CodexAuthStore( + files: files, keychain: FakeKeychain(), now: { now }, + expectedIdentity: try XCTUnwrap(CodexAccountIdentity(accountID: "A", email: "a@test")), + piCredentialSources: [ + .init(path: "/pi/auth.json", providerID: "openai-codex"), + .init(path: "/pi/auth.json", providerID: "openai-codex-2"), + ] + ), + usageClient: CodexUsageClient(http: http), + now: { now } + ) + + let snapshot = await provider.refresh() + + XCTAssertNil(snapshot.errorCategory) + XCTAssertEqual(http.requests.prefix(2).map { $0.headers["Authorization"] }, + ["Bearer \(first)", "Bearer \(second)"]) + } +} diff --git a/Tests/OpenUsageTests/CodexMultiAccountAssemblyTests.swift b/Tests/OpenUsageTests/CodexMultiAccountAssemblyTests.swift new file mode 100644 index 000000000..03999b2b3 --- /dev/null +++ b/Tests/OpenUsageTests/CodexMultiAccountAssemblyTests.swift @@ -0,0 +1,315 @@ +import XCTest +@testable import OpenUsage + +/// Turning discovered Codex logins into cards: identity merging, card ids, and spend attribution. +@MainActor +final class CodexMultiAccountAssemblyTests: XCTestCase { + private typealias Fixtures = CodexMultiAccountFixtures + + private func assemble( + files: FakeFiles, + directories: [String: [String]] = [:], + environment: [String: String] = [:], + home: URL = Fixtures.home, + defaults: UserDefaults? = nil + ) async -> ProviderAccountAssembly { + let observer = DefaultAccountObserver( + environment: FakeEnvironment(environment), + files: files, + keychain: FakeKeychain(), + homeDirectory: { home } + ) + return await ProviderAccountAssembly.make( + observer: observer, + accountsStore: ProviderAccountsStore(defaults: defaults ?? makeScratchDefaults()), + families: ["codex"], + listCodexHomeDirectories: { directories[$0] ?? [] } + ) + } + + private func provider(for card: CodexAccountCard, files: FakeFiles, environment: [String: String], + now: Date, http: RoutingHTTPClient) -> CodexProvider { + CodexProvider( + authStore: CodexAuthStore( + environment: FakeEnvironment(environment), files: files, keychain: FakeKeychain(), now: { now }, + expectedIdentity: card.identity, additionalAuthHomes: card.authHomes, + piCredentialSources: card.piCredentialSources + ), + usageClient: CodexUsageClient(http: http), + now: { now } + ) + } + + func testSingleAccountAcrossHomeAndPiStaysOnThePlainCodexProvider() async { + let files = FakeFiles([ + "/Users/dev/.codex/auth.json": Fixtures.codexAuth(accountID: "ACCT-A", email: "alice@test"), + "/Users/dev/.codex-work/auth.json": Fixtures.codexAuth(accountID: "ACCT-A", email: "alice@test"), + "/Users/dev/.pi/agent/auth.json": Fixtures.piAuth([("openai-codex", "ACCT-A", "alice@test")]), + ]) + + let assembly = await assemble(files: files, directories: ["/Users/dev": [".codex-work"]]) + + XCTAssertTrue(assembly.codexCards.isEmpty) + XCTAssertEqual(assembly.identityKeysByCard["codex"], "acct-a") + } + + func testIncompletePiLoginBesideOnePlainAccountHidesUnattributedSpend() async { + let files = FakeFiles([ + "/Users/dev/.codex/auth.json": Fixtures.codexAuth(accountID: "ACCT-A", email: "alice@test"), + "/Users/dev/.pi/agent/auth.json": Fixtures.piAuth([("openai-codex", "ACCT-B", "")]), + ]) + + let assembly = await assemble(files: files) + let codex = ProviderCatalog.make(defaults: makeScratchDefaults(), codex: assembly.codex) + .compactMap { $0 as? CodexProvider } + + XCTAssertTrue(assembly.codexCards.isEmpty) + XCTAssertFalse(assembly.codex.allowsUnattributedHistory) + XCTAssertEqual(codex.map(\.provider.id), ["codex"]) + XCTAssertEqual(codex.map(\.allowsUnattributedHistory), [false]) + } + + func testPiLoginNeverRenamesASwapAlias() async throws { + let swap = #"{"schemaVersion":1,"mainHome":"/Users/dev/.codex","accounts":[{"number":1,"alias":"Personal","home":"/Users/dev/.xswap/a","identity":{"accountId":"ACCT-A","email":"alice@test"}}]}"# + let files = FakeFiles([ + "/Users/dev/.xswap/accounts.json": swap, + "/Users/dev/.codex/auth.json": Fixtures.codexAuth(accountID: "ACCT-A", email: "alice@test"), + "/Users/dev/.pi/agent/auth.json": Fixtures.piAuth([("openai-codex", "ACCT-A", "alice@test")]), + ]) + + let assembly = await assemble(files: files, environment: ["XSWAP_HOME": "/Users/dev/.xswap"]) + + XCTAssertEqual(assembly.codexCards.map(\.displayName), ["Codex: Personal (alice@test)"]) + } + + func testAccountIDOnlyDefaultHomeStaysOwnCardBesideCompleteSibling() async throws { + let files = FakeFiles([ + "/Users/dev/.codex/auth.json": Fixtures.codexAuth(accountID: "ACCT-A", email: ""), + "/Users/dev/.codex-work/auth.json": Fixtures.codexAuth(accountID: "ACCT-B", email: "b@test"), + ]) + + let assembly = await assemble(files: files, directories: ["/Users/dev": [".codex-work"]]) + + XCTAssertEqual(assembly.codexCards.count, 2) + let accountA = try XCTUnwrap(assembly.codexCards.first { $0.identity.accountID == "acct-a" }) + let accountB = try XCTUnwrap(assembly.codexCards.first { $0.identity.accountID == "acct-b" }) + XCTAssertEqual(accountA.id, "codex") + XCTAssertTrue(accountA.authHomes.contains("/Users/dev/.codex")) + XCTAssertNotEqual(accountB.id, "codex") + XCTAssertFalse(accountA.allowsUnattributedHistory) + XCTAssertFalse(accountB.allowsUnattributedHistory) + } + + func testIncompleteDefaultLoginGetsOwnCardBesideSameWorkspaceSibling() async throws { + let accountOnly = Fixtures.token(accountID: "A") + let files = FakeFiles([ + "/Users/dev/.codex/auth.json": Fixtures.codexAuth(accountID: "A", email: "", accessToken: accountOnly), + "/Users/dev/.codex-b/auth.json": Fixtures.codexAuth(accountID: "A", email: "b@test"), + ]) + + let assembly = await assemble(files: files, directories: ["/Users/dev": [".codex-b"]]) + + XCTAssertEqual(assembly.codexCards.count, 2) + let defaultCard = try XCTUnwrap(assembly.codexCards.first { $0.identity.key == "a|" }) + let sibling = try XCTUnwrap(assembly.codexCards.first { $0.identity.key == "a|b@test" }) + XCTAssertEqual(defaultCard.id, "codex") + XCTAssertNotEqual(sibling.id, "codex") + XCTAssertFalse(defaultCard.allowsUnattributedHistory) + XCTAssertFalse(sibling.allowsUnattributedHistory) + } + + func testEmailOnlySiblingHomeAppearsOnFirstAssemblyWithStableCardIDs() async throws { + let files = FakeFiles([ + "/Users/dev/.codex/auth.json": Fixtures.codexAuth(accountID: "ACCT-A", email: "a@test"), + "/Users/dev/.codex-work/auth.json": Fixtures.codexAuth(accountID: nil, email: "b@test"), + ]) + let defaults = makeScratchDefaults() + let directories = ["/Users/dev": [".codex-work"]] + + let first = await assemble(files: files, directories: directories, defaults: defaults) + + XCTAssertEqual(first.codexCards.count, 2) + let emailOnly = try XCTUnwrap(first.codexCards.first { $0.identity.accountID.isEmpty && $0.identity.email == "b@test" }) + XCTAssertFalse(emailOnly.allowsUnattributedHistory) + + let second = await assemble(files: files, directories: directories, defaults: defaults) + + XCTAssertEqual(second.codexCards.count, 2) + XCTAssertEqual(Set(second.codexCards.map(\.id)), Set(first.codexCards.map(\.id))) + } + + func testIncompletePiLoginDisablesUnattributedHistoryForASingleCard() async throws { + let swap = #"{"schemaVersion":1,"mainHome":"/Users/dev/.codex","accounts":[{"number":1,"alias":"A","home":"/Users/dev/.xswap/a","identity":{"accountId":"ACCT-A","email":"alice@test"}}]}"# + let files = FakeFiles([ + "/Users/dev/.xswap/accounts.json": swap, + "/Users/dev/.codex/auth.json": Fixtures.codexAuth(accountID: "ACCT-A", email: "alice@test"), + ]) + let environment = ["XSWAP_HOME": "/Users/dev/.xswap"] + + let alone = await assemble(files: files, environment: environment) + XCTAssertEqual(alone.codexCards.map(\.identity.key), ["acct-a|alice@test"]) + XCTAssertTrue(alone.codexCards[0].allowsUnattributedHistory) + + files.files["/Users/dev/.pi/agent/auth.json"] = Fixtures.piAuth([("openai-codex", "ACCT-B", "")]) + let withStranger = await assemble(files: files, environment: environment) + + XCTAssertEqual(withStranger.codexCards.map(\.identity.key), ["acct-a|alice@test"]) + XCTAssertFalse(withStranger.codexCards[0].allowsUnattributedHistory) + } + + func testHomesAndPiMergeByWorkspaceAndUserIdentity() async throws { + let files = FakeFiles([ + "/Users/dev/.codex/auth.json": Fixtures.codexAuth(accountID: "ACCT-WORK", email: "me@work.test"), + "/Users/dev/.codex-work/auth.json": Fixtures.codexAuth(accountID: "ACCT-WORK", email: "me@work.test"), + "/Users/dev/.codex-personal/auth.json": Fixtures.codexAuth(accountID: "ACCT-HOME", email: "me@home.test"), + "/Users/dev/.pi/agent/auth.json": Fixtures.piAuth([ + ("openai-codex", "ACCT-HOME", "me@home.test"), + ("openai-codex-2", "ACCT-WORK", "me@work.test"), + ]), + "/Users/dev/.pi/agent/multi-pass.json": #"{"subscriptions":[{"provider":"openai-codex","index":2,"label":"work"}]}"#, + ]) + + let assembly = await assemble(files: files, directories: ["/Users/dev": [".codex-work", ".codex-personal"]]) + + XCTAssertEqual(assembly.codexCards.count, 2) + let work = try XCTUnwrap(assembly.codexCards.first { $0.identity.key == "acct-work|me@work.test" }) + let personal = try XCTUnwrap(assembly.codexCards.first { $0.identity.key == "acct-home|me@home.test" }) + XCTAssertEqual(work.id, "codex") + XCTAssertEqual(personal.id, ProviderAccountID.make(family: "codex", identityKey: "acct-home|me@home.test")) + XCTAssertEqual(work.displayName, "Codex: work") + XCTAssertEqual(work.authHomes, ["/Users/dev/.codex", "/Users/dev/.codex-work"]) + XCTAssertEqual(personal.authHomes, ["/Users/dev/.codex-personal"]) + XCTAssertEqual(work.piCredentialSources.map(\.providerID), ["openai-codex-2"]) + XCTAssertEqual(personal.piCredentialSources.map(\.providerID), ["openai-codex"]) + XCTAssertEqual(work.logHomes, ["/Users/dev/.codex", "/Users/dev/.codex-personal", "/Users/dev/.codex-work"]) + XCTAssertFalse(work.allowsUnattributedHistory) + XCTAssertFalse(personal.allowsUnattributedHistory) + XCTAssertEqual(assembly.identityKeysByCard["codex"], "acct-work|me@work.test") + XCTAssertEqual(assembly.identityKeysByCard[personal.id], "acct-home|me@home.test") + } + + func testUsersInTheSameWorkspaceRemainSeparateCards() async { + let files = FakeFiles([ + "/Users/dev/.codex/auth.json": Fixtures.codexAuth(accountID: "ACCT", email: "a@example.test"), + "/Users/dev/.codex-b/auth.json": Fixtures.codexAuth(accountID: "ACCT", email: "b@example.test"), + ]) + + let assembly = await assemble(files: files, directories: ["/Users/dev": [".codex-b"]]) + + XCTAssertEqual(assembly.codexCards.map(\.identity.key), ["acct|a@example.test", "acct|b@example.test"]) + } + + func testRegistryOrderAndBareCardOwnershipSurviveDefaultSwitch() async throws { + let defaults = makeScratchDefaults() + let work = Fixtures.codexAuth(accountID: "ACCT-WORK", email: "me@work.test") + let personal = Fixtures.codexAuth(accountID: "ACCT-HOME", email: "me@home.test") + let directories = ["/Users/dev": [".codex-personal"]] + + let first = await assemble( + files: FakeFiles(["/Users/dev/.codex/auth.json": work, "/Users/dev/.codex-personal/auth.json": personal]), + directories: directories, defaults: defaults + ) + let personalID = try XCTUnwrap(first.codexCards.first { $0.identity.key == "acct-home|me@home.test" }?.id) + + let swapped = await assemble( + files: FakeFiles(["/Users/dev/.codex/auth.json": personal, "/Users/dev/.codex-personal/auth.json": work]), + directories: directories, defaults: defaults + ) + + XCTAssertEqual(swapped.codexCards.map(\.id), ["codex", personalID]) + XCTAssertEqual(swapped.identityKeysByCard["codex"], "acct-work|me@work.test") + XCTAssertEqual(swapped.identityKeysByCard[personalID], "acct-home|me@home.test") + } + + func testPiOnlyAccountGetsAReadOnlyCredentialSource() async throws { + let files = FakeFiles([ + "/Users/dev/.codex/auth.json": Fixtures.codexAuth(accountID: "ACCT-WORK", email: "me@work.test"), + "/Users/dev/.pi/agent/auth.json": Fixtures.piAuth([("openai-codex-2", "ACCT-HOME", "me@home.test")]), + ]) + + let assembly = await assemble(files: files) + let personal = try XCTUnwrap(assembly.codexCards.first { $0.identity.key == "acct-home|me@home.test" }) + XCTAssertTrue(personal.authHomes.isEmpty) + XCTAssertEqual(personal.piCredentialSources.map(\.providerID), ["openai-codex-2"]) + + let store = CodexAuthStore( + files: files, keychain: FakeKeychain(), expectedIdentity: personal.identity, + additionalAuthHomes: personal.authHomes, piCredentialSources: personal.piCredentialSources + ) + let candidate = try XCTUnwrap(store.loadAuthCandidates().first) + XCTAssertTrue(candidate.readOnly) + XCTAssertNil(candidate.auth.tokens?.refreshToken) + XCTAssertEqual(candidate.auth.tokens?.accountID, "acct-home") + } + + func testCatalogKeepsMultiAccountLocalHistoryUnattributedAndPiReadOnly() async { + let files = FakeFiles([ + "/Users/dev/.codex/auth.json": Fixtures.codexAuth(accountID: "A", email: "a@test"), + "/Users/dev/.codex-b/auth.json": Fixtures.codexAuth(accountID: "B", email: "b@test"), + "/Users/dev/.pi/agent/auth.json": Fixtures.piAuth([("openai-codex", "A", "a@test"), ("openai-codex-2", "B", "b@test")]), + ]) + let assembly = await assemble(files: files, directories: ["/Users/dev": [".codex-b"]]) + let providers = ProviderCatalog.make(defaults: makeScratchDefaults(), codex: assembly.codex) + .compactMap { $0 as? CodexProvider } + + XCTAssertEqual(providers.count, 2) + XCTAssertTrue(providers.allSatisfy { !$0.allowsUnattributedHistory }) + XCTAssertEqual(providers.map { $0.authStore.piCredentialSources.count }, [1, 1]) + } + + // MARK: - Swap-managed homes + + func testSwapMainAndSavedHomesStayReadOnlyThroughRefresh() async throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let expired = Fixtures.token(accountID: "A", email: "a@test", exp: now.addingTimeInterval(-60)) + let credential = Fixtures.codexAuth(accountID: "A", email: "a@test", accessToken: expired) + let files = FakeFiles([ + "/test/swap/accounts.json": #"{"schemaVersion":1,"mainHome":"/test/main","accounts":[{"number":1,"alias":"A","home":"/test/saved","identity":{"accountId":"A","email":"a@test"}}]}"#, + "/test/main/auth.json": credential, + "/test/saved/auth.json": credential, + ]) + let environment = ["CODEX_HOME": "/test/main", "XSWAP_HOME": "/test/swap"] + + let assembly = await assemble(files: files, environment: environment, home: URL(fileURLWithPath: "/test")) + let card = try XCTUnwrap(assembly.codexCards.first) + XCTAssertEqual(card.authHomes, ["/test/main", "/test/saved"]) + + let http = RoutingHTTPClient { _ in + XCTFail("Read-only expired Swap credentials must not make requests") + return HTTPResponse(statusCode: 500, headers: [:], body: Data()) + } + let snapshot = await provider(for: card, files: files, environment: environment, now: now, http: http).refresh() + + XCTAssertEqual(snapshot.errorCategory, .authExpired) + XCTAssertTrue(http.requests.isEmpty) + XCTAssertEqual(files.files["/test/main/auth.json"], credential) + XCTAssertEqual(files.files["/test/saved/auth.json"], credential) + } + + func testSwapHomesStayReadOnlyWhenRegistryIdentityOmitsEmail() async throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let expired = Fixtures.token(accountID: "A", email: "a@test", exp: now.addingTimeInterval(-60)) + let credential = Fixtures.codexAuth(accountID: "A", email: "a@test", accessToken: expired) + let files = FakeFiles([ + "/test/swap/accounts.json": #"{"schemaVersion":1,"mainHome":"/test/main","accounts":[{"number":1,"alias":"A","home":"/test/saved","identity":{"accountId":"A"}}]}"#, + "/test/main/auth.json": credential, + "/test/saved/auth.json": credential, + ]) + let environment = ["CODEX_HOME": "/test/main", "XSWAP_HOME": "/test/swap"] + + let assembly = await assemble(files: files, environment: environment, home: URL(fileURLWithPath: "/test")) + XCTAssertFalse(assembly.codexCards.isEmpty) + + let http = RoutingHTTPClient { _ in + XCTFail("Swap-managed credentials must not rotate") + return HTTPResponse(statusCode: 500, headers: [:], body: Data()) + } + for card in assembly.codexCards { + _ = await provider(for: card, files: files, environment: environment, now: now, http: http).refresh() + } + + XCTAssertTrue(http.requests.isEmpty) + XCTAssertEqual(files.files["/test/main/auth.json"], credential) + XCTAssertEqual(files.files["/test/saved/auth.json"], credential) + } +} diff --git a/Tests/OpenUsageTests/CodexMultiAccountFixtures.swift b/Tests/OpenUsageTests/CodexMultiAccountFixtures.swift new file mode 100644 index 000000000..e316a1a7d --- /dev/null +++ b/Tests/OpenUsageTests/CodexMultiAccountFixtures.swift @@ -0,0 +1,74 @@ +import XCTest +@testable import OpenUsage + +/// Shared JWT / auth.json builders for the Codex multi-account suites. +enum CodexMultiAccountFixtures { + static let home = URL(fileURLWithPath: "/Users/dev") + + static func b64url(_ string: String) -> String { + Data(string.utf8).base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } + + /// A ChatGPT-shaped JWT. `accountID` lands in the `auth` claim, `email` in the `profile` claim. + static func token(accountID: String? = nil, email: String? = nil, exp: Date? = nil) -> String { + var claims: [String] = [] + if let accountID { + claims.append(#""https://api.openai.com/auth":{"chatgpt_account_id":"\#(accountID)","chatgpt_plan_type":"pro"}"#) + } + if let email { + claims.append(#""https://api.openai.com/profile":{"email":"\#(email)"}"#) + } + if let exp { claims.append(#""exp":\#(Int(exp.timeIntervalSince1970))"#) } + return "\(b64url(#"{"alg":"RS256"}"#)).\(b64url("{\(claims.joined(separator: ","))}")).sig" + } + + /// A Codex `auth.json`. The id_token carries `accountID`/`email`; the access token carries + /// `accessAccountID ?? accountID` unless a full `accessToken` is supplied. + static func codexAuth( + accountID: String?, + email: String, + accessAccountID: String? = nil, + accessToken: String? = nil, + refreshToken: String? = "rt" + ) -> String { + let auth = CodexAuth( + tokens: CodexTokens( + accessToken: accessToken ?? token(accountID: accessAccountID ?? accountID, email: email), + refreshToken: refreshToken, + idToken: token(accountID: accountID, email: email), + accountID: accountID + ), + lastRefresh: nil, + apiKey: nil + ) + return String(decoding: try! JSONEncoder().encode(auth), as: UTF8.self) + } + + /// pi's `auth.json` with one `oauth` entry per provider id. + static func piAuth(_ entries: [(provider: String, accountID: String, email: String)]) -> String { + let body = entries.map { entry in + #""\#(entry.provider)":{"type":"oauth","access":"\#(token(accountID: entry.accountID, email: entry.email))","refresh":"rt","accountId":"\#(entry.accountID)"}"# + }.joined(separator: ",") + return "{\(body)}" + } + + static func usageResponse(usedPercent: Int = 10) -> HTTPResponse { + HTTPResponse(statusCode: 200, headers: [:], body: Data( + #"{"plan_type":"pro","rate_limit":{"primary_window":{"used_percent":\#(usedPercent),"limit_window_seconds":18000}}}"#.utf8)) + } +} + +@MainActor +extension XCTestCase { + /// A throwaway `UserDefaults` suite, wiped again at teardown. + func makeScratchDefaults(_ prefix: String = "CodexMultiAccount") -> UserDefaults { + let suiteName = "OpenUsageTests.\(prefix).\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + addTeardownBlock { defaults.removePersistentDomain(forName: suiteName) } + return defaults + } +} diff --git a/Tests/OpenUsageTests/CodexSwapAccountTests.swift b/Tests/OpenUsageTests/CodexSwapAccountTests.swift index 3586d0901..55c04af6a 100644 --- a/Tests/OpenUsageTests/CodexSwapAccountTests.swift +++ b/Tests/OpenUsageTests/CodexSwapAccountTests.swift @@ -57,7 +57,7 @@ final class CodexSwapAccountTests: XCTestCase { XCTAssertEqual(initial.codexCards.count, 2) XCTAssertEqual(initial.codexCards.first { $0.identity == a }?.id, "codex") let ids = Dictionary(uniqueKeysWithValues: initial.codexCards.map { ($0.identity.key, $0.id) }) - let registry = WidgetRegistry.from(ProviderCatalog.make(defaults: defaults, codexCards: initial.codexCards)) + let registry = WidgetRegistry.from(ProviderCatalog.make(defaults: defaults, codex: initial.codex)) let layout = LayoutStore(registry: registry, defaults: defaults) layout.setPinned(true, for: ids[b.key]! + ".weekly") _ = layout.reorderProvider(dragged: ids[b.key]!, target: ids[a.key]!) @@ -72,7 +72,7 @@ final class CodexSwapAccountTests: XCTestCase { let repeated = await assembly(files, store: store) XCTAssertEqual(repeated.codexCards, next.codexCards) XCTAssertTrue(next.codexCards.allSatisfy { !$0.allowsUnattributedHistory }) - let restored = LayoutStore(registry: .from(ProviderCatalog.make(codexCards: next.codexCards)), defaults: defaults) + let restored = LayoutStore(registry: .from(ProviderCatalog.make(codex: next.codex)), defaults: defaults) XCTAssertEqual(restored.pinnedMetricIDs, pins) XCTAssertEqual(restored.providerOrder, order) XCTAssertEqual(store.defaultBadgeHolder(family: "codex")?.identityKey, selected.key) @@ -91,8 +91,9 @@ final class CodexSwapAccountTests: XCTestCase { func testAdditionalCardInheritsDefaultsOnFreshAndExistingLayouts() async throws { let defaults = try defaults() - let cards = await assembly(fixture(), store: ProviderAccountsStore(defaults: defaults)).codexCards - let registry = WidgetRegistry.from(ProviderCatalog.make(codexCards: cards)) + let discovery = await assembly(fixture(), store: ProviderAccountsStore(defaults: defaults)).codex + let cards = discovery.cards + let registry = WidgetRegistry.from(ProviderCatalog.make(codex: discovery)) let ids = cards.map(\.id) func layout() -> LayoutStore { LayoutStore(registry: registry, defaults: defaults, diff --git a/Tests/OpenUsageTests/CodexSwapMaintainerReviewTests.swift b/Tests/OpenUsageTests/CodexSwapMaintainerReviewTests.swift index 4329e61b3..3a3c4943c 100644 --- a/Tests/OpenUsageTests/CodexSwapMaintainerReviewTests.swift +++ b/Tests/OpenUsageTests/CodexSwapMaintainerReviewTests.swift @@ -44,7 +44,7 @@ final class CodexSwapMaintainerReviewTests: XCTestCase { private func card(_ identity: CodexAccountIdentity, id: String = "codex") -> CodexAccountCard { CodexAccountCard(id: id, identity: identity, displayName: id, authHomes: [], - logHomes: [], allowsUnattributedHistory: false) + piCredentialSources: [], logHomes: [], allowsUnattributedHistory: false) } nonisolated private static func response(status: Int = 200) -> HTTPResponse { diff --git a/docs/providers/codex.md b/docs/providers/codex.md index f13f83974..232373709 100644 --- a/docs/providers/codex.md +++ b/docs/providers/codex.md @@ -40,6 +40,27 @@ Upgrading from a version without Swap support refreshes saved shell settings bef - Concurrent `xswap run` sessions are supported. Close Codex sessions before using `xswap switch` to change the global login, as required by Swap. +### Other Codex homes and pi logins + +OpenUsage also finds signed-in accounts in `CODEX_HOME`, `~/.codex`, `~/.config/codex`, sibling +`~/.codex-*` folders, sibling `~/.config/codex-*` folders, and pi's `auth.json` (`openai-codex`, +`openai-codex-2`, …). Logins are matched by ChatGPT workspace and email, so two users in the same +workspace remain separate cards, and the same account signed in through several homes and pi shares +one card, named by its xswap alias, then its pi label, then its workspace and email. A login held only +in the Keychain counts as an account too. A single account keeps the plain Codex card, which still +uses a login found only in a sibling home or in pi, and counts that home's session logs in its +spend tiles. Restart OpenUsage after adding or removing a login. + +These cards read their logins as they are. OpenUsage never rotates a token that belongs to another +Codex home, xswap, or pi; it re-reads each source on every refresh and tries every matching login. If +a card reports an expired login, use that account once in Codex or pi so the tool renews its own +token, then refresh OpenUsage. + +When more than one Codex account is known, OpenUsage excludes Codex, pi, and OpenCode spending that +cannot prove which account paid for it. Live limits and reset-credit actions remain available on each +card. A pi login or Codex home whose token names no account also disables unattributed spending, even +when it cannot form its own card and the plain Codex card is all that shows. + ## The spend tiles With multiple Codex accounts, spending without a reliable account owner is excluded, including @@ -62,6 +83,7 @@ For supported GPT-5.4, GPT-5.5, GPT-5.6, and GPT-6 models, requests above 272k i - **"Not logged in"** — run `codex` and sign in, then refresh. - **A Codex Swap account needs login**: run `xswap login ` for the named account, then refresh. +- **A pi-only account needs login**: use that account in pi to renew its token, then refresh OpenUsage. - **API-key-only setups** can't read subscription usage — sign in with your ChatGPT account instead. - **Spend tiles show "No data"** — OpenUsage found no qualifying Codex usage in Codex, pi, or OpenCode logs from the last 30 days. If your Codex home lives somewhere custom, set `CODEX_HOME` so both the Codex CLI and OpenUsage look in the same place. - **OpenCode usage is missing** — OpenCode must currently have an `openai` OAuth credential in diff --git a/docs/research/account-first-plan.md b/docs/research/account-first-plan.md index 16e90ffb0..e92ef327f 100644 --- a/docs/research/account-first-plan.md +++ b/docs/research/account-first-plan.md @@ -100,6 +100,10 @@ tests land in-slice (repo policy). Estimated source LOC excludes tests. ### Phase 5 — Codex multi-account + per-card resets (~700 LOC) +Shipped as 5a. Codex homes and pi `auth.json` logins merge by workspace and user identity. Each +card gets scoped limits and reset claims. Spending without durable account ownership stays excluded. +Keyring-mode homes (5b) still leave the family unscoped. + - **5a:** `CODEX_HOME` candidate scan with the strict identity rule — `tokens.account_id` or the id_token's ChatGPT account claim; a credential file that can't name its account never becomes a card (port `93e741e`). Scoped auth stores, per-identity log-root grouping, and the