Skip to content
238 changes: 238 additions & 0 deletions Sources/OpenUsage/Providers/Codex/CodexAccountDiscovery.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
import Foundation

struct CodexHomeLogin: Equatable, Sendable {
let home: String
let identity: CodexAccountIdentity
let planType: String?

var authPath: String { home + "/auth.json" }
}

struct PiCodexLogin: Equatable, Sendable {
let providerID: String
let identity: CodexAccountIdentity
let planType: String?
let label: String?
let authPath: String
}

struct CodexAccountDiscovery: 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],
options: [.skipsHiddenFiles]
Comment thread
jal-co marked this conversation as resolved.
Outdated
)) ?? []
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
}
}

static func configuredHomeValues(environment: EnvironmentReading) -> [String] {
let homes = environment.value(for: "CODEX_HOME")?
.split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty } ?? []
return homes.isEmpty ? ["~/.config/codex", "~/.codex"] : homes
}

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
)
}

func homeLogins(additionalHomes: [String] = []) -> [CodexHomeLogin] {
let homes = Self.uniqueHomes(
candidateHomes() + additionalHomes,
homeDirectory: homeDirectory()
)
return homes.compactMap { home in
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")
return nil
}
guard let text,
let auth = CodexAuthStore.parseAuth(text),
auth.tokens?.accessToken?.nilIfEmpty != nil,
let identity = CodexAccountIdentity(auth: auth)
else { return nil }
return CodexHomeLogin(
home: home,
identity: identity,
planType: Self.planType(inTokenPayload: Self.identityPayload(auth))
)
}
}

static let piCodexProviderPrefix = "openai-codex"

static func isPiCodexProvider(_ providerID: String) -> Bool {
guard providerID.hasPrefix(piCodexProviderPrefix) else { return false }
let suffix = providerID.dropFirst(piCodexProviderPrefix.count)
if suffix.isEmpty { return true }
guard suffix.first == "-" else { return false }
let digits = suffix.dropFirst()
return !digits.isEmpty && digits.allSatisfy(\.isNumber)
}

func piAgentDirectory() -> String {
if let configDir = environment.value(for: "PI_CODING_AGENT_DIR")?
.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty {
return Self.expandTilde(configDir, homeDirectory: homeDirectory()).trimmingTrailingSlashes
}
return homeDirectory().appendingPathComponent(".pi/agent").path
}

func piLogins() -> [PiCodexLogin] {
let agentDir = piAgentDirectory()
let authPath = agentDir + "/auth.json"
let object: [String: Any]
do {
guard let text = try files.readTextIfPresent(authPath) else { return [] }
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 []
}
object = parsed
} catch {
AppLog.error(.config, "accounts: pi auth.json could not be read; pi Codex logins skipped")
return []
}
let labels = piSubscriptionLabels(agentDir: agentDir)
return object.keys
.filter(Self.isPiCodexProvider)
.sorted { Self.piProviderIndex($0) < Self.piProviderIndex($1) }
.compactMap { providerID in
guard let auth = Self.piAuth(in: object, providerID: providerID),
let identity = CodexAccountIdentity(auth: auth),
CodexAccountIdentity.isComplete(key: identity.key)
else { return nil }
return PiCodexLogin(
providerID: providerID,
identity: identity,
planType: Self.planType(inTokenPayload: Self.identityPayload(auth)),
label: labels[providerID],
authPath: authPath
)
}
}

static func loadPiAuth(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 piAuth(in: object, providerID: providerID)
}

static func piProviderIndex(_ providerID: String) -> Int {
let suffix = providerID.dropFirst(piCodexProviderPrefix.count)
guard suffix.hasPrefix("-"), let index = Int(suffix.dropFirst()) else { return 1 }
return index
}

static func identityPayload(_ auth: CodexAuth) -> [String: Any]? {
if let payload = auth.tokens?.idToken.flatMap(ProviderParse.jwtPayload),
DefaultAccountObserver.chatGPTAccountID(inIDTokenPayload: payload) != nil {
return payload
}
return auth.tokens?.accessToken.flatMap(ProviderParse.jwtPayload)
?? auth.tokens?.idToken.flatMap(ProviderParse.jwtPayload)
}

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()
}

static func planType(inTokenPayload payload: [String: Any]?) -> String? {
guard let payload else { return nil }
let authClaim = payload["https://api.openai.com/auth"] as? [String: Any]
return (authClaim?["chatgpt_plan_type"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
}

private static func piAuth(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 piSubscriptionLabels(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.piCodexProviderPrefix,
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.piCodexProviderPrefix : "\(Self.piCodexProviderPrefix)-\(index)"
labels[providerID] = label
}
return labels
}

private static func uniqueHomes(_ homes: [String], homeDirectory: URL) -> [String] {
var seen = Set<String>()
return homes.compactMap { raw in
let expanded = expandTilde(raw, homeDirectory: homeDirectory).trimmingTrailingSlashes
let standardized = URL(fileURLWithPath: expanded).standardizedFileURL.path
return seen.insert(standardized).inserted ? standardized : nil
}
}

private static func expandTilde(_ path: String, homeDirectory: URL) -> String {
guard path == "~" || path.hasPrefix("~/") else { return path }
return homeDirectory.path + String(path.dropFirst(1))
}
}
29 changes: 22 additions & 7 deletions Sources/OpenUsage/Providers/Codex/CodexAuthStore+Accounts.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,22 +7,37 @@ 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,
home: FileManager.default.homeDirectoryForCurrentUser).isEmpty {
} else if CodexSwapAccount.discover(
environment: environment,
files: files,
home: FileManager.default.homeDirectoryForCurrentUser
).isEmpty {
return candidate
}
// A first Finder launch can defer account assembly until shell discovery completes.
// Its temporary default card must also stay read-only, even with incomplete identity data.
// xswap snapshots can share a refresh token with a running CLI. Only Codex may rotate it.
candidate.auth.tokens?.refreshToken = nil
candidate.readOnly = true

let readOnly: Bool
switch candidate.source {
case .file(let path):
let home = URL(fileURLWithPath: (path as NSString).expandingTildeInPath)
.deletingLastPathComponent().standardizedFileURL.path
readOnly = !writableAuthHomes.contains(home)
case .keychain:
readOnly = true
case .pi:
readOnly = true
}
if readOnly || expectedIdentity == nil {
candidate.auth.tokens?.refreshToken = nil
candidate.readOnly = true
}
return candidate
}

func isCurrent(_ candidate: CodexAuthState) async -> Bool {
switch candidate.source {
case .file(let path): loadAuth(at: path) == candidate
case .keychain: await loadOffMainActor { loadKeychainAuth() } == candidate
case .pi(let source): loadPiAuth(source) == candidate
}
}
}
43 changes: 33 additions & 10 deletions Sources/OpenUsage/Providers/Codex/CodexAuthStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,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
case pi(CodexPiCredentialSource)
}

var auth: CodexAuth
Expand Down Expand Up @@ -88,33 +94,46 @@ 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 writableAuthHomes: Set<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] = [],
writableAuthHomes: Set<String> = [],
piCredentialSources: [CodexPiCredentialSource] = []
) {
self.environment = environment
self.files = files
self.keychain = keychain
self.now = now
self.expectedIdentity = expectedIdentity
self.additionalAuthHomes = additionalAuthHomes
self.writableAuthHomes = writableAuthHomes
self.piCredentialSources = piCredentialSources
}

func loadAuthCandidates() -> [CodexAuthState] {
authPaths().compactMap { loadAuth(at: $0) }
+ piCredentialSources.compactMap(loadPiAuth)
}

func loadPiAuth(_ source: CodexPiCredentialSource) -> CodexAuthState? {
guard let auth = CodexAccountDiscovery.loadPiAuth(
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 @@ -156,6 +175,8 @@ struct CodexAuthStore: Sendable {
try files.writeText(path, text)
case .keychain:
try keychain.writeGenericPassword(service: Self.keychainService, value: text)
case .pi:
throw CodexAuthError.tokenConflict
}
}

Expand Down Expand Up @@ -189,18 +210,20 @@ struct CodexAuthStore: Sendable {
}

func authPaths() -> [String] {
let homes = (codexHome().map { [$0] } ?? Self.defaultAuthHomes) + additionalAuthHomes
let homes = CodexAccountDiscovery.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? {
guard let codexHome = environment.value(for: "CODEX_HOME")?.trimmingCharacters(in: .whitespacesAndNewlines),
!codexHome.isEmpty
else {
return nil
}
return codexHome
environment.value(for: "CODEX_HOME")?
.split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.first { !$0.isEmpty }
}

static func parseAuth(_ text: String) -> CodexAuth? {
Expand Down
Loading