Skip to content
Merged
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@ The format is based on Keep a Changelog and this project uses Semantic Versionin

## [Unreleased]

### Fixed

- Prevent removed accounts from reappearing after managed-home discovery on macOS and Windows
- Recover stale managed account credentials from newer matching auth homes before surfacing refresh-token errors
- Decode Plus account credit balances when the Codex usage API returns `credits.balance` as a string
- Keep distinct provider account IDs separate even when accounts share the same email or auth subject

## [1.1.3] - 2026-04-23

### Added
Expand Down
4 changes: 4 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,8 @@ let package = Package(
.linkedFramework("AppKit"),
.linkedFramework("SwiftUI"),
]),
.testTarget(
name: "CodexControlTests",
dependencies: ["CodexControl"],
path: "Tests/CodexControlTests"),
])
51 changes: 41 additions & 10 deletions Sources/CodexControl/App/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ final class AppModel: ObservableObject {
private let snapshotStore = SnapshotStore()
private let accountManager = CodexAccountManager()
private let desktopController = CodexDesktopControl()
private var removedAccounts: [RemovedAccountIdentity] = []
private let autoRefreshInterval: TimeInterval = 5 * 60
private var autoRefreshTask: Task<Void, Never>?
private var addAccountTask: Task<Void, Never>?
Expand Down Expand Up @@ -185,8 +186,12 @@ final class AppModel: ObservableObject {
return
}

let snapshot = self.accounts.filter { !self.requiresReauthentication(accountID: $0.id) }
guard !snapshot.isEmpty else {
return
}

self.isRefreshingAll = true
let snapshot = self.accounts
for account in snapshot {
var state = self.runtimeStates[account.id] ?? AccountRuntimeState()
state.isLoading = true
Expand Down Expand Up @@ -260,8 +265,9 @@ final class AppModel: ObservableObject {

do {
let account = try await self.accountManager.addManagedAccount()
self.restoreRemovedAccount(account)
self.accounts = self.accountStore.merge(existing: self.accounts, incoming: [account])
try self.accountStore.saveAccounts(self.accounts)
try self.accountStore.saveAccounts(self.accounts, removedAccounts: self.removedAccounts)
self.selectedAccountID = self.accounts.first(where: { $0.matches(account) })?.id ?? account.id
self.statusMessage = "\(account.displayName) added."
if let selectedAccount = self.selectedAccount {
Expand All @@ -287,8 +293,9 @@ final class AppModel: ObservableObject {

do {
let updated = try await self.accountManager.reauthenticate(account)
self.restoreRemovedAccount(updated)
self.mergeAccount(updated)
try self.accountStore.saveAccounts(self.accounts)
try self.accountStore.saveAccounts(self.accounts, removedAccounts: self.removedAccounts)
self.statusMessage = "\(updated.displayName) reauthenticated."
if let refreshed = self.accounts.first(where: { $0.id == account.id }) {
await self.refresh(account: refreshed)
Expand All @@ -308,7 +315,7 @@ final class AppModel: ObservableObject {
let result = try self.accountManager.switchActiveAccount(account, existing: self.accounts)
if let materializedAccount = result.materializedAccount {
self.mergeAccount(materializedAccount)
try self.accountStore.saveAccounts(self.accounts)
try self.accountStore.saveAccounts(self.accounts, removedAccounts: self.removedAccounts)
}

self.loadInitialAccounts()
Expand Down Expand Up @@ -395,17 +402,20 @@ final class AppModel: ObservableObject {

private func loadInitialAccounts() {
do {
let loadedAccounts = try self.accountStore.loadAccounts()
let stored = try self.accountStore.loadAccountList()
self.removedAccounts = stored.removedAccounts
let loadedAccounts = stored.accounts.filter { !self.isRemoved($0) }
let storedAccounts = loadedAccounts.filter { $0.source != .ambient }
let discoveredManagedAccounts = try self.accountManager.discoverManagedAccounts(existing: loadedAccounts)
var incomingAccounts = discoveredManagedAccounts
if let ambientAccount = try self.accountManager.discoverAmbientAccount(existing: loadedAccounts) {
incomingAccounts.insert(ambientAccount, at: 0)
}
incomingAccounts.removeAll { self.isRemoved($0) }

self.accounts = self.accountStore.merge(existing: storedAccounts, incoming: incomingAccounts)
if self.accounts != loadedAccounts {
try self.accountStore.saveAccounts(self.accounts)
if self.accounts != stored.accounts {
try self.accountStore.saveAccounts(self.accounts, removedAccounts: self.removedAccounts)
}
self.ensureSelection()
self.refreshActiveIdentity()
Expand All @@ -420,12 +430,16 @@ final class AppModel: ObservableObject {
}

private func remove(_ account: StoredAccount) {
self.accounts.removeAll { $0.id == account.id }
let removedIdentity = RemovedAccountIdentity(account: account)
self.removedAccounts.removeAll { $0.matches(account) }
self.removedAccounts.append(removedIdentity)

self.accounts.removeAll { $0.id == account.id || removedIdentity.matches($0) }
self.runtimeStates.removeValue(forKey: account.id)

do {
try self.accountManager.removeManagedFilesIfOwned(account)
try self.accountStore.saveAccounts(self.accounts)
try self.accountStore.saveAccounts(self.accounts, removedAccounts: self.removedAccounts)
self.ensureSelection()
self.statusMessage = "\(account.displayName) removed."
} catch {
Expand Down Expand Up @@ -478,12 +492,29 @@ final class AppModel: ObservableObject {

private func persistAccountsSilently() {
do {
try self.accountStore.saveAccounts(self.accounts)
try self.accountStore.saveAccounts(self.accounts, removedAccounts: self.removedAccounts)
} catch {
self.statusMessage = error.localizedDescription
}
}

private func isRemoved(_ account: StoredAccount) -> Bool {
self.removedAccounts.contains { $0.matches(account) }
}

private func restoreRemovedAccount(_ account: StoredAccount) {
self.removedAccounts.removeAll { $0.matches(account) }
}

private func requiresReauthentication(accountID: UUID) -> Bool {
guard let message = self.runtimeStates[accountID]?.errorMessage?.lowercased() else {
return false
}

return message.contains("refresh token")
&& message.contains("sign in again")
}

private func persistSnapshotsSilently() {
let snapshots = self.runtimeStates.compactMapValues(\.snapshot)
do {
Expand Down
97 changes: 94 additions & 3 deletions Sources/CodexControl/Models/AccountModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -71,17 +71,28 @@ struct StoredAccount: Codable, Identifiable, Hashable, Sendable {
Self.normalizeIdentifier(self.authSubject)
}

var normalizedProviderAccountID: String? {
Self.normalizeIdentifier(self.providerAccountID)
}

var standardizedHomePath: String {
URL(fileURLWithPath: self.codexHomePath, isDirectory: true).standardizedFileURL.path
}

func matches(_ other: StoredAccount) -> Bool {
if let normalizedAuthSubject, normalizedAuthSubject == other.normalizedAuthSubject
{
if self.standardizedHomePath == other.standardizedHomePath {
return true
}

if self.standardizedHomePath == other.standardizedHomePath {
if let normalizedProviderAccountID, let otherProviderAccountID = other.normalizedProviderAccountID {
return normalizedProviderAccountID == otherProviderAccountID
}

if self.normalizedProviderAccountID != nil || other.normalizedProviderAccountID != nil {
return false
}

if let normalizedAuthSubject, normalizedAuthSubject == other.normalizedAuthSubject {
return true
}

Expand Down Expand Up @@ -163,6 +174,86 @@ struct StoredAccount: Codable, Identifiable, Hashable, Sendable {
struct StoredAccountList: Codable, Sendable {
let version: Int
let accounts: [StoredAccount]
let removedAccounts: [RemovedAccountIdentity]

init(version: Int, accounts: [StoredAccount], removedAccounts: [RemovedAccountIdentity] = []) {
self.version = version
self.accounts = accounts
self.removedAccounts = removedAccounts
}

private enum CodingKeys: String, CodingKey {
case version
case accounts
case removedAccounts
}

init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.version = try container.decode(Int.self, forKey: .version)
self.accounts = try container.decode([StoredAccount].self, forKey: .accounts)
self.removedAccounts = try container.decodeIfPresent([RemovedAccountIdentity].self, forKey: .removedAccounts) ?? []
}
}

struct RemovedAccountIdentity: Codable, Hashable, Sendable {
let id: UUID
let emailHint: String?
let authSubject: String?
let providerAccountID: String?
let codexHomePath: String
let source: StoredAccountSource
let removedAt: Date

init(account: StoredAccount, removedAt: Date = Date()) {
self.id = UUID()
self.emailHint = account.emailHint
self.authSubject = account.authSubject
self.providerAccountID = account.providerAccountID
self.codexHomePath = account.codexHomePath
self.source = account.source
self.removedAt = removedAt
}

var normalizedProviderAccountID: String? {
StoredAccount.normalizeIdentifier(self.providerAccountID)
}

var normalizedAuthSubject: String? {
StoredAccount.normalizeIdentifier(self.authSubject)
}

var normalizedEmailHint: String? {
StoredAccount.normalizeEmail(self.emailHint)
}

var standardizedHomePath: String {
URL(fileURLWithPath: self.codexHomePath, isDirectory: true).standardizedFileURL.path
}

func matches(_ account: StoredAccount) -> Bool {
if self.standardizedHomePath == account.standardizedHomePath {
return true
}

if let normalizedProviderAccountID, let accountProviderAccountID = account.normalizedProviderAccountID {
return normalizedProviderAccountID == accountProviderAccountID
}

if self.normalizedProviderAccountID != nil || account.normalizedProviderAccountID != nil {
return false
}

if let normalizedAuthSubject, normalizedAuthSubject == account.normalizedAuthSubject {
return true
}

if let normalizedEmailHint, normalizedEmailHint == account.normalizedEmailHint {
return true
}

return false
}
}

struct AccountRuntimeState: Sendable {
Expand Down
32 changes: 27 additions & 5 deletions Sources/CodexControl/Services/AccountStore.swift
Original file line number Diff line number Diff line change
@@ -1,26 +1,41 @@
import Foundation

struct AccountStore {
private static let currentVersion = 1
private static let currentVersion = 2

func loadAccounts() throws -> [StoredAccount] {
try self.loadAccountList().accounts
}

func loadRemovedAccounts() throws -> [RemovedAccountIdentity] {
try self.loadAccountList().removedAccounts
}

func loadAccountList() throws -> StoredAccountList {
guard FileManager.default.fileExists(atPath: FileLocations.accountsFile.path) else {
return []
return StoredAccountList(version: Self.currentVersion, accounts: [])
}

let data = try Data(contentsOf: FileLocations.accountsFile)
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
let stored = try decoder.decode(StoredAccountList.self, from: data)
return self.sorted(stored.accounts)
return StoredAccountList(
version: stored.version,
accounts: self.sorted(stored.accounts),
removedAccounts: stored.removedAccounts)
}

func saveAccounts(_ accounts: [StoredAccount]) throws {
func saveAccounts(_ accounts: [StoredAccount], removedAccounts: [RemovedAccountIdentity]? = nil) throws {
try FileLocations.ensureDirectories()
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
encoder.dateEncodingStrategy = .iso8601
let data = try encoder.encode(StoredAccountList(version: Self.currentVersion, accounts: self.sorted(accounts)))
let preservedRemovedAccounts = try removedAccounts ?? self.loadRemovedAccountsIfPresent()
let data = try encoder.encode(StoredAccountList(
version: Self.currentVersion,
accounts: self.sorted(accounts),
removedAccounts: preservedRemovedAccounts))
try data.write(to: FileLocations.accountsFile, options: .atomic)
}

Expand All @@ -47,4 +62,11 @@ struct AccountStore {
return left < right
}
}

private func loadRemovedAccountsIfPresent() throws -> [RemovedAccountIdentity] {
guard FileManager.default.fileExists(atPath: FileLocations.accountsFile.path) else {
return []
}
return try self.loadRemovedAccounts()
}
}
Loading
Loading