Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Sources/OpenUsage/App/AppContainer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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
}
}
}
30 changes: 26 additions & 4 deletions Sources/OpenUsage/Providers/Codex/CodexAuthStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -90,33 +96,43 @@ 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
var keychain: KeychainAccessing
var now: @Sendable () -> Date
var expectedIdentity: CodexAccountIdentity?
var additionalAuthHomes: [String]
var piCredentialSources: [CodexPiCredentialSource]

init(
environment: EnvironmentReading = ProcessEnvironmentReader(),
files: TextFileAccessing = LocalTextFileAccessor(),
keychain: KeychainAccessing = SecurityKeychainAccessor(),
now: @escaping @Sendable () -> Date = Date.init,
expectedIdentity: CodexAccountIdentity? = nil,
additionalAuthHomes: [String] = []
additionalAuthHomes: [String] = [],
piCredentialSources: [CodexPiCredentialSource] = []
) {
self.environment = environment
self.files = files
self.keychain = keychain
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
Expand Down Expand Up @@ -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
}
}

Expand Down Expand Up @@ -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<String>()
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? {
Expand Down
130 changes: 130 additions & 0 deletions Sources/OpenUsage/Providers/Codex/CodexHomeScanner.swift
Original file line number Diff line number Diff line change
@@ -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<String>()
return homes.compactMap { raw in
let standardized = standardizedHome(raw, homeDirectory: homeDirectory)
return seen.insert(standardized).inserted ? standardized : nil
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 <account>`, then refresh."
"No valid login for this Codex account. Sign in with Codex, xswap, or pi, then refresh."
}
}
2 changes: 2 additions & 0 deletions Sources/OpenUsage/Providers/Codex/CodexProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down
10 changes: 7 additions & 3 deletions Sources/OpenUsage/Providers/Codex/CodexSwapAccount.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading