Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion Sources/CodexBar/CodexLoginRunner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,14 @@ struct CodexLoginRunner {
let output: String
}

static func run(timeout: TimeInterval = 120) async -> Result {
static func run(homePath: String? = nil, timeout: TimeInterval = 120) async -> Result {
await Task(priority: .userInitiated) {
var env = ProcessInfo.processInfo.environment
env["PATH"] = PathBuilder.effectivePATH(
purposes: [.rpc, .tty, .nodeTooling],
env: env,
loginPATH: LoginShellPathCache.shared.current)
env = CodexHomeScope.scopedEnvironment(base: env, codexHome: homePath)

guard let executable = BinaryLocator.resolveCodexBinary(
env: env,
Expand Down
194 changes: 194 additions & 0 deletions Sources/CodexBar/ManagedCodexAccountService.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
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 loadAccountInfo(homePath: String) throws -> AccountInfo
}

enum ManagedCodexAccountServiceError: Error, Equatable, Sendable {
case loginFailed
case missingEmail
case unsafeManagedHome(String)
}

struct ManagedCodexHomeFactory: ManagedCodexHomeProducing, Sendable {
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 loadAccountInfo(homePath: String) throws -> AccountInfo {
let env = CodexHomeScope.scopedEnvironment(
base: ProcessInfo.processInfo.environment,
codexHome: homePath)
return UsageFetcher(environment: env).loadAccountInfo()
}
}

@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 fileManager: FileManager

init(
store: any ManagedCodexAccountStoring,
homeFactory: any ManagedCodexHomeProducing,
loginRunner: any ManagedCodexLoginRunning,
identityReader: any ManagedCodexIdentityReading,
fileManager: FileManager = .default)
{
self.store = store
self.homeFactory = homeFactory
self.loginRunner = loginRunner
self.identityReader = identityReader
self.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 existingHomePathToDelete: String?

do {
let result = await self.loginRunner.run(homePath: homeURL.path, timeout: timeout)
guard case .success = result.outcome else { throw ManagedCodexAccountServiceError.loginFailed }

let info = try self.identityReader.loadAccountInfo(homePath: homeURL.path)
guard let rawEmail = info.email?.trimmingCharacters(in: .whitespacesAndNewlines), !rawEmail.isEmpty else {
throw ManagedCodexAccountServiceError.missingEmail
}

let now = Date().timeIntervalSince1970
let existing = self.reconciledExistingAccount(
authenticatedEmail: rawEmail,
existingAccountID: existingAccountID,
snapshot: snapshot)

account = ManagedCodexAccount(
id: existing?.id ?? UUID(),
email: rawEmail,
managedHomePath: homeURL.path,
createdAt: existing?.createdAt ?? now,
updatedAt: now,
lastAuthenticatedAt: now)
existingHomePathToDelete = existing?.managedHomePath

let updatedSnapshot = ManagedCodexAccountSet(
version: snapshot.version,
accounts: snapshot.accounts.filter { $0.id != account.id && $0.email != account.email } + [account],
activeAccountID: account.id)
try self.store.storeAccounts(updatedSnapshot)
} catch {
try? self.removeManagedHomeIfSafe(atPath: homeURL.path)
throw error
}

if let existingHomePathToDelete, 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)
try self.homeFactory.validateManagedHomeForDeletion(homeURL)

let remaining = snapshot.accounts.filter { $0.id != id }
let nextActive = if snapshot.activeAccountID == id {
remaining.last?.id
} else {
snapshot.activeAccountID
}
try self.store.storeAccounts(ManagedCodexAccountSet(
version: snapshot.version,
accounts: remaining,
activeAccountID: nextActive))

if 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 reconciledExistingAccount(
authenticatedEmail: String,
existingAccountID: UUID?,
snapshot: ManagedCodexAccountSet)
-> ManagedCodexAccount?
{
if let existingByEmail = snapshot.account(email: authenticatedEmail) {
return existingByEmail
}
guard let existingAccountID else { return nil }
guard let existingByID = snapshot.account(id: existingAccountID) else { return nil }
return existingByID.email == Self.normalizeEmail(authenticatedEmail) ? existingByID : nil
}

private static func normalizeEmail(_ email: String) -> String {
email.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
}
}
13 changes: 8 additions & 5 deletions Sources/CodexBar/MenuDescriptor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -60,12 +60,13 @@ struct MenuDescriptor {
var sections: [Section] = []

if let provider {
let fallbackAccount = store.accountInfo(for: provider)
sections.append(Self.usageSection(for: provider, store: store, settings: settings))
if let accountSection = Self.accountSection(
for: provider,
store: store,
settings: settings,
account: account)
account: fallbackAccount)
{
sections.append(accountSection)
}
Expand All @@ -78,11 +79,12 @@ struct MenuDescriptor {
}
if addedUsage {
if let accountProvider = Self.accountProviderForCombined(store: store),
let fallbackAccount = Optional(store.accountInfo(for: accountProvider)),
let accountSection = Self.accountSection(
for: accountProvider,
store: store,
settings: settings,
account: account)
account: fallbackAccount)
{
sections.append(accountSection)
}
Expand Down Expand Up @@ -307,12 +309,13 @@ struct MenuDescriptor {
var entries: [Entry] = []
let targetProvider = provider ?? store.enabledProviders().first
let metadata = targetProvider.map { store.metadata(for: $0) }
let fallbackAccount = targetProvider.map { store.accountInfo(for: $0) } ?? account
let loginContext = targetProvider.map {
ProviderMenuLoginContext(
provider: $0,
store: store,
settings: store.settings,
account: account)
account: fallbackAccount)
}

// Show "Add Account" if no account, "Switch Account" if logged in
Expand All @@ -326,7 +329,7 @@ struct MenuDescriptor {
entries.append(.action(override.label, override.action))
} else {
let loginAction = self.switchAccountTarget(for: provider, store: store)
let hasAccount = self.hasAccount(for: provider, store: store, account: account)
let hasAccount = self.hasAccount(for: provider, store: store, account: fallbackAccount)
let accountLabel = hasAccount ? "Switch Account..." : "Add Account..."
entries.append(.action(accountLabel, loginAction))
}
Expand All @@ -337,7 +340,7 @@ struct MenuDescriptor {
provider: targetProvider,
store: store,
settings: store.settings,
account: account)
account: fallbackAccount)
ProviderCatalog.implementation(for: targetProvider)?
.appendActionMenuEntries(context: actionContext, entries: &entries)
}
Expand Down
2 changes: 1 addition & 1 deletion Sources/CodexBar/PreferencesProvidersPane.swift
Original file line number Diff line number Diff line change
Expand Up @@ -364,7 +364,7 @@ struct ProvidersPane: View {
dashboardError: dashboardError,
tokenSnapshot: tokenSnapshot,
tokenError: tokenError,
account: self.store.accountInfo(),
account: self.store.accountInfo(for: provider),
isRefreshing: self.store.refreshingProviders.contains(provider),
lastError: self.store.error(for: provider),
usageBarsShowUsed: self.settings.usageBarsShowUsed,
Expand Down
17 changes: 16 additions & 1 deletion Sources/CodexBar/ProviderRegistry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ struct ProviderRegistry {
provider: provider,
settings: settings,
tokenOverride: nil)
let fetcher = Self.makeFetcher(base: codexFetcher, provider: provider, env: env)
let verbose = settings.isVerboseLoggingEnabled
return ProviderFetchContext(
runtime: .app,
Expand All @@ -55,7 +56,7 @@ struct ProviderRegistry {
verbose: verbose,
env: env,
settings: snapshot,
fetcher: codexFetcher,
fetcher: fetcher,
claudeFetcher: claudeFetcher,
browserDetection: browserDetection)
})
Expand Down Expand Up @@ -107,6 +108,20 @@ struct ProviderRegistry {
env[key] = value
}
}
// Managed Codex selection only scopes remote account fetches such as identity, plan,
// quotas, and dashboard data. Token-cost/session history is intentionally not routed
// through the managed home because that data is currently treated as provider-level
// local telemetry from this Mac's Codex sessions, not as account-owned remote state.
// If we later want account-scoped token history in the UI, that needs an explicit
// product decision and presentation change so the two concepts are not conflated.
if provider == .codex, let managedHomePath = settings.activeManagedCodexRemoteHomePath {
env = CodexHomeScope.scopedEnvironment(base: env, codexHome: managedHomePath)
}
return env
}

static func makeFetcher(base: UsageFetcher, provider: UsageProvider, env: [String: String]) -> UsageFetcher {
guard provider == .codex else { return base }
return UsageFetcher(environment: env)
}
}
3 changes: 3 additions & 0 deletions Sources/CodexBar/Providers/Codex/CodexLoginFlow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ import CodexBarCore
@MainActor
extension StatusItemController {
func runCodexLoginFlow() async {
// This menu action still follows the ambient Codex login behavior. Managed-account authentication is
// implemented separately, but wiring add/switch/re-auth UI through that service needs its own account-aware
// flow so this entry point does not silently change what "Switch Account" means for existing users.
let result = await CodexLoginRunner.run(timeout: 120)
guard !Task.isCancelled else { return }
self.loginPhase = .idle
Expand Down
Loading