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 { var ids: Set = [] 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) } }