import AppKitimport CodexBarCoreimport Foundation protocol ManagedCodexHomeProducing: Sendable { func makeHomeURL() -> URL func validateManagedHomeForDeletion(_ url: URL) throws} protocol ManagedCodexLoginRunning: Sendable { func run(homePath: String, timeout: TimeInterval) async -> CodexLoginRunner.Result} protocol ManagedCodexIdentityReading: Sendable { func loadAccountIdentity(homePath: String) throws -> CodexAuthBackedAccount} protocol ManagedCodexWorkspaceResolving: Sendable { func resolveWorkspaceIdentity(homePath: String, providerAccountID: String) async -> CodexOpenAIWorkspaceIdentity? func availableWorkspaceIdentities(homePath: String) async -> [CodexOpenAIWorkspaceIdentity]} extension ManagedCodexWorkspaceResolving { func availableWorkspaceIdentities(homePath _: String) async -> [CodexOpenAIWorkspaceIdentity] { [] }} protocol ManagedCodexWorkspaceSelecting: Sendable { @MainActor func selectWorkspace( email: String, currentWorkspaceID: String?, workspaces: [CodexOpenAIWorkspaceIdentity]) async -> CodexOpenAIWorkspaceIdentity?} enum ManagedCodexAccountServiceError: Error, Equatable { case loginFailed(CodexLoginRunner.Result) case missingEmail case workspaceSelectionCancelled case unsafeManagedHome(String)} extension ManagedCodexAccountServiceError { var userFacingMessage: String { switch self { case let .loginFailed(result): CodexLoginAlertPresentation.managedLoginFailureMessage(for: result) case .missingEmail: L("managed_login_missing_email") case .workspaceSelectionCancelled: L("workspace_selection_cancelled") case let .unsafeManagedHome(path): String(format: L("unsafe_managed_home"), path) } }} struct ManagedCodexHomeFactory: ManagedCodexHomeProducing { let root: URL init(root: URL = Self.defaultRootURL(), fileManager: FileManager = .default) { let standardizedRoot = root.standardizedFileURL if standardizedRoot.path != root.path { self.root = standardizedRoot } else { self.root = root } _ = fileManager } func makeHomeURL() -> URL { self.root.appendingPathComponent(UUID().uuidString, isDirectory: true) } func validateManagedHomeForDeletion(_ url: URL) throws { let rootPath = self.root.standardizedFileURL.path let targetPath = url.standardizedFileURL.path let rootPrefix = rootPath.hasSuffix("/") ? rootPath : rootPath + "/" guard targetPath.hasPrefix(rootPrefix), targetPath != rootPath else { throw ManagedCodexAccountServiceError.unsafeManagedHome(url.path) } } static func defaultRootURL(fileManager: FileManager = .default) -> URL { let base = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first ?? fileManager.homeDirectoryForCurrentUser return base .appendingPathComponent("CodexBar", isDirectory: true) .appendingPathComponent("managed-codex-homes", isDirectory: true) }} struct DefaultManagedCodexLoginRunner: ManagedCodexLoginRunning { func run(homePath: String, timeout: TimeInterval) async -> CodexLoginRunner.Result { await CodexLoginRunner.run(homePath: homePath, timeout: timeout) }} struct DefaultManagedCodexIdentityReader: ManagedCodexIdentityReading { func loadAccountIdentity(homePath: String) throws -> CodexAuthBackedAccount { let env = CodexHomeScope.scopedEnvironment( base: ProcessInfo.processInfo.environment, codexHome: homePath) return UsageFetcher(environment: env).loadAuthBackedCodexAccount() }} struct DefaultManagedCodexWorkspaceResolver: ManagedCodexWorkspaceResolving { private let workspaceCache: CodexOpenAIWorkspaceIdentityCache init( workspaceCache: CodexOpenAIWorkspaceIdentityCache = CodexOpenAIWorkspaceIdentityCache()) { self.workspaceCache = workspaceCache } func resolveWorkspaceIdentity(homePath: String, providerAccountID: String) async -> CodexOpenAIWorkspaceIdentity? { let normalizedProviderAccountID = ManagedCodexAccount.normalizeProviderAccountID(providerAccountID) ?? providerAccountID let env = CodexHomeScope.scopedEnvironment( base: ProcessInfo.processInfo.environment, codexHome: homePath) if let credentials = try? CodexOAuthCredentialsStore.load(env: env), let authoritativeIdentity = try? await CodexOpenAIWorkspaceResolver.resolve(credentials: credentials) { try? self.workspaceCache.store(authoritativeIdentity) return authoritativeIdentity } let cachedLabel = self.workspaceCache.workspaceLabel(for: normalizedProviderAccountID) return CodexOpenAIWorkspaceIdentity( workspaceAccountID: normalizedProviderAccountID, workspaceLabel: cachedLabel) } func availableWorkspaceIdentities(homePath: String) async -> [CodexOpenAIWorkspaceIdentity] { let env = CodexHomeScope.scopedEnvironment( base: ProcessInfo.processInfo.environment, codexHome: homePath) guard let credentials = try? CodexOAuthCredentialsStore.load(env: env), let identities = try? await CodexOpenAIWorkspaceResolver.listWorkspaces(credentials: credentials) else { return [] } for identity in identities { try? self.workspaceCache.store(identity) } return identities }} struct CodexWorkspaceAlertSelector: ManagedCodexWorkspaceSelecting { @MainActor func selectWorkspace( email: String, currentWorkspaceID: String?, workspaces: [CodexOpenAIWorkspaceIdentity]) async -> CodexOpenAIWorkspaceIdentity? { guard workspaces.count > 1 else { return workspaces.first } let popup = NSPopUpButton(frame: NSRect(x: 0, y: 0, width: 360, height: 26), pullsDown: false) let sortedWorkspaces = workspaces.sorted { lhs, rhs in self.workspaceTitle(lhs) < self.workspaceTitle(rhs) } for workspace in sortedWorkspaces { popup.addItem(withTitle: self.workspaceTitle(workspace)) popup.lastItem?.representedObject = workspace.workspaceAccountID } if let currentWorkspaceID, let selectedIndex = sortedWorkspaces.firstIndex(where: { $0.workspaceAccountID == currentWorkspaceID }) { popup.selectItem(at: selectedIndex) } let alert = NSAlert() alert.messageText = L("Choose Codex workspace") alert.informativeText = String(format: L("multiple_workspaces_found"), email) alert.alertStyle = .informational alert.accessoryView = popup alert.addButton(withTitle: L("Add Workspace")) alert.addButton(withTitle: L("Cancel")) guard alert.runModal() == .alertFirstButtonReturn else { return nil } let selectedWorkspaceID = popup.selectedItem?.representedObject as? String return sortedWorkspaces.first { $0.workspaceAccountID == selectedWorkspaceID } } private func workspaceTitle(_ workspace: CodexOpenAIWorkspaceIdentity) -> String { workspace.workspaceLabel ?? workspace.workspaceAccountID }} @MainActorfinal class ManagedCodexAccountService { private let store: any ManagedCodexAccountStoring private let homeFactory: any ManagedCodexHomeProducing private let loginRunner: any ManagedCodexLoginRunning private let identityReader: any ManagedCodexIdentityReading private let workspaceResolver: any ManagedCodexWorkspaceResolving private let workspaceSelector: any ManagedCodexWorkspaceSelecting private let fileManager: FileManager init( store: any ManagedCodexAccountStoring, homeFactory: any ManagedCodexHomeProducing, loginRunner: any ManagedCodexLoginRunning, identityReader: any ManagedCodexIdentityReading, workspaceResolver: any ManagedCodexWorkspaceResolving = DefaultManagedCodexWorkspaceResolver(), workspaceSelector: any ManagedCodexWorkspaceSelecting = CodexWorkspaceAlertSelector(), fileManager: FileManager = .default) { self.store = store self.homeFactory = homeFactory self.loginRunner = loginRunner self.identityReader = identityReader self.workspaceResolver = workspaceResolver self.workspaceSelector = workspaceSelector self.fileManager = fileManager } convenience init(fileManager: FileManager = .default) { self.init( store: FileManagedCodexAccountStore(fileManager: fileManager), homeFactory: ManagedCodexHomeFactory(fileManager: fileManager), loginRunner: DefaultManagedCodexLoginRunner(), identityReader: DefaultManagedCodexIdentityReader(), workspaceResolver: DefaultManagedCodexWorkspaceResolver(), workspaceSelector: CodexWorkspaceAlertSelector(), fileManager: fileManager) } func authenticateManagedAccount( existingAccountID: UUID? = nil, timeout: TimeInterval = 120) async throws -> ManagedCodexAccount { let snapshot = try self.store.loadAccounts() let homeURL = self.homeFactory.makeHomeURL() try self.fileManager.createDirectory(at: homeURL, withIntermediateDirectories: true) let account: ManagedCodexAccount let existingHomePathsToDelete: [String] do { let result = await self.loginRunner.run(homePath: homeURL.path, timeout: timeout) guard case .success = result.outcome else { throw ManagedCodexAccountServiceError.loginFailed(result) } let identity = try self.identityReader.loadAccountIdentity(homePath: homeURL.path) guard let rawEmail = identity.email?.trimmingCharacters(in: .whitespacesAndNewlines), !rawEmail.isEmpty else { throw ManagedCodexAccountServiceError.missingEmail } let authenticatedProviderAccountID: String? = switch identity.identity { case let .providerAccount(id): ManagedCodexAccount.normalizeProviderAccountID(id) case .emailOnly, .unresolved: nil } let selectedWorkspace = try await self.selectedWorkspaceIdentity( email: rawEmail, homePath: homeURL.path, authenticatedProviderAccountID: authenticatedProviderAccountID) let providerAccountID = selectedWorkspace?.workspaceAccountID ?? authenticatedProviderAccountID let workspaceIdentity: CodexOpenAIWorkspaceIdentity? = if let selectedWorkspace { selectedWorkspace } else { await self.resolvedWorkspaceIdentity( homePath: homeURL.path, providerAccountID: providerAccountID) } let now = Date().timeIntervalSince1970 let existing = self.reconciledExistingAccount( authenticatedEmail: rawEmail, providerAccountID: providerAccountID, existingAccountID: existingAccountID, snapshot: snapshot) let persistedMetadata = self.persistedProviderMetadata( authenticatedProviderAccountID: providerAccountID, resolvedWorkspaceIdentity: workspaceIdentity, existingAccount: existing) account = ManagedCodexAccount( id: existing?.id ?? UUID(), email: rawEmail, providerAccountID: persistedMetadata.providerAccountID, workspaceLabel: persistedMetadata.workspaceLabel, workspaceAccountID: persistedMetadata.workspaceAccountID, authFingerprint: CodexAuthFingerprint.fingerprint( homePath: homeURL.path, fileManager: self.fileManager), managedHomePath: homeURL.path, createdAt: existing?.createdAt ?? now, updatedAt: now, lastAuthenticatedAt: now) let replacedAccountIDs = self.replacedAccountIDs( authenticatedEmail: rawEmail, providerAccountID: providerAccountID, existingAccountID: existingAccountID, matchedAccountID: existing?.id, snapshot: snapshot) existingHomePathsToDelete = snapshot.accounts .filter { replacedAccountIDs.contains($0.id) } .map(\.managedHomePath) let updatedSnapshot = ManagedCodexAccountSet( version: snapshot.version, accounts: snapshot.accounts.filter { replacedAccountIDs.contains($0.id) == false } + [account]) try self.store.storeAccounts(updatedSnapshot) } catch { try? self.removeManagedHomeIfSafe(atPath: homeURL.path) throw error } for existingHomePathToDelete in existingHomePathsToDelete where existingHomePathToDelete != homeURL.path { try? self.removeManagedHomeIfSafe(atPath: existingHomePathToDelete) } return account } func removeManagedAccount(id: UUID) async throws { let snapshot = try self.store.loadAccounts() guard let account = snapshot.account(id: id) else { return } let homeURL = URL(fileURLWithPath: account.managedHomePath, isDirectory: true) let canDeleteHome = (try? self.homeFactory.validateManagedHomeForDeletion(homeURL)) != nil let remaining = snapshot.accounts.filter { $0.id != id } try self.store.storeAccounts(ManagedCodexAccountSet( version: snapshot.version, accounts: remaining)) if canDeleteHome, self.fileManager.fileExists(atPath: homeURL.path) { try? self.fileManager.removeItem(at: homeURL) } } private func removeManagedHomeIfSafe(atPath path: String) throws { let homeURL = URL(fileURLWithPath: path, isDirectory: true) try self.homeFactory.validateManagedHomeForDeletion(homeURL) if self.fileManager.fileExists(atPath: homeURL.path) { try self.fileManager.removeItem(at: homeURL) } } private func selectedWorkspaceIdentity( email: String, homePath: String, authenticatedProviderAccountID: String?) async throws -> CodexOpenAIWorkspaceIdentity? { let workspaces = await self.workspaceResolver.availableWorkspaceIdentities(homePath: homePath) guard workspaces.count > 1 else { return workspaces.first { $0.workspaceAccountID == authenticatedProviderAccountID } } guard let selected = await self.workspaceSelector.selectWorkspace( email: email, currentWorkspaceID: authenticatedProviderAccountID, workspaces: workspaces) else { throw ManagedCodexAccountServiceError.workspaceSelectionCancelled } try self.persistSelectedWorkspaceID(selected.workspaceAccountID, homePath: homePath) return selected } private func resolvedWorkspaceIdentity( homePath: String, providerAccountID: String?) async -> CodexOpenAIWorkspaceIdentity? { guard let providerAccountID else { return nil } return await self.workspaceResolver.resolveWorkspaceIdentity( homePath: homePath, providerAccountID: providerAccountID) } private func persistSelectedWorkspaceID(_ workspaceID: String, homePath: String) throws { let env = CodexHomeScope.scopedEnvironment( base: ProcessInfo.processInfo.environment, codexHome: homePath) let credentials = try CodexOAuthCredentialsStore.load(env: env) try CodexOAuthCredentialsStore.save( CodexOAuthCredentials( accessToken: credentials.accessToken, refreshToken: credentials.refreshToken, idToken: credentials.idToken, accountId: workspaceID, lastRefresh: credentials.lastRefresh), env: env) } private func reconciledExistingAccount( authenticatedEmail: String, providerAccountID: String?, existingAccountID: UUID?, snapshot: ManagedCodexAccountSet) -> ManagedCodexAccount? { if let providerAccountID, let existingByProviderAccountID = snapshot.account( email: authenticatedEmail, providerAccountID: providerAccountID) { return existingByProviderAccountID } if let existingAccountID, let existingByID = snapshot.account(id: existingAccountID), existingByID.email == Self.normalizeEmail(authenticatedEmail), providerAccountID == nil || existingByID.providerAccountID == nil { return existingByID } guard providerAccountID == nil else { return nil } // Email-only reconciliation is a legacy/hardening fallback. Once an auth payload carries a // provider account ID, matching must stay on that ID so same-email workspaces can coexist. return snapshot.account(email: authenticatedEmail) } private func replacedAccountIDs( authenticatedEmail: String, providerAccountID: String?, existingAccountID: UUID?, matchedAccountID: UUID?, snapshot: ManagedCodexAccountSet) -> Set<UUID> { var ids: Set<UUID> = [] let normalizedEmail = Self.normalizeEmail(authenticatedEmail) if let matchedAccountID { ids.insert(matchedAccountID) } if providerAccountID != nil { let legacySameEmailIDs = snapshot.accounts .filter { $0.id != matchedAccountID && $0.providerAccountID == nil && $0.email == normalizedEmail } .map(\.id) ids.formUnion(legacySameEmailIDs) } guard let existingAccountID, existingAccountID != matchedAccountID, let existingByID = snapshot.account(id: existingAccountID) else { return ids } if existingByID.providerAccountID == nil, existingByID.email == normalizedEmail, providerAccountID != nil { ids.insert(existingAccountID) } return ids } private static func normalizeEmail(_ email: String) -> String { email.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } private func persistedProviderMetadata( authenticatedProviderAccountID: String?, resolvedWorkspaceIdentity: CodexOpenAIWorkspaceIdentity?, existingAccount: ManagedCodexAccount?) -> ( providerAccountID: String?, workspaceLabel: String?, workspaceAccountID: String?) { if let authenticatedProviderAccountID { let isExistingProviderMatch = existingAccount?.providerAccountID == authenticatedProviderAccountID return ( providerAccountID: authenticatedProviderAccountID, workspaceLabel: resolvedWorkspaceIdentity?.workspaceLabel ?? (isExistingProviderMatch ? existingAccount?.workspaceLabel : nil), workspaceAccountID: resolvedWorkspaceIdentity?.workspaceAccountID ?? (isExistingProviderMatch ? existingAccount?.workspaceAccountID : nil) ?? authenticatedProviderAccountID) } guard let existingAccount, existingAccount.providerAccountID != nil else { return (providerAccountID: nil, workspaceLabel: nil, workspaceAccountID: nil) } return ( providerAccountID: existingAccount.providerAccountID, workspaceLabel: existingAccount.workspaceLabel, workspaceAccountID: existingAccount.workspaceAccountID ?? existingAccount.providerAccountID) }}
import AppKit
import CodexBarCore
import Foundation
protocol ManagedCodexHomeProducing: Sendable {
func makeHomeURL() -> URL
func validateManagedHomeForDeletion(_ url: URL) throws
}
protocol ManagedCodexLoginRunning: Sendable {
func run(homePath: String, timeout: TimeInterval) async -> CodexLoginRunner.Result
}
protocol ManagedCodexIdentityReading: Sendable {
func loadAccountIdentity(homePath: String) throws -> CodexAuthBackedAccount
}
protocol ManagedCodexWorkspaceResolving: Sendable {
func resolveWorkspaceIdentity(homePath: String, providerAccountID: String) async -> CodexOpenAIWorkspaceIdentity?
func availableWorkspaceIdentities(homePath: String) async -> [CodexOpenAIWorkspaceIdentity]
}
extension ManagedCodexWorkspaceResolving {
func availableWorkspaceIdentities(homePath _: String) async -> [CodexOpenAIWorkspaceIdentity] {
[]
}
}
protocol ManagedCodexWorkspaceSelecting: Sendable {
@MainActor
func selectWorkspace(
email: String,
currentWorkspaceID: String?,
workspaces: [CodexOpenAIWorkspaceIdentity]) async -> CodexOpenAIWorkspaceIdentity?
}
enum ManagedCodexAccountServiceError: Error, Equatable {
case loginFailed(CodexLoginRunner.Result)
case missingEmail
case workspaceSelectionCancelled
case unsafeManagedHome(String)
}
extension ManagedCodexAccountServiceError {
var userFacingMessage: String {
switch self {
case let .loginFailed(result):
CodexLoginAlertPresentation.managedLoginFailureMessage(for: result)
case .missingEmail:
L("managed_login_missing_email")
case .workspaceSelectionCancelled:
L("workspace_selection_cancelled")
case let .unsafeManagedHome(path):
String(format: L("unsafe_managed_home"), path)
}
}
}
struct ManagedCodexHomeFactory: ManagedCodexHomeProducing {
let root: URL
init(root: URL = Self.defaultRootURL(), fileManager: FileManager = .default) {
let standardizedRoot = root.standardizedFileURL
if standardizedRoot.path != root.path {
self.root = standardizedRoot
} else {
self.root = root
}
_ = fileManager
}
func makeHomeURL() -> URL {
self.root.appendingPathComponent(UUID().uuidString, isDirectory: true)
}
func validateManagedHomeForDeletion(_ url: URL) throws {
let rootPath = self.root.standardizedFileURL.path
let targetPath = url.standardizedFileURL.path
let rootPrefix = rootPath.hasSuffix("/") ? rootPath : rootPath + "/"
guard targetPath.hasPrefix(rootPrefix), targetPath != rootPath else {
throw ManagedCodexAccountServiceError.unsafeManagedHome(url.path)
}
}
static func defaultRootURL(fileManager: FileManager = .default) -> URL {
let base = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
?? fileManager.homeDirectoryForCurrentUser
return base
.appendingPathComponent("CodexBar", isDirectory: true)
.appendingPathComponent("managed-codex-homes", isDirectory: true)
}
}
struct DefaultManagedCodexLoginRunner: ManagedCodexLoginRunning {
func run(homePath: String, timeout: TimeInterval) async -> CodexLoginRunner.Result {
await CodexLoginRunner.run(homePath: homePath, timeout: timeout)
}
}
struct DefaultManagedCodexIdentityReader: ManagedCodexIdentityReading {
func loadAccountIdentity(homePath: String) throws -> CodexAuthBackedAccount {
let env = CodexHomeScope.scopedEnvironment(
base: ProcessInfo.processInfo.environment,
codexHome: homePath)
return UsageFetcher(environment: env).loadAuthBackedCodexAccount()
}
}
struct DefaultManagedCodexWorkspaceResolver: ManagedCodexWorkspaceResolving {
private let workspaceCache: CodexOpenAIWorkspaceIdentityCache
init(
workspaceCache: CodexOpenAIWorkspaceIdentityCache = CodexOpenAIWorkspaceIdentityCache())
{
self.workspaceCache = workspaceCache
}
func resolveWorkspaceIdentity(homePath: String, providerAccountID: String) async -> CodexOpenAIWorkspaceIdentity? {
let normalizedProviderAccountID = ManagedCodexAccount.normalizeProviderAccountID(providerAccountID)
?? providerAccountID
let env = CodexHomeScope.scopedEnvironment(
base: ProcessInfo.processInfo.environment,
codexHome: homePath)
if let credentials = try? CodexOAuthCredentialsStore.load(env: env),
let authoritativeIdentity = try? await CodexOpenAIWorkspaceResolver.resolve(credentials: credentials)
{
try? self.workspaceCache.store(authoritativeIdentity)
return authoritativeIdentity
}
let cachedLabel = self.workspaceCache.workspaceLabel(for: normalizedProviderAccountID)
return CodexOpenAIWorkspaceIdentity(
workspaceAccountID: normalizedProviderAccountID,
workspaceLabel: cachedLabel)
}
func availableWorkspaceIdentities(homePath: String) async -> [CodexOpenAIWorkspaceIdentity] {
let env = CodexHomeScope.scopedEnvironment(
base: ProcessInfo.processInfo.environment,
codexHome: homePath)
guard let credentials = try? CodexOAuthCredentialsStore.load(env: env),
let identities = try? await CodexOpenAIWorkspaceResolver.listWorkspaces(credentials: credentials)
else {
return []
}
for identity in identities {
try? self.workspaceCache.store(identity)
}
return identities
}
}
struct CodexWorkspaceAlertSelector: ManagedCodexWorkspaceSelecting {
@MainActor
func selectWorkspace(
email: String,
currentWorkspaceID: String?,
workspaces: [CodexOpenAIWorkspaceIdentity]) async -> CodexOpenAIWorkspaceIdentity?
{
guard workspaces.count > 1 else { return workspaces.first }
let popup = NSPopUpButton(frame: NSRect(x: 0, y: 0, width: 360, height: 26), pullsDown: false)
let sortedWorkspaces = workspaces.sorted { lhs, rhs in
self.workspaceTitle(lhs) < self.workspaceTitle(rhs)
}
for workspace in sortedWorkspaces {
popup.addItem(withTitle: self.workspaceTitle(workspace))
popup.lastItem?.representedObject = workspace.workspaceAccountID
}
if let currentWorkspaceID,
let selectedIndex = sortedWorkspaces.firstIndex(where: { $0.workspaceAccountID == currentWorkspaceID })
{
popup.selectItem(at: selectedIndex)
}
let alert = NSAlert()
alert.messageText = L("Choose Codex workspace")
alert.informativeText = String(format: L("multiple_workspaces_found"), email)
alert.alertStyle = .informational
alert.accessoryView = popup
alert.addButton(withTitle: L("Add Workspace"))
alert.addButton(withTitle: L("Cancel"))
guard alert.runModal() == .alertFirstButtonReturn else {
return nil
}
let selectedWorkspaceID = popup.selectedItem?.representedObject as? String
return sortedWorkspaces.first { $0.workspaceAccountID == selectedWorkspaceID }
}
private func workspaceTitle(_ workspace: CodexOpenAIWorkspaceIdentity) -> String {
workspace.workspaceLabel ?? workspace.workspaceAccountID
}
}
@MainActor
final class ManagedCodexAccountService {
private let store: any ManagedCodexAccountStoring
private let homeFactory: any ManagedCodexHomeProducing
private let loginRunner: any ManagedCodexLoginRunning
private let identityReader: any ManagedCodexIdentityReading
private let workspaceResolver: any ManagedCodexWorkspaceResolving
private let workspaceSelector: any ManagedCodexWorkspaceSelecting
private let fileManager: FileManager
init(
store: any ManagedCodexAccountStoring,
homeFactory: any ManagedCodexHomeProducing,
loginRunner: any ManagedCodexLoginRunning,
identityReader: any ManagedCodexIdentityReading,
workspaceResolver: any ManagedCodexWorkspaceResolving = DefaultManagedCodexWorkspaceResolver(),
workspaceSelector: any ManagedCodexWorkspaceSelecting = CodexWorkspaceAlertSelector(),
fileManager: FileManager = .default)
{
self.store = store
self.homeFactory = homeFactory
self.loginRunner = loginRunner
self.identityReader = identityReader
self.workspaceResolver = workspaceResolver
self.workspaceSelector = workspaceSelector
self.fileManager = fileManager
}
convenience init(fileManager: FileManager = .default) {
self.init(
store: FileManagedCodexAccountStore(fileManager: fileManager),
homeFactory: ManagedCodexHomeFactory(fileManager: fileManager),
loginRunner: DefaultManagedCodexLoginRunner(),
identityReader: DefaultManagedCodexIdentityReader(),
workspaceResolver: DefaultManagedCodexWorkspaceResolver(),
workspaceSelector: CodexWorkspaceAlertSelector(),
fileManager: fileManager)
}
func authenticateManagedAccount(
existingAccountID: UUID? = nil,
timeout: TimeInterval = 120)
async throws -> ManagedCodexAccount
{
let snapshot = try self.store.loadAccounts()
let homeURL = self.homeFactory.makeHomeURL()
try self.fileManager.createDirectory(at: homeURL, withIntermediateDirectories: true)
let account: ManagedCodexAccount
let existingHomePathsToDelete: [String]
do {
let result = await self.loginRunner.run(homePath: homeURL.path, timeout: timeout)
guard case .success = result.outcome else { throw ManagedCodexAccountServiceError.loginFailed(result) }
let identity = try self.identityReader.loadAccountIdentity(homePath: homeURL.path)
guard let rawEmail = identity.email?.trimmingCharacters(in: .whitespacesAndNewlines),
!rawEmail.isEmpty
else {
throw ManagedCodexAccountServiceError.missingEmail
}
let authenticatedProviderAccountID: String? = switch identity.identity {
case let .providerAccount(id):
ManagedCodexAccount.normalizeProviderAccountID(id)
case .emailOnly, .unresolved:
nil
}
let selectedWorkspace = try await self.selectedWorkspaceIdentity(
email: rawEmail,
homePath: homeURL.path,
authenticatedProviderAccountID: authenticatedProviderAccountID)
let providerAccountID = selectedWorkspace?.workspaceAccountID ?? authenticatedProviderAccountID
let workspaceIdentity: CodexOpenAIWorkspaceIdentity? = if let selectedWorkspace {
selectedWorkspace
} else {
await self.resolvedWorkspaceIdentity(
homePath: homeURL.path,
providerAccountID: providerAccountID)
}
let now = Date().timeIntervalSince1970
let existing = self.reconciledExistingAccount(
authenticatedEmail: rawEmail,
providerAccountID: providerAccountID,
existingAccountID: existingAccountID,
snapshot: snapshot)
let persistedMetadata = self.persistedProviderMetadata(
authenticatedProviderAccountID: providerAccountID,
resolvedWorkspaceIdentity: workspaceIdentity,
existingAccount: existing)
account = ManagedCodexAccount(
id: existing?.id ?? UUID(),
email: rawEmail,
providerAccountID: persistedMetadata.providerAccountID,
workspaceLabel: persistedMetadata.workspaceLabel,
workspaceAccountID: persistedMetadata.workspaceAccountID,
authFingerprint: CodexAuthFingerprint.fingerprint(
homePath: homeURL.path,
fileManager: self.fileManager),
managedHomePath: homeURL.path,
createdAt: existing?.createdAt ?? now,
updatedAt: now,
lastAuthenticatedAt: now)
let replacedAccountIDs = self.replacedAccountIDs(
authenticatedEmail: rawEmail,
providerAccountID: providerAccountID,
existingAccountID: existingAccountID,
matchedAccountID: existing?.id,
snapshot: snapshot)
existingHomePathsToDelete = snapshot.accounts
.filter { replacedAccountIDs.contains($0.id) }
.map(\.managedHomePath)
let updatedSnapshot = ManagedCodexAccountSet(
version: snapshot.version,
accounts: snapshot.accounts.filter { replacedAccountIDs.contains($0.id) == false } + [account])
try self.store.storeAccounts(updatedSnapshot)
} catch {
try? self.removeManagedHomeIfSafe(atPath: homeURL.path)
throw error
}
for existingHomePathToDelete in existingHomePathsToDelete where existingHomePathToDelete != homeURL.path {
try? self.removeManagedHomeIfSafe(atPath: existingHomePathToDelete)
}
return account
}
func removeManagedAccount(id: UUID) async throws {
let snapshot = try self.store.loadAccounts()
guard let account = snapshot.account(id: id) else { return }
let homeURL = URL(fileURLWithPath: account.managedHomePath, isDirectory: true)
let canDeleteHome = (try? self.homeFactory.validateManagedHomeForDeletion(homeURL)) != nil
let remaining = snapshot.accounts.filter { $0.id != id }
try self.store.storeAccounts(ManagedCodexAccountSet(
version: snapshot.version,
accounts: remaining))
if canDeleteHome, self.fileManager.fileExists(atPath: homeURL.path) {
try? self.fileManager.removeItem(at: homeURL)
}
}
private func removeManagedHomeIfSafe(atPath path: String) throws {
let homeURL = URL(fileURLWithPath: path, isDirectory: true)
try self.homeFactory.validateManagedHomeForDeletion(homeURL)
if self.fileManager.fileExists(atPath: homeURL.path) {
try self.fileManager.removeItem(at: homeURL)
}
}
private func selectedWorkspaceIdentity(
email: String,
homePath: String,
authenticatedProviderAccountID: String?) async throws -> CodexOpenAIWorkspaceIdentity?
{
let workspaces = await self.workspaceResolver.availableWorkspaceIdentities(homePath: homePath)
guard workspaces.count > 1 else {
return workspaces.first { $0.workspaceAccountID == authenticatedProviderAccountID }
}
guard let selected = await self.workspaceSelector.selectWorkspace(
email: email,
currentWorkspaceID: authenticatedProviderAccountID,
workspaces: workspaces)
else {
throw ManagedCodexAccountServiceError.workspaceSelectionCancelled
}
try self.persistSelectedWorkspaceID(selected.workspaceAccountID, homePath: homePath)
return selected
}
private func resolvedWorkspaceIdentity(
homePath: String,
providerAccountID: String?) async -> CodexOpenAIWorkspaceIdentity?
{
guard let providerAccountID else { return nil }
return await self.workspaceResolver.resolveWorkspaceIdentity(
homePath: homePath,
providerAccountID: providerAccountID)
}
private func persistSelectedWorkspaceID(_ workspaceID: String, homePath: String) throws {
let env = CodexHomeScope.scopedEnvironment(
base: ProcessInfo.processInfo.environment,
codexHome: homePath)
let credentials = try CodexOAuthCredentialsStore.load(env: env)
try CodexOAuthCredentialsStore.save(
CodexOAuthCredentials(
accessToken: credentials.accessToken,
refreshToken: credentials.refreshToken,
idToken: credentials.idToken,
accountId: workspaceID,
lastRefresh: credentials.lastRefresh),
env: env)
}
private func reconciledExistingAccount(
authenticatedEmail: String,
providerAccountID: String?,
existingAccountID: UUID?,
snapshot: ManagedCodexAccountSet)
-> ManagedCodexAccount?
{
if let providerAccountID,
let existingByProviderAccountID = snapshot.account(
email: authenticatedEmail,
providerAccountID: providerAccountID)
{
return existingByProviderAccountID
}
if let existingAccountID,
let existingByID = snapshot.account(id: existingAccountID),
existingByID.email == Self.normalizeEmail(authenticatedEmail),
providerAccountID == nil || existingByID.providerAccountID == nil
{
return existingByID
}
guard providerAccountID == nil else {
return nil
}
// Email-only reconciliation is a legacy/hardening fallback. Once an auth payload carries a
// provider account ID, matching must stay on that ID so same-email workspaces can coexist.
return snapshot.account(email: authenticatedEmail)
}
private func replacedAccountIDs(
authenticatedEmail: String,
providerAccountID: String?,
existingAccountID: UUID?,
matchedAccountID: UUID?,
snapshot: ManagedCodexAccountSet) -> Set<UUID>
{
var ids: Set<UUID> = []
let normalizedEmail = Self.normalizeEmail(authenticatedEmail)
if let matchedAccountID {
ids.insert(matchedAccountID)
}
if providerAccountID != nil {
let legacySameEmailIDs = snapshot.accounts
.filter {
$0.id != matchedAccountID &&
$0.providerAccountID == nil &&
$0.email == normalizedEmail
}
.map(\.id)
ids.formUnion(legacySameEmailIDs)
}
guard let existingAccountID,
existingAccountID != matchedAccountID,
let existingByID = snapshot.account(id: existingAccountID)
else {
return ids
}
if existingByID.providerAccountID == nil,
existingByID.email == normalizedEmail,
providerAccountID != nil
{
ids.insert(existingAccountID)
}
return ids
}
private static func normalizeEmail(_ email: String) -> String {
email.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
}
private func persistedProviderMetadata(
authenticatedProviderAccountID: String?,
resolvedWorkspaceIdentity: CodexOpenAIWorkspaceIdentity?,
existingAccount: ManagedCodexAccount?) -> (
providerAccountID: String?,
workspaceLabel: String?,
workspaceAccountID: String?)
{
if let authenticatedProviderAccountID {
let isExistingProviderMatch = existingAccount?.providerAccountID == authenticatedProviderAccountID
return (
providerAccountID: authenticatedProviderAccountID,
workspaceLabel: resolvedWorkspaceIdentity?.workspaceLabel
?? (isExistingProviderMatch ? existingAccount?.workspaceLabel : nil),
workspaceAccountID: resolvedWorkspaceIdentity?.workspaceAccountID ??
(isExistingProviderMatch ? existingAccount?.workspaceAccountID : nil) ??
authenticatedProviderAccountID)
}
guard let existingAccount, existingAccount.providerAccountID != nil else {
return (providerAccountID: nil, workspaceLabel: nil, workspaceAccountID: nil)
}
return (
providerAccountID: existingAccount.providerAccountID,
workspaceLabel: existingAccount.workspaceLabel,
workspaceAccountID: existingAccount.workspaceAccountID ?? existingAccount.providerAccountID)
}
}