Skip to content

Commit 54bdae4

Browse files
authored
Merge pull request #1 from ademisler/codex/fix-account-removal-refresh
[codex] Fix account removal and token recovery
2 parents 811a770 + 9011f43 commit 54bdae4

16 files changed

Lines changed: 1027 additions & 74 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,13 @@ The format is based on Keep a Changelog and this project uses Semantic Versionin
66

77
## [Unreleased]
88

9+
### Fixed
10+
11+
- Prevent removed accounts from reappearing after managed-home discovery on macOS and Windows
12+
- Recover stale managed account credentials from newer matching auth homes before surfacing refresh-token errors
13+
- Decode Plus account credit balances when the Codex usage API returns `credits.balance` as a string
14+
- Keep distinct provider account IDs separate even when accounts share the same email or auth subject
15+
916
## [1.1.3] - 2026-04-23
1017

1118
### Added

Package.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,8 @@ let package = Package(
2020
.linkedFramework("AppKit"),
2121
.linkedFramework("SwiftUI"),
2222
]),
23+
.testTarget(
24+
name: "CodexControlTests",
25+
dependencies: ["CodexControl"],
26+
path: "Tests/CodexControlTests"),
2327
])

Sources/CodexControl/App/AppModel.swift

Lines changed: 41 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ final class AppModel: ObservableObject {
2525
private let snapshotStore = SnapshotStore()
2626
private let accountManager = CodexAccountManager()
2727
private let desktopController = CodexDesktopControl()
28+
private var removedAccounts: [RemovedAccountIdentity] = []
2829
private let autoRefreshInterval: TimeInterval = 5 * 60
2930
private var autoRefreshTask: Task<Void, Never>?
3031
private var addAccountTask: Task<Void, Never>?
@@ -185,8 +186,12 @@ final class AppModel: ObservableObject {
185186
return
186187
}
187188

189+
let snapshot = self.accounts.filter { !self.requiresReauthentication(accountID: $0.id) }
190+
guard !snapshot.isEmpty else {
191+
return
192+
}
193+
188194
self.isRefreshingAll = true
189-
let snapshot = self.accounts
190195
for account in snapshot {
191196
var state = self.runtimeStates[account.id] ?? AccountRuntimeState()
192197
state.isLoading = true
@@ -260,8 +265,9 @@ final class AppModel: ObservableObject {
260265

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

288294
do {
289295
let updated = try await self.accountManager.reauthenticate(account)
296+
self.restoreRemovedAccount(updated)
290297
self.mergeAccount(updated)
291-
try self.accountStore.saveAccounts(self.accounts)
298+
try self.accountStore.saveAccounts(self.accounts, removedAccounts: self.removedAccounts)
292299
self.statusMessage = "\(updated.displayName) reauthenticated."
293300
if let refreshed = self.accounts.first(where: { $0.id == account.id }) {
294301
await self.refresh(account: refreshed)
@@ -308,7 +315,7 @@ final class AppModel: ObservableObject {
308315
let result = try self.accountManager.switchActiveAccount(account, existing: self.accounts)
309316
if let materializedAccount = result.materializedAccount {
310317
self.mergeAccount(materializedAccount)
311-
try self.accountStore.saveAccounts(self.accounts)
318+
try self.accountStore.saveAccounts(self.accounts, removedAccounts: self.removedAccounts)
312319
}
313320

314321
self.loadInitialAccounts()
@@ -395,17 +402,20 @@ final class AppModel: ObservableObject {
395402

396403
private func loadInitialAccounts() {
397404
do {
398-
let loadedAccounts = try self.accountStore.loadAccounts()
405+
let stored = try self.accountStore.loadAccountList()
406+
self.removedAccounts = stored.removedAccounts
407+
let loadedAccounts = stored.accounts.filter { !self.isRemoved($0) }
399408
let storedAccounts = loadedAccounts.filter { $0.source != .ambient }
400409
let discoveredManagedAccounts = try self.accountManager.discoverManagedAccounts(existing: loadedAccounts)
401410
var incomingAccounts = discoveredManagedAccounts
402411
if let ambientAccount = try self.accountManager.discoverAmbientAccount(existing: loadedAccounts) {
403412
incomingAccounts.insert(ambientAccount, at: 0)
404413
}
414+
incomingAccounts.removeAll { self.isRemoved($0) }
405415

406416
self.accounts = self.accountStore.merge(existing: storedAccounts, incoming: incomingAccounts)
407-
if self.accounts != loadedAccounts {
408-
try self.accountStore.saveAccounts(self.accounts)
417+
if self.accounts != stored.accounts {
418+
try self.accountStore.saveAccounts(self.accounts, removedAccounts: self.removedAccounts)
409419
}
410420
self.ensureSelection()
411421
self.refreshActiveIdentity()
@@ -420,12 +430,16 @@ final class AppModel: ObservableObject {
420430
}
421431

422432
private func remove(_ account: StoredAccount) {
423-
self.accounts.removeAll { $0.id == account.id }
433+
let removedIdentity = RemovedAccountIdentity(account: account)
434+
self.removedAccounts.removeAll { $0.matches(account) }
435+
self.removedAccounts.append(removedIdentity)
436+
437+
self.accounts.removeAll { $0.id == account.id || removedIdentity.matches($0) }
424438
self.runtimeStates.removeValue(forKey: account.id)
425439

426440
do {
427441
try self.accountManager.removeManagedFilesIfOwned(account)
428-
try self.accountStore.saveAccounts(self.accounts)
442+
try self.accountStore.saveAccounts(self.accounts, removedAccounts: self.removedAccounts)
429443
self.ensureSelection()
430444
self.statusMessage = "\(account.displayName) removed."
431445
} catch {
@@ -478,12 +492,29 @@ final class AppModel: ObservableObject {
478492

479493
private func persistAccountsSilently() {
480494
do {
481-
try self.accountStore.saveAccounts(self.accounts)
495+
try self.accountStore.saveAccounts(self.accounts, removedAccounts: self.removedAccounts)
482496
} catch {
483497
self.statusMessage = error.localizedDescription
484498
}
485499
}
486500

501+
private func isRemoved(_ account: StoredAccount) -> Bool {
502+
self.removedAccounts.contains { $0.matches(account) }
503+
}
504+
505+
private func restoreRemovedAccount(_ account: StoredAccount) {
506+
self.removedAccounts.removeAll { $0.matches(account) }
507+
}
508+
509+
private func requiresReauthentication(accountID: UUID) -> Bool {
510+
guard let message = self.runtimeStates[accountID]?.errorMessage?.lowercased() else {
511+
return false
512+
}
513+
514+
return message.contains("refresh token")
515+
&& message.contains("sign in again")
516+
}
517+
487518
private func persistSnapshotsSilently() {
488519
let snapshots = self.runtimeStates.compactMapValues(\.snapshot)
489520
do {

Sources/CodexControl/Models/AccountModels.swift

Lines changed: 94 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,17 +71,28 @@ struct StoredAccount: Codable, Identifiable, Hashable, Sendable {
7171
Self.normalizeIdentifier(self.authSubject)
7272
}
7373

74+
var normalizedProviderAccountID: String? {
75+
Self.normalizeIdentifier(self.providerAccountID)
76+
}
77+
7478
var standardizedHomePath: String {
7579
URL(fileURLWithPath: self.codexHomePath, isDirectory: true).standardizedFileURL.path
7680
}
7781

7882
func matches(_ other: StoredAccount) -> Bool {
79-
if let normalizedAuthSubject, normalizedAuthSubject == other.normalizedAuthSubject
80-
{
83+
if self.standardizedHomePath == other.standardizedHomePath {
8184
return true
8285
}
8386

84-
if self.standardizedHomePath == other.standardizedHomePath {
87+
if let normalizedProviderAccountID, let otherProviderAccountID = other.normalizedProviderAccountID {
88+
return normalizedProviderAccountID == otherProviderAccountID
89+
}
90+
91+
if self.normalizedProviderAccountID != nil || other.normalizedProviderAccountID != nil {
92+
return false
93+
}
94+
95+
if let normalizedAuthSubject, normalizedAuthSubject == other.normalizedAuthSubject {
8596
return true
8697
}
8798

@@ -163,6 +174,86 @@ struct StoredAccount: Codable, Identifiable, Hashable, Sendable {
163174
struct StoredAccountList: Codable, Sendable {
164175
let version: Int
165176
let accounts: [StoredAccount]
177+
let removedAccounts: [RemovedAccountIdentity]
178+
179+
init(version: Int, accounts: [StoredAccount], removedAccounts: [RemovedAccountIdentity] = []) {
180+
self.version = version
181+
self.accounts = accounts
182+
self.removedAccounts = removedAccounts
183+
}
184+
185+
private enum CodingKeys: String, CodingKey {
186+
case version
187+
case accounts
188+
case removedAccounts
189+
}
190+
191+
init(from decoder: Decoder) throws {
192+
let container = try decoder.container(keyedBy: CodingKeys.self)
193+
self.version = try container.decode(Int.self, forKey: .version)
194+
self.accounts = try container.decode([StoredAccount].self, forKey: .accounts)
195+
self.removedAccounts = try container.decodeIfPresent([RemovedAccountIdentity].self, forKey: .removedAccounts) ?? []
196+
}
197+
}
198+
199+
struct RemovedAccountIdentity: Codable, Hashable, Sendable {
200+
let id: UUID
201+
let emailHint: String?
202+
let authSubject: String?
203+
let providerAccountID: String?
204+
let codexHomePath: String
205+
let source: StoredAccountSource
206+
let removedAt: Date
207+
208+
init(account: StoredAccount, removedAt: Date = Date()) {
209+
self.id = UUID()
210+
self.emailHint = account.emailHint
211+
self.authSubject = account.authSubject
212+
self.providerAccountID = account.providerAccountID
213+
self.codexHomePath = account.codexHomePath
214+
self.source = account.source
215+
self.removedAt = removedAt
216+
}
217+
218+
var normalizedProviderAccountID: String? {
219+
StoredAccount.normalizeIdentifier(self.providerAccountID)
220+
}
221+
222+
var normalizedAuthSubject: String? {
223+
StoredAccount.normalizeIdentifier(self.authSubject)
224+
}
225+
226+
var normalizedEmailHint: String? {
227+
StoredAccount.normalizeEmail(self.emailHint)
228+
}
229+
230+
var standardizedHomePath: String {
231+
URL(fileURLWithPath: self.codexHomePath, isDirectory: true).standardizedFileURL.path
232+
}
233+
234+
func matches(_ account: StoredAccount) -> Bool {
235+
if self.standardizedHomePath == account.standardizedHomePath {
236+
return true
237+
}
238+
239+
if let normalizedProviderAccountID, let accountProviderAccountID = account.normalizedProviderAccountID {
240+
return normalizedProviderAccountID == accountProviderAccountID
241+
}
242+
243+
if self.normalizedProviderAccountID != nil || account.normalizedProviderAccountID != nil {
244+
return false
245+
}
246+
247+
if let normalizedAuthSubject, normalizedAuthSubject == account.normalizedAuthSubject {
248+
return true
249+
}
250+
251+
if let normalizedEmailHint, normalizedEmailHint == account.normalizedEmailHint {
252+
return true
253+
}
254+
255+
return false
256+
}
166257
}
167258

168259
struct AccountRuntimeState: Sendable {

Sources/CodexControl/Services/AccountStore.swift

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,41 @@
11
import Foundation
22

33
struct AccountStore {
4-
private static let currentVersion = 1
4+
private static let currentVersion = 2
55

66
func loadAccounts() throws -> [StoredAccount] {
7+
try self.loadAccountList().accounts
8+
}
9+
10+
func loadRemovedAccounts() throws -> [RemovedAccountIdentity] {
11+
try self.loadAccountList().removedAccounts
12+
}
13+
14+
func loadAccountList() throws -> StoredAccountList {
715
guard FileManager.default.fileExists(atPath: FileLocations.accountsFile.path) else {
8-
return []
16+
return StoredAccountList(version: Self.currentVersion, accounts: [])
917
}
1018

1119
let data = try Data(contentsOf: FileLocations.accountsFile)
1220
let decoder = JSONDecoder()
1321
decoder.dateDecodingStrategy = .iso8601
1422
let stored = try decoder.decode(StoredAccountList.self, from: data)
15-
return self.sorted(stored.accounts)
23+
return StoredAccountList(
24+
version: stored.version,
25+
accounts: self.sorted(stored.accounts),
26+
removedAccounts: stored.removedAccounts)
1627
}
1728

18-
func saveAccounts(_ accounts: [StoredAccount]) throws {
29+
func saveAccounts(_ accounts: [StoredAccount], removedAccounts: [RemovedAccountIdentity]? = nil) throws {
1930
try FileLocations.ensureDirectories()
2031
let encoder = JSONEncoder()
2132
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
2233
encoder.dateEncodingStrategy = .iso8601
23-
let data = try encoder.encode(StoredAccountList(version: Self.currentVersion, accounts: self.sorted(accounts)))
34+
let preservedRemovedAccounts = try removedAccounts ?? self.loadRemovedAccountsIfPresent()
35+
let data = try encoder.encode(StoredAccountList(
36+
version: Self.currentVersion,
37+
accounts: self.sorted(accounts),
38+
removedAccounts: preservedRemovedAccounts))
2439
try data.write(to: FileLocations.accountsFile, options: .atomic)
2540
}
2641

@@ -47,4 +62,11 @@ struct AccountStore {
4762
return left < right
4863
}
4964
}
65+
66+
private func loadRemovedAccountsIfPresent() throws -> [RemovedAccountIdentity] {
67+
guard FileManager.default.fileExists(atPath: FileLocations.accountsFile.path) else {
68+
return []
69+
}
70+
return try self.loadRemovedAccounts()
71+
}
5072
}

0 commit comments

Comments
 (0)