From 58ba4fa2d0bd05b05a70c0f199b0e0d287b46269 Mon Sep 17 00:00:00 2001 From: igneous-prose <252039396+igneous-prose@users.noreply.github.com> Date: Tue, 31 Mar 2026 23:58:09 +0100 Subject: [PATCH 1/2] Add optional Codex profile discovery --- .../PreferencesCodexAccountsSection.swift | 132 ++++++++++ .../PreferencesProvidersPane+Testing.swift | 8 + .../CodexBar/PreferencesProvidersPane.swift | 71 ++++- Sources/CodexBar/ProviderRegistry.swift | 6 + .../Providers/Codex/CodexSettingsStore.swift | 172 +++++++++++- .../Codex/UsageStore+CodexAccountState.swift | 13 +- Sources/CodexBar/SettingsStore+Defaults.swift | 12 + Sources/CodexBar/SettingsStore.swift | 3 + Sources/CodexBar/SettingsStoreState.swift | 1 + .../CodexBar/StatusItemController+Menu.swift | 87 +++++- .../StatusItemController+SwitcherViews.swift | 108 ++++++++ Sources/CodexBar/UsageStore+OpenAIWeb.swift | 60 ++++- .../CodexOAuth/CodexOAuthCredentials.swift | 19 +- .../CodexProfileExecutionEnvironment.swift | 75 ++++++ .../Codex/CodexProfileSelection.swift | 23 ++ .../Providers/Codex/CodexProfileStore.swift | 247 ++++++++++++++++++ .../Codex/CodexWebDashboardStrategy.swift | 11 +- .../Providers/ProviderSettingsSnapshot.swift | 5 +- Sources/CodexBarCore/UsageFetcher.swift | 30 ++- .../CodexProfileRoutingTests.swift | 162 ++++++++++++ .../CodexProfileStoreTests.swift | 166 ++++++++++++ Tests/CodexBarTests/SettingsStoreTests.swift | 33 ++- .../StatusMenuCodexSwitcherTests.swift | 87 ++++++ 23 files changed, 1492 insertions(+), 39 deletions(-) create mode 100644 Sources/CodexBarCore/Providers/Codex/CodexProfileExecutionEnvironment.swift create mode 100644 Sources/CodexBarCore/Providers/Codex/CodexProfileSelection.swift create mode 100644 Sources/CodexBarCore/Providers/Codex/CodexProfileStore.swift create mode 100644 Tests/CodexBarTests/CodexProfileRoutingTests.swift create mode 100644 Tests/CodexBarTests/CodexProfileStoreTests.swift diff --git a/Sources/CodexBar/PreferencesCodexAccountsSection.swift b/Sources/CodexBar/PreferencesCodexAccountsSection.swift index c1b620e07..785b36af6 100644 --- a/Sources/CodexBar/PreferencesCodexAccountsSection.swift +++ b/Sources/CodexBar/PreferencesCodexAccountsSection.swift @@ -21,6 +21,15 @@ struct CodexAccountsSectionNotice: Equatable { let tone: Tone } +struct CodexDiscoveredProfileState: Identifiable, Equatable { + let id: String + let title: String + let subtitle: String? + let detail: String? + let isDisplayed: Bool + let isLive: Bool +} + struct CodexAccountsSectionState: Equatable { let visibleAccounts: [CodexVisibleAccount] let activeVisibleAccountID: String? @@ -29,6 +38,8 @@ struct CodexAccountsSectionState: Equatable { let authenticatingManagedAccountID: UUID? let isAuthenticatingLiveAccount: Bool let notice: CodexAccountsSectionNotice? + let localProfiles: [CodexDiscoveredProfileState] + let hasUnavailableSelectedProfile: Bool var showsActivePicker: Bool { self.visibleAccounts.count > 1 @@ -84,6 +95,17 @@ struct CodexAccountsSectionState: Equatable { } return "Re-auth" } + + var showsLocalProfiles: Bool { + !self.localProfiles.isEmpty || self.hasUnavailableSelectedProfile + } + + var localProfilesNotice: CodexAccountsSectionNotice? { + guard self.hasUnavailableSelectedProfile else { return nil } + return CodexAccountsSectionNotice( + text: "The selected local Codex profile is unavailable. Pick another profile or reload profiles.", + tone: .warning) + } } @MainActor @@ -93,6 +115,9 @@ struct CodexAccountsSectionView: View { let reauthenticateAccount: (CodexVisibleAccount) -> Void let removeAccount: (CodexVisibleAccount) -> Void let addAccount: () -> Void + let selectLocalProfile: (String) -> Void + let reloadLocalProfiles: () -> Void + let openLocalProfilesFolder: () -> Void var body: some View { ProviderSettingsSection(title: "Accounts") { @@ -167,6 +192,56 @@ struct CodexAccountsSectionView: View { .buttonStyle(.bordered) .controlSize(.small) .disabled(self.state.canAddAccount == false) + + if self.state.showsLocalProfiles { + Divider() + + VStack(alignment: .leading, spacing: 10) { + Text("Local Profiles (Advanced)") + .font(.subheadline.weight(.semibold)) + + Text( + "Reuse existing local Codex profiles/auth files. Selecting one switches CodexBar back to the local live-system account.") + .font(.footnote) + .foregroundStyle(.secondary) + + if self.state.localProfiles.isEmpty { + Text("No saved local Codex profiles found in ~/.codex/profiles.") + .font(.footnote) + .foregroundStyle(.secondary) + } else { + VStack(alignment: .leading, spacing: 10) { + ForEach(self.state.localProfiles) { profile in + CodexLocalProfileRowView( + profile: profile, + onSelect: { self.selectLocalProfile(profile.id) }) + } + } + } + + if let notice = self.state.localProfilesNotice { + Text(notice.text) + .font(.footnote) + .foregroundStyle(notice.tone == .warning ? .red : .secondary) + .fixedSize(horizontal: false, vertical: true) + } + + HStack(spacing: 8) { + Button("Reload profiles") { + self.reloadLocalProfiles() + } + .buttonStyle(.bordered) + .controlSize(.small) + + Button("Open profiles folder") { + self.openLocalProfilesFolder() + } + .buttonStyle(.bordered) + .controlSize(.small) + } + } + .disabled(self.state.isAuthenticatingManagedAccount || self.state.isAuthenticatingLiveAccount) + } } } @@ -223,3 +298,60 @@ private struct CodexAccountsSectionRowView: View { } } } + +private struct CodexLocalProfileRowView: View { + let profile: CodexDiscoveredProfileState + let onSelect: () -> Void + + var body: some View { + HStack(alignment: .center, spacing: 12) { + VStack(alignment: .leading, spacing: 3) { + HStack(alignment: .firstTextBaseline, spacing: 6) { + Text(self.profile.title) + .font(.subheadline.weight(.semibold)) + if self.profile.isDisplayed { + CodexLocalProfileBadgeView(title: "Displayed", tone: .emphasized) + } + if self.profile.isLive { + CodexLocalProfileBadgeView(title: "Live", tone: .subtle) + } + } + if let subtitle = self.profile.subtitle, !subtitle.isEmpty { + Text(subtitle) + .font(.footnote) + .foregroundStyle(.secondary) + } + if let detail = self.profile.detail, !detail.isEmpty { + Text(detail) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + Spacer(minLength: 8) + + Button(self.profile.isDisplayed ? "Displayed" : "Display") { + self.onSelect() + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(self.profile.isDisplayed) + } + } +} + +private struct CodexLocalProfileBadgeView: View { + enum Tone { + case emphasized + case subtle + } + + let title: String + let tone: Tone + + var body: some View { + Text(self.title) + .font(.caption.weight(.semibold)) + .foregroundStyle(self.tone == .emphasized ? Color.accentColor : .secondary) + } +} diff --git a/Sources/CodexBar/PreferencesProvidersPane+Testing.swift b/Sources/CodexBar/PreferencesProvidersPane+Testing.swift index 437cb6a93..244d03577 100644 --- a/Sources/CodexBar/PreferencesProvidersPane+Testing.swift +++ b/Sources/CodexBar/PreferencesProvidersPane+Testing.swift @@ -42,6 +42,14 @@ extension ProvidersPane { func _test_reauthenticateCodexAccount(_ account: CodexVisibleAccount) async { await self.reauthenticateCodexAccount(account) } + + func _test_selectCodexLocalProfile(path: String) async { + await self.selectCodexLocalProfile(path: path) + } + + func _test_reloadCodexLocalProfiles() async { + await self.reloadCodexLocalProfiles() + } } @MainActor diff --git a/Sources/CodexBar/PreferencesProvidersPane.swift b/Sources/CodexBar/PreferencesProvidersPane.swift index 53943cf53..3f7ef7467 100644 --- a/Sources/CodexBar/PreferencesProvidersPane.swift +++ b/Sources/CodexBar/PreferencesProvidersPane.swift @@ -91,6 +91,19 @@ struct ProvidersPane: View { Task { @MainActor in await self.addManagedCodexAccount() } + }, + selectLocalProfile: { profilePath in + Task { @MainActor in + await self.selectCodexLocalProfile(path: profilePath) + } + }, + reloadLocalProfiles: { + Task { @MainActor in + await self.reloadCodexLocalProfiles() + } + }, + openLocalProfilesFolder: { + self.openCodexProfilesFolder() }) } }) @@ -181,6 +194,39 @@ struct ProvidersPane: View { func codexAccountsSectionState(for provider: UsageProvider) -> CodexAccountsSectionState? { guard provider == .codex else { return nil } let projection = self.settings.codexVisibleAccountProjection + let profiles = self.settings.codexProfiles() + let defaultAuthPath = CodexOAuthCredentialsStore.authFilePath().standardizedFileURL.path + let selectedProfilePath: String? = { + if let rawSelectedPath = self.settings.selectedCodexProfilePath?.trimmingCharacters(in: .whitespacesAndNewlines), + !rawSelectedPath.isEmpty + { + let standardized = URL(fileURLWithPath: rawSelectedPath).standardizedFileURL.path + if standardized == defaultAuthPath { + return profiles.first(where: \.isActiveInCodex)?.fileURL.standardizedFileURL.path + } + if profiles.contains(where: { $0.fileURL.standardizedFileURL.path == standardized }) { + return standardized + } + return nil + } + return self.settings.selectedCodexProfile()?.fileURL.standardizedFileURL.path + }() + let localProfiles = profiles.map { profile in + let cleanedPlan = profile.plan.flatMap { plan in + let cleaned = UsageFormatter.cleanPlanName(plan) + return cleaned.isEmpty ? plan : cleaned + } + let title = profile.fileURL.standardizedFileURL.path == defaultAuthPath && profile.alias == "Live" + ? "Live (unsaved)" + : profile.alias + return CodexDiscoveredProfileState( + id: profile.fileURL.standardizedFileURL.path, + title: title, + subtitle: PersonalInfoRedactor.redactEmail(profile.accountEmail, isEnabled: self.settings.hidePersonalInfo), + detail: cleanedPlan, + isDisplayed: selectedProfilePath == profile.fileURL.standardizedFileURL.path, + isLive: profile.isActiveInCodex) + } let degradedNotice: CodexAccountsSectionNotice? = if projection.hasUnreadableAddedAccountStore { CodexAccountsSectionNotice( text: "Managed account storage is unreadable. Live account access is still available, " @@ -197,7 +243,9 @@ struct ProvidersPane: View { isAuthenticatingManagedAccount: self.managedCodexAccountCoordinator.isAuthenticatingManagedAccount, authenticatingManagedAccountID: self.managedCodexAccountCoordinator.authenticatingManagedAccountID, isAuthenticatingLiveAccount: self.isAuthenticatingLiveCodexAccount, - notice: self.codexAccountsNotice ?? degradedNotice) + notice: self.codexAccountsNotice ?? degradedNotice, + localProfiles: localProfiles, + hasUnavailableSelectedProfile: self.settings.hasUnavailableSelectedCodexProfile) } func selectCodexVisibleAccount(id: String) async { @@ -263,6 +311,27 @@ struct ProvidersPane: View { } } + func selectCodexLocalProfile(path: String) async { + self.codexAccountsNotice = nil + self.settings.codexActiveSource = .liveSystem + self.settings.selectCodexProfile(path: path) + await self.refreshCodexProvider() + } + + func reloadCodexLocalProfiles() async { + self.codexAccountsNotice = nil + self.settings.reloadCodexProfiles() + await self.refreshCodexProvider() + } + + func openCodexProfilesFolder() { + let profilesURL = CodexOAuthCredentialsStore.authFilePath() + .deletingLastPathComponent() + .appendingPathComponent("profiles", isDirectory: true) + let fallbackURL = profilesURL.deletingLastPathComponent() + NSWorkspace.shared.open(FileManager.default.fileExists(atPath: profilesURL.path) ? profilesURL : fallbackURL) + } + func requestManagedCodexAccountRemoval(_ account: CodexVisibleAccount) { guard let accountID = account.storedAccountID else { return } self.activeConfirmation = ProviderSettingsConfirmationState( diff --git a/Sources/CodexBar/ProviderRegistry.swift b/Sources/CodexBar/ProviderRegistry.swift index e3934d288..e44724845 100644 --- a/Sources/CodexBar/ProviderRegistry.swift +++ b/Sources/CodexBar/ProviderRegistry.swift @@ -108,6 +108,12 @@ struct ProviderRegistry { env[key] = value } } + if provider == .codex { + let profileOverride = settings.codexEnvironmentOverrides(tokenOverride: tokenOverride) + for (key, value) in profileOverride { + env[key] = value + } + } // Managed Codex routing only scopes remote account fetches such as identity, plan, // quotas, and dashboard data, and only when the active source is a managed account. // Token-cost/session history is intentionally not routed through the managed home diff --git a/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift b/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift index fe099b1a5..08e6cd409 100644 --- a/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift +++ b/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift @@ -2,6 +2,47 @@ import CodexBarCore import Foundation extension SettingsStore { + private var standardizedSelectedCodexProfilePath: String? { + guard let selectedPath = self.selectedCodexProfilePath?.trimmingCharacters(in: .whitespacesAndNewlines), + !selectedPath.isEmpty + else { + return nil + } + return URL(fileURLWithPath: selectedPath).standardizedFileURL.path + } + + func codexProfiles() -> [DiscoveredCodexProfile] { + #if DEBUG + if let override = self._test_codexProfiles { + return override + } + #endif + _ = self.codexProfilesRevision + return CodexProfileStore.displayProfiles() + } + + func selectedCodexProfile() -> DiscoveredCodexProfile? { + #if DEBUG + if let override = self._test_codexProfiles { + if let standardizedPath = self.standardizedSelectedCodexProfilePath { + if let exact = override.first(where: { $0.fileURL.standardizedFileURL.path == standardizedPath }) { + return exact + } + } + return override.first(where: \.isActiveInCodex) ?? override.first + } + #endif + return CodexProfileStore.selectedDisplayProfile(selectedPath: self.selectedCodexProfilePath) + } + + func selectCodexProfile(path: String?) { + self.selectedCodexProfilePath = path + } + + func reloadCodexProfiles() { + self.codexProfilesRevision &+= 1 + } + private var codexPersistedActiveSource: CodexActiveSource { self.providerConfig(for: .codex)?.codexActiveSource ?? .liveSystem } @@ -127,6 +168,18 @@ extension SettingsStore { } } + var hasUnavailableSelectedCodexProfile: Bool { + guard case .liveSystem = self.codexResolvedActiveSource else { + return false + } + guard let standardizedSelectedPath = self.standardizedSelectedCodexProfilePath else { + return false + } + let defaultPath = CodexOAuthCredentialsStore.authFilePath().standardizedFileURL.path + guard standardizedSelectedPath != defaultPath else { return false } + return self.codexProfiles().contains(where: { $0.fileURL.standardizedFileURL.path == standardizedSelectedPath }) == false + } + var codexUsageDataSource: CodexUsageDataSource { get { let source = self.configSnapshot.providerConfig(for: .codex)?.source @@ -442,6 +495,27 @@ private enum CodexManagedRemoteHomeTestingOverrideError: Error { case unreadableManagedStore } +#if DEBUG +private enum CodexProfileTestingOverride { + @MainActor private static var values: [ObjectIdentifier: [DiscoveredCodexProfile]] = [:] + + @MainActor + static func profiles(for settings: SettingsStore) -> [DiscoveredCodexProfile]? { + self.values[ObjectIdentifier(settings)] + } + + @MainActor + static func setProfiles(_ profiles: [DiscoveredCodexProfile]?, for settings: SettingsStore) { + let key = ObjectIdentifier(settings) + if let profiles { + self.values[key] = profiles + } else { + self.values.removeValue(forKey: key) + } + } +} +#endif + private struct CodexManagedRemoteHomeTestingSystemObserver: CodexSystemAccountObserving { let overrideAccount: ObservedSystemCodexAccount? let usesInjectedEnvironment: Bool @@ -458,6 +532,11 @@ private struct CodexManagedRemoteHomeTestingSystemObserver: CodexSystemAccountOb } extension SettingsStore { + var _test_codexProfiles: [DiscoveredCodexProfile]? { + get { CodexProfileTestingOverride.profiles(for: self) } + set { CodexProfileTestingOverride.setProfiles(newValue, for: self) } + } + var _test_activeManagedCodexRemoteHomePath: String? { get { CodexManagedRemoteHomeTestingOverride.homePath(for: self) } set { CodexManagedRemoteHomeTestingOverride.setHomePath(newValue, for: self) } @@ -497,7 +576,8 @@ extension SettingsStore { cookieSource: self.codexSnapshotCookieSource(tokenOverride: tokenOverride), manualCookieHeader: self.codexSnapshotCookieHeader(tokenOverride: tokenOverride), managedAccountStoreUnreadable: self.hasUnreadableSelectedManagedCodexAccountStore, - managedAccountTargetUnavailable: self.hasUnavailableSelectedManagedCodexAccount) + managedAccountTargetUnavailable: self.hasUnavailableSelectedManagedCodexAccount, + selectedProfileUnavailable: self.hasUnavailableSelectedCodexProfile) } private static func codexUsageDataSource(from source: ProviderSourceMode?) -> CodexUsageDataSource { @@ -539,4 +619,94 @@ extension SettingsStore { if self.tokenAccounts(for: .codex).isEmpty { return fallback } return .manual } + + func codexEnvironmentOverrides(tokenOverride: TokenAccountOverride?) -> [String: String] { + if let tokenOverride, tokenOverride.provider == .codex { + let overrideURL = URL(fileURLWithPath: tokenOverride.account.token).standardizedFileURL + return [CodexProfileExecutionEnvironment.authFileOverrideKey: overrideURL.path] + } + guard case .liveSystem = self.codexResolvedActiveSource else { return [:] } + guard let selectedPath = self.standardizedSelectedCodexProfilePath else { return [:] } + let defaultPath = CodexOAuthCredentialsStore.authFilePath().standardizedFileURL.path + guard selectedPath != defaultPath else { return [:] } + return [CodexProfileExecutionEnvironment.authFileOverrideKey: selectedPath] + } + + func selectedCodexProfileEmail() -> String? { + switch self.codexResolvedActiveSource { + case .managedAccount: + return self.activeManagedCodexAccount?.email + case .liveSystem: + if let standardizedSelectedPath = self.standardizedSelectedCodexProfilePath { + if let overrideProfile = self.codexProfiles().first(where: { + $0.fileURL.standardizedFileURL.path == standardizedSelectedPath + }) { + return overrideProfile.accountEmail + } + let overrideURL = URL(fileURLWithPath: standardizedSelectedPath) + return CodexProfileStore.profile( + at: overrideURL, + alias: overrideURL.deletingPathExtension().lastPathComponent)?.accountEmail + } + return self.selectedCodexProfile()?.accountEmail + } + } + + func selectedCodexProfilePlan() -> String? { + guard case .liveSystem = self.codexResolvedActiveSource else { return nil } + guard let standardizedSelectedPath = self.standardizedSelectedCodexProfilePath else { + return self.selectedCodexProfile()?.plan + } + if let overrideProfile = self.codexProfiles().first(where: { + $0.fileURL.standardizedFileURL.path == standardizedSelectedPath + }) { + return overrideProfile.plan + } + let overrideURL = URL(fileURLWithPath: standardizedSelectedPath) + return CodexProfileStore.profile( + at: overrideURL, + alias: overrideURL.deletingPathExtension().lastPathComponent)?.plan + } + + func selectedCodexProfileAlias() -> String? { + guard case .liveSystem = self.codexResolvedActiveSource else { return nil } + guard let standardizedSelectedPath = self.standardizedSelectedCodexProfilePath else { + return self.selectedCodexProfile()?.alias + } + if let overrideProfile = self.codexProfiles().first(where: { + $0.fileURL.standardizedFileURL.path == standardizedSelectedPath + }) { + return overrideProfile.alias + } + let overrideURL = URL(fileURLWithPath: standardizedSelectedPath) + return CodexProfileStore.profile( + at: overrideURL, + alias: overrideURL.deletingPathExtension().lastPathComponent)?.alias + } + + func codexDiscoveredProfileSelection(for tokenOverride: TokenAccountOverride?) -> CodexProfileSelection? { + if let tokenOverride, tokenOverride.provider == .codex { + let overrideURL = URL(fileURLWithPath: tokenOverride.account.token) + if let overrideProfile = self.codexProfiles().first(where: { + $0.fileURL.standardizedFileURL == overrideURL.standardizedFileURL + }) { + return overrideProfile.selection + } + return CodexProfileStore.profile( + at: overrideURL, + alias: overrideURL.deletingPathExtension().lastPathComponent)?.selection + } + guard let standardizedSelectedPath = self.standardizedSelectedCodexProfilePath else { + return self.selectedCodexProfile()?.selection + } + if let overrideProfile = self.codexProfiles().first(where: { + $0.fileURL.standardizedFileURL.path == standardizedSelectedPath + }) { + return overrideProfile.selection + } + let overrideURL = URL(fileURLWithPath: standardizedSelectedPath) + return CodexProfileStore.profile( + at: overrideURL, + alias: overrideURL.deletingPathExtension().lastPathComponent)?.selection + } } diff --git a/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift b/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift index 35319de48..741037018 100644 --- a/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift +++ b/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift @@ -126,9 +126,7 @@ extension UsageStore { let source = self.settings.codexResolvedActiveSource let accountKey: String? = switch self.settings.codexResolvedActiveSource { case .liveSystem: - Self - .normalizeCodexAccountScopedKey(self.settings.codexAccountReconciliationSnapshot.liveSystemAccount? - .email) + Self.normalizeCodexAccountScopedKey(self.settings.selectedCodexProfileEmail()) case .managedAccount: Self.normalizeCodexAccountScopedKey(self.currentManagedCodexRuntimeEmail()) } @@ -304,11 +302,10 @@ extension UsageStore { { switch self.settings.codexResolvedActiveSource { case .liveSystem: - let liveSystem = Self.normalizeCodexAccountScopedEmail( - self.settings.codexAccountReconciliationSnapshot.liveSystemAccount?.email) - if let liveSystem { - self.lastKnownLiveSystemCodexEmail = liveSystem - return liveSystem + let selectedProfile = Self.normalizeCodexAccountScopedEmail(self.settings.selectedCodexProfileEmail()) + if let selectedProfile { + self.lastKnownLiveSystemCodexEmail = selectedProfile + return selectedProfile } if preferCurrentSnapshot, diff --git a/Sources/CodexBar/SettingsStore+Defaults.swift b/Sources/CodexBar/SettingsStore+Defaults.swift index ced4e5184..6d8a8a030 100644 --- a/Sources/CodexBar/SettingsStore+Defaults.swift +++ b/Sources/CodexBar/SettingsStore+Defaults.swift @@ -318,6 +318,18 @@ extension SettingsStore { } } + var selectedCodexProfilePath: String? { + get { self.defaultsState.selectedCodexProfilePath } + set { + self.defaultsState.selectedCodexProfilePath = newValue + if let value = newValue { + self.userDefaults.set(value, forKey: "selectedCodexProfilePath") + } else { + self.userDefaults.removeObject(forKey: "selectedCodexProfilePath") + } + } + } + var selectedMenuProvider: UsageProvider? { get { self.selectedMenuProviderRaw.flatMap(UsageProvider.init(rawValue:)) } set { diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index c3a59ba7a..5fb3595ac 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -81,6 +81,7 @@ final class SettingsStore { @ObservationIgnored var tokenAccountsLoaded = false var defaultsState: SettingsDefaultsState var configRevision: Int = 0 + var codexProfilesRevision: Int = 0 var providerOrder: [UsageProvider] = [] var providerEnablement: [UsageProvider: Bool] = [:] @@ -240,6 +241,7 @@ extension SettingsStore { let mergedOverviewSelectedProvidersRaw = userDefaults.array( forKey: "mergedOverviewSelectedProviders") as? [String] ?? [] let selectedMenuProviderRaw = userDefaults.string(forKey: "selectedMenuProvider") + let selectedCodexProfilePath = userDefaults.string(forKey: "selectedCodexProfilePath") let providerDetectionCompleted = userDefaults.object(forKey: "providerDetectionCompleted") as? Bool ?? false return SettingsDefaultsState( @@ -275,6 +277,7 @@ extension SettingsStore { mergedMenuLastSelectedWasOverview: mergedMenuLastSelectedWasOverview, mergedOverviewSelectedProvidersRaw: mergedOverviewSelectedProvidersRaw, selectedMenuProviderRaw: selectedMenuProviderRaw, + selectedCodexProfilePath: selectedCodexProfilePath, providerDetectionCompleted: providerDetectionCompleted) } } diff --git a/Sources/CodexBar/SettingsStoreState.swift b/Sources/CodexBar/SettingsStoreState.swift index 98e01406d..822311360 100644 --- a/Sources/CodexBar/SettingsStoreState.swift +++ b/Sources/CodexBar/SettingsStoreState.swift @@ -33,5 +33,6 @@ struct SettingsDefaultsState { var mergedMenuLastSelectedWasOverview: Bool var mergedOverviewSelectedProvidersRaw: [String] var selectedMenuProviderRaw: String? + var selectedCodexProfilePath: String? var providerDetectionCompleted: Bool } diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 3e353c2cc..131a7d216 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -68,6 +68,11 @@ extension StatusItemController { let activeVisibleAccountID: String? } + private struct CodexProfileMenuDisplay { + let profiles: [DiscoveredCodexProfile] + let selectedProfilePath: String? + } + private func menuCardWidth(for providers: [UsageProvider], menu: NSMenu? = nil) -> CGFloat { _ = menu return Self.menuCardBaseWidth @@ -168,6 +173,7 @@ extension StatusItemController { let menuWidth = self.menuCardWidth(for: enabledProviders, menu: menu) let currentProvider = selectedProvider ?? enabledProviders.first ?? .codex let codexAccountDisplay = isOverviewSelected ? nil : self.codexAccountMenuDisplay(for: currentProvider) + let codexProfileDisplay = isOverviewSelected ? nil : self.codexProfileMenuDisplay(for: currentProvider) let tokenAccountDisplay = isOverviewSelected ? nil : self.tokenAccountMenuDisplay(for: currentProvider) let showAllTokenAccounts = tokenAccountDisplay?.showAll ?? false let openAIContext = self.openAIWebContext( @@ -175,7 +181,9 @@ extension StatusItemController { showAllTokenAccounts: showAllTokenAccounts) let hasAuxiliarySwitcher = menu.items.contains { - $0.view is TokenAccountSwitcherView || $0.view is CodexAccountSwitcherView + $0.view is TokenAccountSwitcherView || + $0.view is CodexAccountSwitcherView || + $0.view is CodexProfileSwitcherView } let switcherProvidersMatch = enabledProviders == self.lastSwitcherProviders let switcherUsageBarsShowUsedMatch = self.settings.usageBarsShowUsed == self.lastSwitcherUsageBarsShowUsed @@ -189,6 +197,7 @@ extension StatusItemController { switcherSelectionMatches && switcherOverviewAvailabilityMatches && codexAccountDisplay == nil && + codexProfileDisplay == nil && tokenAccountDisplay == nil && !hasAuxiliarySwitcher && !menu.items.isEmpty && @@ -227,6 +236,7 @@ extension StatusItemController { self.lastSwitcherIncludesOverview = includesOverview } self.addCodexAccountSwitcherIfNeeded(to: menu, display: codexAccountDisplay) + self.addCodexProfileSwitcherIfNeeded(to: menu, display: codexProfileDisplay) self.addTokenAccountSwitcherIfNeeded(to: menu, display: tokenAccountDisplay) let menuContext = MenuCardContext( currentProvider: currentProvider, @@ -376,6 +386,13 @@ extension StatusItemController { menu.addItem(.separator()) } + private func addCodexProfileSwitcherIfNeeded(to menu: NSMenu, display: CodexProfileMenuDisplay?) { + guard let display else { return } + let switcherItem = self.makeCodexProfileSwitcherItem(display: display, menu: menu) + menu.addItem(switcherItem) + menu.addItem(.separator()) + } + @discardableResult private func addOverviewRows( to menu: NSMenu, @@ -707,6 +724,24 @@ extension StatusItemController { return item } + private func makeCodexProfileSwitcherItem( + display: CodexProfileMenuDisplay, + menu: NSMenu) -> NSMenuItem + { + let view = CodexProfileSwitcherView( + profiles: display.profiles, + selectedProfilePath: display.selectedProfilePath, + width: self.menuCardWidth(for: self.store.enabledProvidersForDisplay(), menu: menu), + onSelect: { [weak self, weak menu] profilePath in + guard let self else { return } + self.handleCodexProfileSelection(profilePath, menu: menu) + }) + let item = NSMenuItem() + item.view = view + item.isEnabled = false + return item + } + @discardableResult private func handleCodexVisibleAccountSelection(_ visibleAccountID: String, menu: NSMenu?) -> Bool { guard self.settings.selectCodexVisibleAccount(id: visibleAccountID) else { return false } @@ -782,6 +817,56 @@ extension StatusItemController { activeVisibleAccountID: projection.activeVisibleAccountID) } + private func codexProfileMenuDisplay(for provider: UsageProvider) -> CodexProfileMenuDisplay? { + guard provider == .codex else { return nil } + guard case .liveSystem = self.settings.codexResolvedActiveSource else { return nil } + let profiles = self.settings.codexProfiles() + guard profiles.count > 1 else { return nil } + let defaultAuthPath = CodexOAuthCredentialsStore.authFilePath().standardizedFileURL.path + let selectedPath: String? = { + if let rawSelectedPath = self.settings.selectedCodexProfilePath?.trimmingCharacters(in: .whitespacesAndNewlines), + !rawSelectedPath.isEmpty + { + let standardized = URL(fileURLWithPath: rawSelectedPath).standardizedFileURL.path + if standardized == defaultAuthPath { + return profiles.first(where: \.isActiveInCodex)?.fileURL.standardizedFileURL.path + } + if profiles.contains(where: { $0.fileURL.standardizedFileURL.path == standardized }) { + return standardized + } + return nil + } + return self.settings.selectedCodexProfile()?.fileURL.standardizedFileURL.path + }() + return CodexProfileMenuDisplay( + profiles: profiles, + selectedProfilePath: selectedPath) + } + + @discardableResult + private func handleCodexProfileSelection(_ profilePath: String, menu: NSMenu?) -> Bool { + self.settings.codexActiveSource = .liveSystem + self.settings.selectCodexProfile(path: profilePath) + if self.store.prepareCodexAccountScopedRefreshIfNeeded(), let menu { + self.refreshOpenMenuIfStillVisible(menu, provider: .codex) + } + Task { @MainActor in + await ProviderInteractionContext.$current.withValue(.userInitiated) { + await self.store.refreshCodexAccountScopedState( + allowDisabled: true, + phaseDidChange: { [weak self, weak menu] _ in + guard let self, let menu else { return } + guard self.settings.selectedCodexProfile()?.fileURL.standardizedFileURL.path == profilePath + else { + return + } + self.refreshOpenMenuIfStillVisible(menu, provider: .codex) + }) + } + } + return true + } + private func menuNeedsRefresh(_ menu: NSMenu) -> Bool { let key = ObjectIdentifier(menu) return self.menuVersions[key] != self.menuContentVersion diff --git a/Sources/CodexBar/StatusItemController+SwitcherViews.swift b/Sources/CodexBar/StatusItemController+SwitcherViews.swift index 4c56de1ab..43a693d6a 100644 --- a/Sources/CodexBar/StatusItemController+SwitcherViews.swift +++ b/Sources/CodexBar/StatusItemController+SwitcherViews.swift @@ -1002,3 +1002,111 @@ final class CodexAccountSwitcherView: NSView { self.onSelect(accountID) } } + +final class CodexProfileSwitcherView: NSView { + private let profiles: [DiscoveredCodexProfile] + private let onSelect: (String) -> Void + private var selectedProfilePath: String + private var buttons: [NSButton] = [] + private let rowSpacing: CGFloat = 4 + private let rowHeight: CGFloat = 26 + private let selectedBackground = NSColor.controlAccentColor.cgColor + private let unselectedBackground = NSColor.clear.cgColor + private let selectedTextColor = NSColor.white + private let unselectedTextColor = NSColor.secondaryLabelColor + + init( + profiles: [DiscoveredCodexProfile], + selectedProfilePath: String?, + width: CGFloat, + onSelect: @escaping (String) -> Void) + { + self.profiles = profiles + self.onSelect = onSelect + self.selectedProfilePath = selectedProfilePath ?? profiles.first?.fileURL.standardizedFileURL.path ?? "" + let useTwoRows = profiles.count > 3 + let rows = useTwoRows ? 2 : 1 + let height = self.rowHeight * CGFloat(rows) + (useTwoRows ? self.rowSpacing : 0) + super.init(frame: NSRect(x: 0, y: 0, width: width, height: height)) + self.wantsLayer = true + self.buildButtons(useTwoRows: useTwoRows) + self.updateButtonStyles() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + nil + } + + private func buildButtons(useTwoRows: Bool) { + let perRow = useTwoRows ? Int(ceil(Double(self.profiles.count) / 2.0)) : self.profiles.count + let rows: [[DiscoveredCodexProfile]] = { + if !useTwoRows { return [self.profiles] } + let first = Array(self.profiles.prefix(perRow)) + let second = Array(self.profiles.dropFirst(perRow)) + return [first, second] + }() + + let stack = NSStackView() + stack.orientation = .vertical + stack.alignment = .centerX + stack.spacing = self.rowSpacing + stack.translatesAutoresizingMaskIntoConstraints = false + + for rowProfiles in rows { + let row = NSStackView() + row.orientation = .horizontal + row.alignment = .centerY + row.distribution = .fillEqually + row.spacing = self.rowSpacing + row.translatesAutoresizingMaskIntoConstraints = false + + for profile in rowProfiles { + let button = PaddedToggleButton( + title: profile.alias, + target: self, + action: #selector(self.handleSelect)) + let path = profile.fileURL.standardizedFileURL.path + button.identifier = NSUserInterfaceItemIdentifier(path) + button.toolTip = path + button.isBordered = false + button.setButtonType(.toggle) + button.controlSize = .small + button.font = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize) + button.wantsLayer = true + button.layer?.cornerRadius = 6 + row.addArrangedSubview(button) + self.buttons.append(button) + } + + stack.addArrangedSubview(row) + } + + self.addSubview(stack) + NSLayoutConstraint.activate([ + stack.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 6), + stack.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -6), + stack.topAnchor.constraint(equalTo: self.topAnchor), + stack.bottomAnchor.constraint(equalTo: self.bottomAnchor), + stack.heightAnchor.constraint(equalToConstant: self.rowHeight * CGFloat(rows.count) + + (useTwoRows ? self.rowSpacing : 0)), + ]) + } + + private func updateButtonStyles() { + for button in self.buttons { + let selected = button.identifier?.rawValue == self.selectedProfilePath + button.state = selected ? .on : .off + button.layer?.backgroundColor = selected ? self.selectedBackground : self.unselectedBackground + button.contentTintColor = selected ? self.selectedTextColor : self.unselectedTextColor + } + } + + @objc private func handleSelect(_ sender: NSButton) { + guard let profilePath = sender.identifier?.rawValue else { return } + guard self.profiles.contains(where: { $0.fileURL.standardizedFileURL.path == profilePath }) else { return } + self.selectedProfilePath = profilePath + self.updateButtonStyles() + self.onSelect(profilePath) + } +} diff --git a/Sources/CodexBar/UsageStore+OpenAIWeb.swift b/Sources/CodexBar/UsageStore+OpenAIWeb.swift index 6a2e9145b..659714d73 100644 --- a/Sources/CodexBar/UsageStore+OpenAIWeb.swift +++ b/Sources/CodexBar/UsageStore+OpenAIWeb.swift @@ -85,6 +85,10 @@ extension UsageStore { await self.failClosedRefreshForMissingManagedCodexTarget() return } + if self.openAIWebSelectedProfileIsUnavailable() { + await self.failClosedRefreshForUnavailableSelectedCodexProfile() + return + } await MainActor.run { if let cached = self.lastOpenAIDashboardSnapshot { @@ -120,6 +124,10 @@ extension UsageStore { await self.failClosedRefreshForMissingManagedCodexTarget() return } + if self.openAIWebSelectedProfileIsUnavailable() { + await self.failClosedRefreshForUnavailableSelectedCodexProfile() + return + } await MainActor.run { self.lastOpenAIDashboardError = [ @@ -305,6 +313,10 @@ extension UsageStore { await self.failClosedRefreshForMissingManagedCodexTarget() return } + if self.openAIWebSelectedProfileIsUnavailable() { + await self.failClosedRefreshForUnavailableSelectedCodexProfile() + return + } let allowCurrentSnapshotFallback = expectedGuard?.source == .liveSystem && expectedGuard? .identity == .unresolved @@ -614,11 +626,11 @@ extension UsageStore { { switch self.settings.codexResolvedActiveSource { case .liveSystem: - let liveSystem = self.settings.codexAccountReconciliationSnapshot.liveSystemAccount?.email + let selectedProfile = self.settings.selectedCodexProfileEmail()? .trimmingCharacters(in: .whitespacesAndNewlines) - if let liveSystem, !liveSystem.isEmpty { - self.lastKnownLiveSystemCodexEmail = liveSystem - return liveSystem + if let selectedProfile, !selectedProfile.isEmpty { + self.lastKnownLiveSystemCodexEmail = selectedProfile + return selectedProfile } if allowCurrentSnapshotFallback, @@ -784,6 +796,27 @@ extension UsageStore { ].joined(separator: " ") } + private func failClosedForUnavailableSelectedCodexProfile() async -> String? { + await MainActor.run { + self.failClosedOpenAIDashboardSnapshot() + self.openAIDashboardCookieImportStatus = [ + "The selected local Codex profile is unavailable.", + "Pick another profile or reload profiles before importing OpenAI cookies.", + ].joined(separator: " ") + } + return nil + } + + private func failClosedRefreshForUnavailableSelectedCodexProfile() async { + await MainActor.run { + self.failClosedOpenAIDashboardSnapshot() + self.lastOpenAIDashboardError = [ + "The selected local Codex profile is unavailable.", + "Pick another profile or reload profiles before refreshing OpenAI web data.", + ].joined(separator: " ") + } + } + private func openAIWebCookieImportShouldFailClosed() async -> Bool { if self.openAIWebManagedTargetStoreIsUnreadable() { _ = await self.failClosedForUnreadableManagedCodexStore() @@ -793,6 +826,10 @@ extension UsageStore { _ = await self.failClosedForMissingManagedCodexTarget() return true } + if self.openAIWebSelectedProfileIsUnavailable() { + _ = await self.failClosedForUnavailableSelectedCodexProfile() + return true + } return false } @@ -1007,6 +1044,13 @@ extension UsageStore { return self.selectedManagedCodexAccountForOpenAIWeb() == nil } + private func openAIWebSelectedProfileIsUnavailable() -> Bool { + guard case .liveSystem = self.settings.codexResolvedActiveSource else { + return false + } + return self.settings.codexSettingsSnapshot(tokenOverride: nil).selectedProfileUnavailable + } + private func selectedManagedCodexAccountForOpenAIWeb() -> ManagedCodexAccount? { guard case let .managedAccount(id) = self.settings.codexResolvedActiveSource else { return nil @@ -1019,11 +1063,11 @@ extension UsageStore { func codexAccountEmailForOpenAIDashboard(allowLastKnownLiveFallback: Bool = true) -> String? { switch self.settings.codexResolvedActiveSource { case .liveSystem: - let liveSystem = self.settings.codexAccountReconciliationSnapshot.liveSystemAccount?.email + let selectedProfile = self.settings.selectedCodexProfileEmail()? .trimmingCharacters(in: .whitespacesAndNewlines) - if let liveSystem, !liveSystem.isEmpty { - self.lastKnownLiveSystemCodexEmail = liveSystem - return liveSystem + if let selectedProfile, !selectedProfile.isEmpty { + self.lastKnownLiveSystemCodexEmail = selectedProfile + return selectedProfile } guard allowLastKnownLiveFallback else { return nil } diff --git a/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthCredentials.swift b/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthCredentials.swift index c6c5698b2..8560aa403 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthCredentials.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthCredentials.swift @@ -46,11 +46,17 @@ public enum CodexOAuthCredentialsError: LocalizedError, Sendable { } public enum CodexOAuthCredentialsStore { - private static func authFilePath( + public static func authFilePath( env: [String: String] = ProcessInfo.processInfo.environment, fileManager: FileManager = .default) -> URL { - CodexHomeScope + if let override = env[CodexProfileExecutionEnvironment.authFileOverrideKey]? + .trimmingCharacters(in: .whitespacesAndNewlines), + !override.isEmpty + { + return URL(fileURLWithPath: override) + } + return CodexHomeScope .ambientHomeURL(env: env, fileManager: fileManager) .appendingPathComponent("auth.json") } @@ -59,6 +65,10 @@ public enum CodexOAuthCredentialsStore { .environment) throws -> CodexOAuthCredentials { let url = self.authFilePath(env: env) + return try self.load(from: url) + } + + public static func load(from url: URL) throws -> CodexOAuthCredentials { guard FileManager.default.fileExists(atPath: url.path) else { throw CodexOAuthCredentialsError.notFound } @@ -112,7 +122,10 @@ public enum CodexOAuthCredentialsStore { _ credentials: CodexOAuthCredentials, env: [String: String] = ProcessInfo.processInfo.environment) throws { - let url = self.authFilePath(env: env) + try self.save(credentials, to: self.authFilePath(env: env)) + } + + public static func save(_ credentials: CodexOAuthCredentials, to url: URL) throws { var json: [String: Any] = [:] if let data = try? Data(contentsOf: url), diff --git a/Sources/CodexBarCore/Providers/Codex/CodexProfileExecutionEnvironment.swift b/Sources/CodexBarCore/Providers/Codex/CodexProfileExecutionEnvironment.swift new file mode 100644 index 000000000..2fddde300 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Codex/CodexProfileExecutionEnvironment.swift @@ -0,0 +1,75 @@ +import Foundation + +public enum CodexProfileExecutionEnvironment { + public static let authFileOverrideKey = "CODEXBAR_AUTH_FILE_OVERRIDE" + + struct ResolvedEnvironment { + let environment: [String: String] + let cleanup: @Sendable () -> Void + } + + enum Error: LocalizedError { + case invalidProfilePath + + var errorDescription: String? { + switch self { + case .invalidProfilePath: + "Selected Codex profile is missing or not a regular file." + } + } + } + + static func resolvedEnvironment( + from base: [String: String], + fileManager: FileManager = .default) throws -> ResolvedEnvironment + { + guard let overridePath = base[authFileOverrideKey]?.trimmingCharacters(in: .whitespacesAndNewlines), + !overridePath.isEmpty + else { + return ResolvedEnvironment(environment: base, cleanup: {}) + } + + let sourceURL = URL(fileURLWithPath: overridePath) + guard self.isSafeRegularFile(sourceURL) else { + throw Error.invalidProfilePath + } + + let tempDirectory = fileManager.temporaryDirectory + .appendingPathComponent("codexbar-codex-home-\(UUID().uuidString)", isDirectory: true) + try fileManager.createDirectory(at: tempDirectory, withIntermediateDirectories: true) + try self.setPermissions(0o700, for: tempDirectory, fileManager: fileManager) + + let destinationURL = tempDirectory.appendingPathComponent("auth.json") + try fileManager.copyItem(at: sourceURL, to: destinationURL) + try self.setPermissions(0o600, for: destinationURL, fileManager: fileManager) + + var environment = base + environment["CODEX_HOME"] = tempDirectory.path + environment.removeValue(forKey: Self.authFileOverrideKey) + + let tempPath = tempDirectory.path + return ResolvedEnvironment( + environment: environment, + cleanup: { + try? FileManager.default.removeItem(atPath: tempPath) + }) + } + + private static func isSafeRegularFile(_ url: URL) -> Bool { + guard let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey]), + values.isRegularFile == true, + values.isSymbolicLink != true + else { + return false + } + return FileManager.default.fileExists(atPath: url.path) + } + + private static func setPermissions(_ permissions: Int16, for url: URL, fileManager: FileManager) throws { + #if os(macOS) + try fileManager.setAttributes( + [.posixPermissions: NSNumber(value: permissions)], + ofItemAtPath: url.path) + #endif + } +} diff --git a/Sources/CodexBarCore/Providers/Codex/CodexProfileSelection.swift b/Sources/CodexBarCore/Providers/Codex/CodexProfileSelection.swift new file mode 100644 index 000000000..e6bf9c746 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Codex/CodexProfileSelection.swift @@ -0,0 +1,23 @@ +import Foundation + +public struct CodexProfileSelection: Codable, Sendable, Equatable { + public let alias: String + public let profilePath: String + public let accountEmail: String? + public let accountID: String? + public let plan: String? + + public init( + alias: String, + profilePath: String, + accountEmail: String?, + accountID: String?, + plan: String?) + { + self.alias = alias + self.profilePath = profilePath + self.accountEmail = accountEmail + self.accountID = accountID + self.plan = plan + } +} diff --git a/Sources/CodexBarCore/Providers/Codex/CodexProfileStore.swift b/Sources/CodexBarCore/Providers/Codex/CodexProfileStore.swift new file mode 100644 index 000000000..23a01ab3b --- /dev/null +++ b/Sources/CodexBarCore/Providers/Codex/CodexProfileStore.swift @@ -0,0 +1,247 @@ +import CryptoKit +import Foundation + +public struct DiscoveredCodexProfile: Identifiable, Equatable, Sendable { + public let alias: String + public let fileURL: URL + public let accountEmail: String? + public let accountID: String? + public let plan: String? + public let isActiveInCodex: Bool + + public init( + alias: String, + fileURL: URL, + accountEmail: String?, + accountID: String?, + plan: String?, + isActiveInCodex: Bool) + { + self.alias = alias + self.fileURL = fileURL.standardizedFileURL + self.accountEmail = accountEmail + self.accountID = accountID + self.plan = plan + self.isActiveInCodex = isActiveInCodex + } + + public var id: String { + self.fileURL.path + } + + public var selection: CodexProfileSelection { + CodexProfileSelection( + alias: self.alias, + profilePath: self.fileURL.path, + accountEmail: self.accountEmail, + accountID: self.accountID, + plan: self.plan) + } + + public var tokenAccount: ProviderTokenAccount { + ProviderTokenAccount( + id: Self.stableUUID(seed: self.accountID ?? self.accountEmail ?? self.fileURL.path), + label: self.alias, + token: self.fileURL.path, + addedAt: 0, + lastUsed: nil) + } + + private static func stableUUID(seed: String) -> UUID { + let digest = SHA256.hash(data: Data(seed.utf8)) + let bytes = Array(digest.prefix(16)) + let uuidBytes = uuid_t( + bytes[0], + bytes[1], + bytes[2], + bytes[3], + bytes[4], + bytes[5], + bytes[6], + bytes[7], + bytes[8], + bytes[9], + bytes[10], + bytes[11], + bytes[12], + bytes[13], + bytes[14], + bytes[15]) + return UUID(uuid: uuidBytes) + } +} + +public enum CodexProfileStore { + public static func discover( + authFileURL: URL = CodexOAuthCredentialsStore.authFilePath(), + fileManager: FileManager = .default) -> [DiscoveredCodexProfile] + { + let activeAuthURL = authFileURL + let activeProfile = self.profile(at: activeAuthURL, alias: "Current", fileManager: fileManager).map { profile in + DiscoveredCodexProfile( + alias: profile.alias, + fileURL: profile.fileURL, + accountEmail: profile.accountEmail, + accountID: profile.accountID, + plan: profile.plan, + isActiveInCodex: true) + } + let activeCredentials = activeProfile.flatMap { self.credentials(at: $0.fileURL, fileManager: fileManager) } + + let profilesDirectory = activeAuthURL.deletingLastPathComponent().appendingPathComponent("profiles") + let candidates = (try? fileManager.contentsOfDirectory( + at: profilesDirectory, + includingPropertiesForKeys: [.isRegularFileKey, .isSymbolicLinkKey], + options: [.skipsHiddenFiles])) ?? [] + + var discovered: [DiscoveredCodexProfile] = candidates + .filter { $0.pathExtension.lowercased() == "json" } + .sorted(by: { $0.lastPathComponent.localizedStandardCompare($1.lastPathComponent) == .orderedAscending }) + .compactMap { candidate in + let alias = candidate.deletingPathExtension().lastPathComponent + guard var profile = self.profile(at: candidate, alias: alias, fileManager: fileManager) else { + return nil + } + let credentials = self.credentials(at: candidate, fileManager: fileManager) + profile = DiscoveredCodexProfile( + alias: profile.alias, + fileURL: profile.fileURL, + accountEmail: profile.accountEmail, + accountID: profile.accountID, + plan: profile.plan, + isActiveInCodex: self.matches(credentials, activeCredentials)) + return profile + } + + if let activeProfile, + discovered.contains(where: { $0.fileURL.standardizedFileURL == activeProfile.fileURL.standardizedFileURL }) == false + { + discovered.append(activeProfile) + } + + return discovered.sorted { lhs, rhs in + lhs.alias.localizedStandardCompare(rhs.alias) == .orderedAscending + } + } + + public static func profile( + at url: URL, + alias: String, + fileManager: FileManager = .default) -> DiscoveredCodexProfile? + { + guard self.isSafeRegularFile(url, fileManager: fileManager), + let data = try? Data(contentsOf: url), + let credentials = try? CodexOAuthCredentialsStore.parse(data: data) + else { + return nil + } + + let payload = credentials.idToken.flatMap(UsageFetcher.parseJWT) + let authDict = payload?["https://api.openai.com/auth"] as? [String: Any] + let profileDict = payload?["https://api.openai.com/profile"] as? [String: Any] + let rawEmail = (payload?["email"] as? String) ?? (profileDict?["email"] as? String) + let email = rawEmail?.trimmingCharacters(in: .whitespacesAndNewlines) + let rawPlan = (authDict?["chatgpt_plan_type"] as? String) ?? (payload?["chatgpt_plan_type"] as? String) + let plan = rawPlan?.trimmingCharacters(in: .whitespacesAndNewlines) + let accountID = credentials.accountId?.trimmingCharacters(in: .whitespacesAndNewlines) + + return DiscoveredCodexProfile( + alias: alias, + fileURL: url.standardizedFileURL, + accountEmail: email?.isEmpty == false ? email : nil, + accountID: accountID?.isEmpty == false ? accountID : nil, + plan: plan?.isEmpty == false ? plan : nil, + isActiveInCodex: false) + } + + public static func displayProfiles( + authFileURL: URL = CodexOAuthCredentialsStore.authFilePath(), + fileManager: FileManager = .default) -> [DiscoveredCodexProfile] + { + let discovered = self.discover(authFileURL: authFileURL, fileManager: fileManager) + let authPath = authFileURL.standardizedFileURL.path + guard discovered.contains(where: { $0.fileURL.path == authPath }) else { + return discovered + } + + let activeCredentials = self.credentials(at: authFileURL, fileManager: fileManager) + let matchedSavedPath = discovered.first(where: { profile in + profile.fileURL.path != authPath && + self.matches(self.credentials(at: profile.fileURL, fileManager: fileManager), activeCredentials) + })?.fileURL.standardizedFileURL.path + + let visible = discovered.compactMap { profile -> DiscoveredCodexProfile? in + if profile.fileURL.path == authPath, matchedSavedPath != nil { + return nil + } + if profile.fileURL.path == authPath { + return DiscoveredCodexProfile( + alias: "Live", + fileURL: profile.fileURL, + accountEmail: profile.accountEmail, + accountID: profile.accountID, + plan: profile.plan, + isActiveInCodex: true) + } + if profile.fileURL.path == matchedSavedPath { + return DiscoveredCodexProfile( + alias: profile.alias, + fileURL: profile.fileURL, + accountEmail: profile.accountEmail, + accountID: profile.accountID, + plan: profile.plan, + isActiveInCodex: true) + } + return profile + } + + return visible.sorted { lhs, rhs in + lhs.alias.localizedStandardCompare(rhs.alias) == .orderedAscending + } + } + + public static func selectedDisplayProfile( + selectedPath: String?, + authFileURL: URL = CodexOAuthCredentialsStore.authFilePath(), + fileManager: FileManager = .default) -> DiscoveredCodexProfile? + { + let displayed = self.displayProfiles(authFileURL: authFileURL, fileManager: fileManager) + if let selectedPath { + let standardizedPath = URL(fileURLWithPath: selectedPath).standardizedFileURL.path + if let exact = displayed.first(where: { $0.fileURL.path == standardizedPath }) { + return exact + } + let authPath = authFileURL.standardizedFileURL.path + if standardizedPath == authPath { + return displayed.first(where: \.isActiveInCodex) ?? displayed.first + } + } + return displayed.first(where: \.isActiveInCodex) ?? displayed.first + } + + private static func credentials(at url: URL, fileManager: FileManager) -> CodexOAuthCredentials? { + guard self.isSafeRegularFile(url, fileManager: fileManager), + let data = try? Data(contentsOf: url) + else { + return nil + } + return try? CodexOAuthCredentialsStore.parse(data: data) + } + + private static func matches(_ lhs: CodexOAuthCredentials?, _ rhs: CodexOAuthCredentials?) -> Bool { + guard let lhs, let rhs else { return false } + return lhs.accessToken == rhs.accessToken && + lhs.refreshToken == rhs.refreshToken && + lhs.accountId == rhs.accountId + } + + private static func isSafeRegularFile(_ url: URL, fileManager: FileManager) -> Bool { + guard let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey]), + values.isRegularFile == true, + values.isSymbolicLink != true + else { + return false + } + return fileManager.fileExists(atPath: url.path) + } +} diff --git a/Sources/CodexBarCore/Providers/Codex/CodexWebDashboardStrategy.swift b/Sources/CodexBarCore/Providers/Codex/CodexWebDashboardStrategy.swift index 9f7b6b93e..c62b322d1 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexWebDashboardStrategy.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexWebDashboardStrategy.swift @@ -11,7 +11,8 @@ public struct CodexWebDashboardStrategy: ProviderFetchStrategy { public func isAvailable(_ context: ProviderFetchContext) async -> Bool { context.sourceMode.usesWeb && !Self.managedAccountStoreIsUnreadable(context) && - !Self.managedAccountTargetIsUnavailable(context) + !Self.managedAccountTargetIsUnavailable(context) && + !Self.selectedProfileIsUnavailable(context) } public func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { @@ -25,6 +26,10 @@ public struct CodexWebDashboardStrategy: ProviderFetchStrategy { // fall back to "any signed-in browser account" for that stale selection. throw OpenAIDashboardFetcher.FetchError.loginRequired } + guard !Self.selectedProfileIsUnavailable(context) else { + // A missing selected live-system profile must not fall back to the ambient local Codex account. + throw OpenAIDashboardFetcher.FetchError.loginRequired + } // Ensure AppKit is initialized before using WebKit in a CLI. await MainActor.run { @@ -58,6 +63,10 @@ public struct CodexWebDashboardStrategy: ProviderFetchStrategy { private static func managedAccountTargetIsUnavailable(_ context: ProviderFetchContext) -> Bool { context.settings?.codex?.managedAccountTargetUnavailable == true } + + private static func selectedProfileIsUnavailable(_ context: ProviderFetchContext) -> Bool { + context.settings?.codex?.selectedProfileUnavailable == true + } } struct OpenAIWebCodexResult { diff --git a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift index d74efbebd..82d436e48 100644 --- a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift +++ b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift @@ -49,6 +49,7 @@ public struct ProviderSettingsSnapshot: Sendable { public let managedAccountStoreUnreadable: Bool public let managedAccountTargetUnavailable: Bool public let dashboardAuthorityKnownOwners: [CodexDashboardKnownOwnerCandidate] + public let selectedProfileUnavailable: Bool public init( usageDataSource: CodexUsageDataSource, @@ -56,7 +57,8 @@ public struct ProviderSettingsSnapshot: Sendable { manualCookieHeader: String?, managedAccountStoreUnreadable: Bool = false, managedAccountTargetUnavailable: Bool = false, - dashboardAuthorityKnownOwners: [CodexDashboardKnownOwnerCandidate] = []) + dashboardAuthorityKnownOwners: [CodexDashboardKnownOwnerCandidate] = [], + selectedProfileUnavailable: Bool = false) { self.usageDataSource = usageDataSource self.cookieSource = cookieSource @@ -64,6 +66,7 @@ public struct ProviderSettingsSnapshot: Sendable { self.managedAccountStoreUnreadable = managedAccountStoreUnreadable self.managedAccountTargetUnavailable = managedAccountTargetUnavailable self.dashboardAuthorityKnownOwners = dashboardAuthorityKnownOwners + self.selectedProfileUnavailable = selectedProfileUnavailable } } diff --git a/Sources/CodexBarCore/UsageFetcher.swift b/Sources/CodexBarCore/UsageFetcher.swift index 9731c0891..d37f8be3e 100644 --- a/Sources/CodexBarCore/UsageFetcher.swift +++ b/Sources/CodexBarCore/UsageFetcher.swift @@ -566,7 +566,9 @@ public struct UsageFetcher: Sendable { } private func loadRPCUsage() async throws -> UsageSnapshot { - let rpc = try CodexRPCClient(environment: self.environment) + let resolved = try CodexProfileExecutionEnvironment.resolvedEnvironment(from: self.environment) + defer { resolved.cleanup() } + let rpc = try CodexRPCClient(environment: resolved.environment) defer { rpc.shutdown() } try await rpc.initialize(clientName: "codexbar", clientVersion: "0.5.4") @@ -592,9 +594,11 @@ public struct UsageFetcher: Sendable { } private func loadTTYUsage(keepCLISessionsAlive: Bool) async throws -> UsageSnapshot { + let resolved = try CodexProfileExecutionEnvironment.resolvedEnvironment(from: self.environment) + defer { resolved.cleanup() } let status = try await CodexStatusProbe( keepCLISessionsAlive: keepCLISessionsAlive, - environment: self.environment) + environment: resolved.environment) .fetch() return try Self.makeCodexUsageSnapshot( primary: Self.makeTTYWindow( @@ -617,7 +621,9 @@ public struct UsageFetcher: Sendable { } private func loadRPCCredits() async throws -> CreditsSnapshot { - let rpc = try CodexRPCClient(environment: self.environment) + let resolved = try CodexProfileExecutionEnvironment.resolvedEnvironment(from: self.environment) + defer { resolved.cleanup() } + let rpc = try CodexRPCClient(environment: resolved.environment) defer { rpc.shutdown() } try await rpc.initialize(clientName: "codexbar", clientVersion: "0.5.4") let limits = try await rpc.fetchRateLimits().rateLimits @@ -627,9 +633,11 @@ public struct UsageFetcher: Sendable { } private func loadTTYCredits(keepCLISessionsAlive: Bool) async throws -> CreditsSnapshot { + let resolved = try CodexProfileExecutionEnvironment.resolvedEnvironment(from: self.environment) + defer { resolved.cleanup() } let status = try await CodexStatusProbe( keepCLISessionsAlive: keepCLISessionsAlive, - environment: self.environment) + environment: resolved.environment) .fetch() guard let credits = status.credits else { throw UsageError.noRateLimitsFound } return CreditsSnapshot(remaining: credits, events: [], updatedAt: Date()) @@ -653,7 +661,9 @@ public struct UsageFetcher: Sendable { public func debugRawRateLimits() async -> String { do { - let rpc = try CodexRPCClient(environment: self.environment) + let resolved = try CodexProfileExecutionEnvironment.resolvedEnvironment(from: self.environment) + defer { resolved.cleanup() } + let rpc = try CodexRPCClient(environment: resolved.environment) defer { rpc.shutdown() } try await rpc.initialize(clientName: "codexbar", clientVersion: "0.5.4") let limits = try await rpc.fetchRateLimits() @@ -670,7 +680,15 @@ public struct UsageFetcher: Sendable { } public func loadAuthBackedCodexAccount() -> CodexAuthBackedAccount { - guard let credentials = try? CodexOAuthCredentialsStore.load(env: self.environment) else { + let resolvedEnv: [String: String] + if let resolved = try? CodexProfileExecutionEnvironment.resolvedEnvironment(from: self.environment) { + defer { resolved.cleanup() } + resolvedEnv = resolved.environment + } else { + resolvedEnv = self.environment + } + + guard let credentials = try? CodexOAuthCredentialsStore.load(env: resolvedEnv) else { return CodexAuthBackedAccount(identity: .unresolved, email: nil, plan: nil) } diff --git a/Tests/CodexBarTests/CodexProfileRoutingTests.swift b/Tests/CodexBarTests/CodexProfileRoutingTests.swift new file mode 100644 index 000000000..f67646de7 --- /dev/null +++ b/Tests/CodexBarTests/CodexProfileRoutingTests.swift @@ -0,0 +1,162 @@ +import AppKit +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +struct CodexProfileRoutingTests { + @Test + func `provider registry routes codex through selected local profile`() throws { + let settings = Self.makeSettingsStore(suite: "CodexProfileRoutingTests-env") + let profileURL = URL(fileURLWithPath: "/tmp/codex-profile-plus-b.json") + settings._test_codexProfiles = [ + DiscoveredCodexProfile( + alias: "plus-a", + fileURL: URL(fileURLWithPath: "/tmp/codex-profile-plus-a.json"), + accountEmail: "plus-a@example.com", + accountID: "acct-a", + plan: "plus", + isActiveInCodex: true), + DiscoveredCodexProfile( + alias: "plus-b", + fileURL: profileURL, + accountEmail: "plus-b@example.com", + accountID: "acct-b", + plan: "plus", + isActiveInCodex: false), + ] + settings.selectCodexProfile(path: profileURL.path) + + let env = ProviderRegistry.makeEnvironment( + base: [:], + provider: .codex, + settings: settings, + tokenOverride: nil) + + #expect(env[CodexProfileExecutionEnvironment.authFileOverrideKey] == profileURL.standardizedFileURL.path) + #expect(settings.selectedCodexProfileEmail() == "plus-b@example.com") + } + + @Test + func `missing selected local profile fails closed for openai web refresh`() async throws { + let settings = Self.makeSettingsStore(suite: "CodexProfileRoutingTests-openai-fail-closed") + settings.refreshFrequency = .manual + settings.codexCookieSource = .auto + Self.enableOnlyCodex(settings) + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "live@example.com", + codexHomePath: "/Users/test/.codex", + observedAt: Date()) + settings._test_codexProfiles = [ + DiscoveredCodexProfile( + alias: "plus-a", + fileURL: URL(fileURLWithPath: "/tmp/codex-profile-plus-a.json"), + accountEmail: "plus-a@example.com", + accountID: "acct-a", + plan: "plus", + isActiveInCodex: true), + ] + settings.selectCodexProfile(path: "/tmp/codex-profile-missing.json") + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing) + + await store.refreshOpenAIDashboardIfNeeded(force: true) + + #expect(settings.codexSettingsSnapshot(tokenOverride: nil).selectedProfileUnavailable) + #expect(store.openAIDashboard == nil) + #expect(store.lastOpenAIDashboardError?.contains("selected local Codex profile is unavailable") == true) + } + + @Test + func `settings section exposes local profiles and selecting one switches codex back to live system`() async throws { + let settings = Self.makeSettingsStore(suite: "CodexProfileRoutingTests-pane") + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing) + + let managedAccount = ManagedCodexAccount( + id: UUID(), + email: "managed@example.com", + managedHomePath: "/tmp/managed-home", + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + settings._test_activeManagedCodexAccount = managedAccount + settings.codexActiveSource = .managedAccount(id: managedAccount.id) + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "live@example.com", + codexHomePath: "/Users/test/.codex", + observedAt: Date()) + + let profileURL = URL(fileURLWithPath: "/tmp/codex-profile-plus-b.json") + settings._test_codexProfiles = [ + DiscoveredCodexProfile( + alias: "plus-a", + fileURL: URL(fileURLWithPath: "/tmp/codex-profile-plus-a.json"), + accountEmail: "plus-a@example.com", + accountID: "acct-a", + plan: "plus", + isActiveInCodex: true), + DiscoveredCodexProfile( + alias: "plus-b", + fileURL: profileURL, + accountEmail: "plus-b@example.com", + accountID: "acct-b", + plan: "plus", + isActiveInCodex: false), + ] + + let pane = ProvidersPane(settings: settings, store: store) + let initialState = try #require(pane._test_codexAccountsSectionState()) + #expect(initialState.localProfiles.map(\.title) == ["plus-a", "plus-b"]) + #expect(initialState.localProfiles.map(\.subtitle) == ["plus-a@example.com", "plus-b@example.com"]) + #expect(initialState.localProfiles.contains(where: { $0.title == "plus-a" && $0.isLive })) + + await pane._test_selectCodexLocalProfile(path: profileURL.path) + + #expect(settings.codexActiveSource == .liveSystem) + #expect(settings.selectedCodexProfile()?.alias == "plus-b") + let updatedState = try #require(pane._test_codexAccountsSectionState()) + #expect(updatedState.localProfiles.contains(where: { $0.title == "plus-b" && $0.isDisplayed })) + } + + private static func makeSettingsStore(suite: String) -> SettingsStore { + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + + return SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore(), + codexCookieStore: InMemoryCookieHeaderStore(), + claudeCookieStore: InMemoryCookieHeaderStore(), + cursorCookieStore: InMemoryCookieHeaderStore(), + opencodeCookieStore: InMemoryCookieHeaderStore(), + factoryCookieStore: InMemoryCookieHeaderStore(), + minimaxCookieStore: InMemoryMiniMaxCookieStore(), + minimaxAPITokenStore: InMemoryMiniMaxAPITokenStore(), + kimiTokenStore: InMemoryKimiTokenStore(), + kimiK2TokenStore: InMemoryKimiK2TokenStore(), + augmentCookieStore: InMemoryCookieHeaderStore(), + ampCookieStore: InMemoryCookieHeaderStore(), + copilotTokenStore: InMemoryCopilotTokenStore(), + tokenAccountStore: InMemoryTokenAccountStore()) + } + + private static func enableOnlyCodex(_ settings: SettingsStore) { + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: provider == .codex) + } + } +} diff --git a/Tests/CodexBarTests/CodexProfileStoreTests.swift b/Tests/CodexBarTests/CodexProfileStoreTests.swift new file mode 100644 index 000000000..ab989ade6 --- /dev/null +++ b/Tests/CodexBarTests/CodexProfileStoreTests.swift @@ -0,0 +1,166 @@ +import Foundation +import Testing +@testable import CodexBar +@testable import CodexBarCore + +struct CodexProfileStoreTests { + @Test + func `discovers codex profiles and skips malformed entries`() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let authURL = root.appendingPathComponent("auth.json") + let profilesURL = root.appendingPathComponent("profiles", isDirectory: true) + try FileManager.default.createDirectory(at: profilesURL, withIntermediateDirectories: true) + + try self.writeAuthFile( + to: authURL, + email: "current@example.com", + plan: "plus", + accountID: "acct-current") + try self.writeAuthFile( + to: profilesURL.appendingPathComponent("plus-b.json"), + email: "plus-b@example.com", + plan: "plus", + accountID: "acct-b") + try self.writeAuthFile( + to: profilesURL.appendingPathComponent("plus-c.json"), + email: "plus-c@example.com", + plan: "plus", + accountID: "acct-c") + try Data("{\"broken\":true}".utf8).write(to: profilesURL.appendingPathComponent("broken.json")) + try FileManager.default.createSymbolicLink( + at: profilesURL.appendingPathComponent("linked.json"), + withDestinationURL: profilesURL.appendingPathComponent("plus-b.json")) + + let profiles = CodexProfileStore.discover(authFileURL: authURL) + + #expect(profiles.map(\.alias) == ["Current", "plus-b", "plus-c"]) + #expect(profiles.contains(where: { $0.alias == "Current" && $0.isActiveInCodex })) + #expect(!profiles.contains(where: { $0.alias == "broken" })) + #expect(!profiles.contains(where: { $0.alias == "linked" })) + } + + @Test + func `selected profile falls back to current auth when saved selection is missing`() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let authURL = root.appendingPathComponent("auth.json") + let profilesURL = root.appendingPathComponent("profiles", isDirectory: true) + try FileManager.default.createDirectory(at: profilesURL, withIntermediateDirectories: true) + + try self.writeAuthFile( + to: authURL, + email: "current@example.com", + plan: "plus", + accountID: "acct-current") + try self.writeAuthFile( + to: profilesURL.appendingPathComponent("plus-a.json"), + email: "plus-a@example.com", + plan: "plus", + accountID: "acct-a") + + let selected = CodexProfileStore.selectedDisplayProfile( + selectedPath: profilesURL.appendingPathComponent("missing.json").path, + authFileURL: authURL) + + #expect(selected?.alias == "Live") + #expect(selected?.isActiveInCodex == true) + } + + @Test + func `display profiles collapse live auth into matching saved profile`() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let authURL = root.appendingPathComponent("auth.json") + let profilesURL = root.appendingPathComponent("profiles", isDirectory: true) + try FileManager.default.createDirectory(at: profilesURL, withIntermediateDirectories: true) + + try self.writeAuthFile( + to: authURL, + email: "same@example.com", + plan: "plus", + accountID: "acct-same") + try self.writeAuthFile( + to: profilesURL.appendingPathComponent("plus-b.json"), + email: "same@example.com", + plan: "plus", + accountID: "acct-same") + + let profiles = CodexProfileStore.displayProfiles(authFileURL: authURL) + + #expect(profiles.map(\.alias) == ["plus-b"]) + #expect(profiles.first?.isActiveInCodex == true) + } + + @Test + func `creates isolated codex execution environment for profile override`() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let profileURL = root.appendingPathComponent("plus-a.json") + try self.writeAuthFile( + to: profileURL, + email: "plus-a@example.com", + plan: "plus", + accountID: "acct-a") + + let resolved = try CodexProfileExecutionEnvironment.resolvedEnvironment(from: [ + CodexProfileExecutionEnvironment.authFileOverrideKey: profileURL.path, + ]) + defer { resolved.cleanup() } + + let codexHome = try #require(resolved.environment["CODEX_HOME"]) + let authURL = URL(fileURLWithPath: codexHome).appendingPathComponent("auth.json") + #expect(resolved.environment[CodexProfileExecutionEnvironment.authFileOverrideKey] == nil) + #expect(FileManager.default.fileExists(atPath: authURL.path)) + #expect(try Data(contentsOf: authURL) == Data(contentsOf: profileURL)) + + let dirPermissions = try FileManager.default.attributesOfItem(atPath: codexHome)[.posixPermissions] as? NSNumber + let filePermissions = try FileManager.default + .attributesOfItem(atPath: authURL.path)[.posixPermissions] as? NSNumber + #expect(dirPermissions?.intValue == 0o700) + #expect(filePermissions?.intValue == 0o600) + + resolved.cleanup() + #expect(FileManager.default.fileExists(atPath: codexHome) == false) + } + + private func writeAuthFile(to url: URL, email: String, plan: String, accountID: String) throws { + let token = Self.fakeJWT(email: email, plan: plan) + let payload: [String: Any] = [ + "tokens": [ + "access_token": "access-\(accountID)", + "refresh_token": "refresh-\(accountID)", + "id_token": token, + "account_id": accountID, + ], + ] + let data = try JSONSerialization.data(withJSONObject: payload) + try data.write(to: url) + } + + private static func fakeJWT(email: String, plan: String) -> String { + let header = (try? JSONSerialization.data(withJSONObject: ["alg": "none"])) ?? Data() + let payload = (try? JSONSerialization.data(withJSONObject: [ + "email": email, + "chatgpt_plan_type": plan, + "https://api.openai.com/auth": ["chatgpt_plan_type": plan], + "https://api.openai.com/profile": ["email": email], + ])) ?? Data() + return "\(self.base64URL(header)).\(self.base64URL(payload))." + } + + private static func base64URL(_ data: Data) -> String { + data.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } +} diff --git a/Tests/CodexBarTests/SettingsStoreTests.swift b/Tests/CodexBarTests/SettingsStoreTests.swift index b00fc9796..4d616e937 100644 --- a/Tests/CodexBarTests/SettingsStoreTests.swift +++ b/Tests/CodexBarTests/SettingsStoreTests.swift @@ -662,20 +662,25 @@ struct SettingsStoreTests { zaiTokenStore: NoopZaiTokenStore(), syntheticTokenStore: NoopSyntheticTokenStore()) - var didChange = false + @MainActor + final class ObservationFlag { + var value = false + } + + let didChange = ObservationFlag() withObservationTracking { _ = store.menuObservationToken } onChange: { Task { @MainActor in - didChange = true + didChange.value = true } } store.statusChecksEnabled.toggle() try? await Task.sleep(nanoseconds: 50_000_000) - #expect(didChange == true) + #expect(didChange.value == true) } @Test @@ -691,20 +696,25 @@ struct SettingsStoreTests { zaiTokenStore: NoopZaiTokenStore(), syntheticTokenStore: NoopSyntheticTokenStore()) - var didChange = false + @MainActor + final class ObservationFlag { + var value = false + } + + let didChange = ObservationFlag() withObservationTracking { _ = store.codexCookieSource } onChange: { Task { @MainActor in - didChange = true + didChange.value = true } } store.codexCookieSource = .manual try? await Task.sleep(nanoseconds: 50_000_000) - #expect(didChange == true) + #expect(didChange.value == true) } @Test @@ -720,20 +730,25 @@ struct SettingsStoreTests { zaiTokenStore: NoopZaiTokenStore(), syntheticTokenStore: NoopSyntheticTokenStore()) - var didChange = false + @MainActor + final class ObservationFlag { + var value = false + } + + let didChange = ObservationFlag() withObservationTracking { _ = store.menuObservationToken } onChange: { Task { @MainActor in - didChange = true + didChange.value = true } } store.codexActiveSource = .liveSystem try? await Task.sleep(nanoseconds: 50_000_000) - #expect(didChange == true) + #expect(didChange.value == true) } @Test diff --git a/Tests/CodexBarTests/StatusMenuCodexSwitcherTests.swift b/Tests/CodexBarTests/StatusMenuCodexSwitcherTests.swift index 81ba6bb45..a78ae6daf 100644 --- a/Tests/CodexBarTests/StatusMenuCodexSwitcherTests.swift +++ b/Tests/CodexBarTests/StatusMenuCodexSwitcherTests.swift @@ -1,3 +1,4 @@ +import AppKit import CodexBarCore import Foundation import Testing @@ -23,6 +24,14 @@ struct StatusMenuCodexSwitcherTests { syntheticTokenStore: NoopSyntheticTokenStore()) } + private func makeStatusBarForTesting() -> NSStatusBar { + let env = ProcessInfo.processInfo.environment + if env["GITHUB_ACTIONS"] == "true" || env["CI"] == "true" { + return .system + } + return NSStatusBar() + } + private func enableOnlyCodex(_ settings: SettingsStore) { let registry = ProviderRegistry.shared for provider in UsageProvider.allCases { @@ -47,6 +56,17 @@ struct StatusMenuCodexSwitcherTests { } } + private func descendantButtons(in view: NSView) -> [NSButton] { + view.subviews.flatMap { subview in + var buttons: [NSButton] = [] + if let button = subview as? NSButton { + buttons.append(button) + } + buttons.append(contentsOf: self.descendantButtons(in: subview)) + return buttons + } + } + private func selectCodexVisibleAccountForStatusMenu( id: String, settings: SettingsStore, @@ -348,6 +368,73 @@ struct StatusMenuCodexSwitcherTests { #expect(state.canAddAccount == false) } + @Test + func `codex menu shows local profile switcher and selecting profile updates displayed profile`() async throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.costUsageEnabled = false + settings.codexCookieSource = .off + self.enableOnlyCodex(settings) + + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "plus-a@example.com", + codexHomePath: "/Users/test/.codex", + observedAt: Date()) + defer { + settings._test_liveSystemCodexAccount = nil + settings._test_codexProfiles = nil + } + + let profiles = [ + DiscoveredCodexProfile( + alias: "plus-a", + fileURL: URL(fileURLWithPath: "/tmp/codex-profile-plus-a.json"), + accountEmail: "plus-a@example.com", + accountID: "acct-a", + plan: "plus", + isActiveInCodex: true), + DiscoveredCodexProfile( + alias: "plus-b", + fileURL: URL(fileURLWithPath: "/tmp/codex-profile-plus-b.json"), + accountEmail: "plus-b@example.com", + accountID: "acct-b", + plan: "plus", + isActiveInCodex: false), + ] + settings._test_codexProfiles = profiles + settings.selectCodexProfile(path: profiles[0].fileURL.path) + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + let menu = controller.makeMenu(for: .codex) + controller.menuWillOpen(menu) + + let switcherView = try #require(menu.items.compactMap { $0.view as? CodexProfileSwitcherView }.first) + let buttons = self.descendantButtons(in: switcherView) + #expect(buttons.map(\.title) == ["plus-a", "plus-b"]) + + let plusBButton = try #require(buttons.first(where: { $0.title == "plus-b" })) + plusBButton.performClick(nil) + + for _ in 0..<10 where settings.selectedCodexProfile()?.alias != "plus-b" { + try? await Task.sleep(for: .milliseconds(20)) + } + + #expect(settings.codexActiveSource == .liveSystem) + #expect(settings.selectedCodexProfile()?.alias == "plus-b") + } + @Test func `codex menu switcher can select managed row when same email rows split by identity`() throws { self.disableMenuCardsForTesting() From d72d5a09e89c1cd438fc711010ec8903d8c0f6dc Mon Sep 17 00:00:00 2001 From: igneous-prose <252039396+igneous-prose@users.noreply.github.com> Date: Thu, 2 Apr 2026 08:48:31 +0100 Subject: [PATCH 2/2] Fail closed when no active Codex profile exists --- .../Providers/Codex/CodexSettingsStore.swift | 2 +- .../Providers/Codex/CodexProfileStore.swift | 8 ++++-- .../CodexProfileStoreTests.swift | 28 +++++++++++++++++++ 3 files changed, 34 insertions(+), 4 deletions(-) diff --git a/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift b/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift index 08e6cd409..918e387a4 100644 --- a/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift +++ b/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift @@ -29,7 +29,7 @@ extension SettingsStore { return exact } } - return override.first(where: \.isActiveInCodex) ?? override.first + return override.first(where: \.isActiveInCodex) } #endif return CodexProfileStore.selectedDisplayProfile(selectedPath: self.selectedCodexProfilePath) diff --git a/Sources/CodexBarCore/Providers/Codex/CodexProfileStore.swift b/Sources/CodexBarCore/Providers/Codex/CodexProfileStore.swift index 23a01ab3b..04a35a972 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexProfileStore.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexProfileStore.swift @@ -114,7 +114,9 @@ public enum CodexProfileStore { } if let activeProfile, - discovered.contains(where: { $0.fileURL.standardizedFileURL == activeProfile.fileURL.standardizedFileURL }) == false + discovered.contains(where: { + $0.fileURL.standardizedFileURL == activeProfile.fileURL.standardizedFileURL + }) == false { discovered.append(activeProfile) } @@ -213,10 +215,10 @@ public enum CodexProfileStore { } let authPath = authFileURL.standardizedFileURL.path if standardizedPath == authPath { - return displayed.first(where: \.isActiveInCodex) ?? displayed.first + return displayed.first(where: \.isActiveInCodex) } } - return displayed.first(where: \.isActiveInCodex) ?? displayed.first + return displayed.first(where: \.isActiveInCodex) } private static func credentials(at url: URL, fileManager: FileManager) -> CodexOAuthCredentials? { diff --git a/Tests/CodexBarTests/CodexProfileStoreTests.swift b/Tests/CodexBarTests/CodexProfileStoreTests.swift index ab989ade6..ac0c28e56 100644 --- a/Tests/CodexBarTests/CodexProfileStoreTests.swift +++ b/Tests/CodexBarTests/CodexProfileStoreTests.swift @@ -71,6 +71,34 @@ struct CodexProfileStoreTests { #expect(selected?.isActiveInCodex == true) } + @Test + func `selected profile stays unset when no active auth exists`() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let authURL = root.appendingPathComponent("auth.json") + let profilesURL = root.appendingPathComponent("profiles", isDirectory: true) + try FileManager.default.createDirectory(at: profilesURL, withIntermediateDirectories: true) + + try self.writeAuthFile( + to: profilesURL.appendingPathComponent("plus-a.json"), + email: "plus-a@example.com", + plan: "plus", + accountID: "acct-a") + try self.writeAuthFile( + to: profilesURL.appendingPathComponent("plus-b.json"), + email: "plus-b@example.com", + plan: "plus", + accountID: "acct-b") + + let selected = CodexProfileStore.selectedDisplayProfile( + selectedPath: nil, + authFileURL: authURL) + + #expect(selected == nil) + } + @Test func `display profiles collapse live auth into matching saved profile`() throws { let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)