497 lines · 19.9 KB · Swift
RawDownload
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)    }}