498 lines
20 KiB
Swift
498 lines
20 KiB
Swift
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)
|
|
}
|
|
}
|