Skip to content

Commit d0f4302

Browse files
committed
Persist codex auth files and track codexHomePath
Add codexHomePath to CodexAccount and include it in coding/initialization so accounts can reference a local Codex home. Extend AccountStore with codexHomesDirectory and codexHomeURL(for:) to compute stable per-account home directories. Add makeAuthJSON(...) and persistAuthFile(...) in CodexAPIService to generate a normalized auth.json (pretty/sorted JSON with ISO8601 last_refresh) and write it to the account's codexHomePath (attempts to set 0o600 permissions). Update token refresh result to populate codexHomePath and codexAuthJSON. Update AccountsViewModel to normalize loaded accounts (parse token claims for canonical email/accountId), assign codexHomePath, generate/persist auth JSON on load, deduplicate duplicate accounts preferring healthy/recent validations, and always operate on normalized/canonical accounts when merging or importing. Minor fixes: expose storeDirectory as non-private and persist accounts after setup. These changes ensure local auth files are maintained and account identities are normalized/deduplicated on load.
1 parent da58502 commit d0f4302

6 files changed

Lines changed: 154 additions & 19 deletions

File tree

CLAUDE.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,14 +40,15 @@ Always report build status explicitly (`BUILD SUCCEEDED` or failure reason) in h
4040

4141
### Key Data Flow
4242

43-
**Startup:** `viewModel.setup()``AccountStore.load()` → start auto-refresh timer + token keep-alive (every 10 min)
43+
**Startup:** `viewModel.setup()``AccountStore.load()`normalize each account into its isolated Codex home → start auto-refresh timer + token keep-alive
4444

4545
**Refresh cycle:** `refreshAccount()``CodexAPIService.fetchUsageWithRefresh()` → on 401, auto-calls `refreshToken()` → updates `usageData`/`accountStatuses` → persists tokens
4646

47-
**Add account:** `startAddingAccount()` starts `AuthFileWatcher` polling `~/.codex/auth.json` every 2s → detects modification after `codex auth` → parses JWT claims via `JWTParser` → saves to `AccountStore`
47+
**Add account:** `startAddingAccount()` starts `AuthFileWatcher` polling isolated import home `~/.codex-accounts-import/auth.json` every 2s → detects modification after `codex login` → parses JWT claims via `JWTParser` → saves to `AccountStore` → writes canonical auth to `~/Library/Application Support/CodexAccounts/CodexHomes/<account>/auth.json`
4848

4949
### Persistence
5050
- Accounts: `~/Library/Application Support/CodexAccounts/accounts.json` (atomic writes)
51+
- Per-account Codex homes: `~/Library/Application Support/CodexAccounts/CodexHomes/<account>/auth.json` (canonical auth file per tracked account)
5152
- Preferences (sort mode, display mode, refresh interval): `UserDefaults` as **stored** `var` properties with `didSet` — never computed getters (breaks `@Observable` reactivity)
5253
- Watched file: `~/.codex/auth.json` (read-only)
5354

CodexAccounts/Models/CodexAccount.swift

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ struct CodexAccount: Identifiable, Codable, Hashable {
7171
var refreshToken: String
7272
var idToken: String?
7373
var accountId: String?
74+
var codexHomePath: String?
7475
var codexAuthJSON: String?
7576
var lastTokenRefresh: Date?
7677
var lastSuccessfulUsageAt: Date?
@@ -92,7 +93,7 @@ struct CodexAccount: Identifiable, Codable, Hashable {
9293

9394
enum CodingKeys: String, CodingKey {
9495
case id, email, planType, accessToken, refreshToken
95-
case idToken, accountId, codexAuthJSON, lastTokenRefresh
96+
case idToken, accountId, codexHomePath, codexAuthJSON, lastTokenRefresh
9697
case lastSuccessfulUsageAt, lastSuccessfulTokenRefreshAt
9798
case lastRefreshAttemptAt, lastRefreshFailureAt
9899
case consecutiveRefreshFailures, authState
@@ -110,6 +111,7 @@ struct CodexAccount: Identifiable, Codable, Hashable {
110111
refreshToken: String,
111112
idToken: String? = nil,
112113
accountId: String? = nil,
114+
codexHomePath: String? = nil,
113115
codexAuthJSON: String? = nil,
114116
lastTokenRefresh: Date? = nil,
115117
lastSuccessfulUsageAt: Date? = nil,
@@ -136,6 +138,7 @@ struct CodexAccount: Identifiable, Codable, Hashable {
136138
self.refreshToken = refreshToken
137139
self.idToken = idToken
138140
self.accountId = accountId
141+
self.codexHomePath = codexHomePath
139142
self.codexAuthJSON = codexAuthJSON
140143
self.lastTokenRefresh = lastTokenRefresh
141144
self.lastSuccessfulUsageAt = lastSuccessfulUsageAt
@@ -165,6 +168,7 @@ struct CodexAccount: Identifiable, Codable, Hashable {
165168
refreshToken = try c.decode(String.self, forKey: .refreshToken)
166169
idToken = try c.decodeIfPresent(String.self, forKey: .idToken)
167170
accountId = try c.decodeIfPresent(String.self, forKey: .accountId)
171+
codexHomePath = try c.decodeIfPresent(String.self, forKey: .codexHomePath)
168172
codexAuthJSON = try c.decodeIfPresent(String.self, forKey: .codexAuthJSON)
169173
lastTokenRefresh = try c.decodeIfPresent(Date.self, forKey: .lastTokenRefresh)
170174
lastSuccessfulUsageAt = try c.decodeIfPresent(Date.self, forKey: .lastSuccessfulUsageAt)

CodexAccounts/Services/AccountStore.swift

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,31 @@
88
import Foundation
99

1010
enum AccountStore {
11-
private static var storeDirectory: URL {
11+
static var storeDirectory: URL {
1212
let appSupport = FileManager.default.urls(
1313
for: .applicationSupportDirectory,
1414
in: .userDomainMask
1515
).first!
1616
return appSupport.appendingPathComponent("CodexAccounts")
1717
}
1818

19+
static var codexHomesDirectory: URL {
20+
storeDirectory.appendingPathComponent("CodexHomes")
21+
}
22+
1923
private static var storeURL: URL {
2024
storeDirectory.appendingPathComponent("accounts.json")
2125
}
2226

27+
static func codexHomeURL(for account: CodexAccount) -> URL {
28+
let stableID = account.accountId?.isEmpty == false ? account.accountId! : account.id
29+
let safeID = stableID
30+
.replacingOccurrences(of: "/", with: "_")
31+
.replacingOccurrences(of: ":", with: "_")
32+
.replacingOccurrences(of: " ", with: "_")
33+
return codexHomesDirectory.appendingPathComponent(safeID, isDirectory: true)
34+
}
35+
2336
static func load() -> [CodexAccount] {
2437
guard let data = try? Data(contentsOf: storeURL) else { return [] }
2538
let decoder = JSONDecoder()

CodexAccounts/Services/CodexAPIService.swift

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,9 +347,56 @@ enum CodexAPIService {
347347
updated.lastRefreshFailureAt = nil
348348
updated.consecutiveRefreshFailures = 0
349349
updated.authState = .healthy
350+
updated.codexAuthJSON = makeAuthJSON(for: updated, lastRefresh: now)
351+
persistAuthFile(for: updated)
350352
return updated
351353
}
352354

355+
static func persistAuthFile(for account: CodexAccount) {
356+
guard let codexHomePath = account.codexHomePath, !codexHomePath.isEmpty else { return }
357+
358+
let authURL = URL(fileURLWithPath: codexHomePath).appendingPathComponent("auth.json")
359+
do {
360+
try FileManager.default.createDirectory(
361+
at: authURL.deletingLastPathComponent(),
362+
withIntermediateDirectories: true
363+
)
364+
let authJSON = account.codexAuthJSON ?? makeAuthJSON(for: account)
365+
try authJSON.write(to: authURL, atomically: true, encoding: .utf8)
366+
try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: authURL.path)
367+
} catch {
368+
print("CodexAPIService: Failed to persist auth file: \(error)")
369+
}
370+
}
371+
372+
static func makeAuthJSON(for account: CodexAccount, lastRefresh: Date? = nil) -> String {
373+
var tokens: [String: Any] = [
374+
"access_token": account.accessToken,
375+
"refresh_token": account.refreshToken,
376+
]
377+
if let idToken = account.idToken, !idToken.isEmpty {
378+
tokens["id_token"] = idToken
379+
}
380+
if let accountId = account.accountId, !accountId.isEmpty {
381+
tokens["account_id"] = accountId
382+
}
383+
384+
let formatter = ISO8601DateFormatter()
385+
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
386+
let auth: [String: Any] = [
387+
"auth_mode": "chatgpt",
388+
"tokens": tokens,
389+
"last_refresh": formatter.string(from: lastRefresh ?? account.lastTokenRefresh ?? Date()),
390+
]
391+
392+
guard let data = try? JSONSerialization.data(withJSONObject: auth, options: [.prettyPrinted, .sortedKeys]),
393+
let text = String(data: data, encoding: .utf8)
394+
else {
395+
return "{\"auth_mode\":\"chatgpt\",\"tokens\":{}}\n"
396+
}
397+
return text + "\n"
398+
}
399+
353400
private static func responseErrorText(from data: Data) -> String? {
354401
guard !data.isEmpty else { return nil }
355402

@@ -459,6 +506,7 @@ enum CodexAPIService {
459506
refreshToken: refreshToken,
460507
idToken: tokens.idToken,
461508
accountId: tokens.accountId ?? claims.accountId,
509+
codexHomePath: codexHome,
462510
codexAuthJSON: String(data: data, encoding: .utf8),
463511
lastTokenRefresh: lastRefresh,
464512
lastSuccessfulTokenRefreshAt: lastRefresh,

CodexAccounts/ViewModels/AccountsViewModel.swift

Lines changed: 79 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -318,13 +318,14 @@ final class AccountsViewModel {
318318
guard !hasSetup else { return }
319319
hasSetup = true
320320

321-
accounts = AccountStore.load().map(normalizedAccountOnLoad)
321+
accounts = deduplicatedAccounts(AccountStore.load().map(normalizedAccountOnLoad))
322322
normalizePinnedOrder()
323323
applyAccountStates()
324+
persistAccounts()
324325
syncCurrentAuthFile()
325326

326327
if accounts.isEmpty, let account = CodexAPIService.readAuthFile() {
327-
accounts.append(account)
328+
accounts.append(normalizedAccountOnLoad(account))
328329
persistAccounts()
329330
detectedUntrackedEmail = nil
330331
}
@@ -594,12 +595,14 @@ final class AccountsViewModel {
594595
guard let authAccount = CodexAPIService.readAuthFile() else { return }
595596
let existingIds = Set(accounts.map(\.id))
596597

597-
if !existingIds.contains(authAccount.id) {
598+
let canonicalAuthAccount = normalizedAccountOnLoad(authAccount)
599+
600+
if !existingIds.contains(canonicalAuthAccount.id) {
598601
detectedUntrackedEmail = authAccount.email
599602
return
600603
}
601604

602-
let merged = mergeAuthSnapshot(authAccount)
605+
let merged = mergeAuthSnapshot(canonicalAuthAccount)
603606
detectedUntrackedEmail = nil
604607

605608
if trigger == .authFileSync {
@@ -683,12 +686,13 @@ final class AccountsViewModel {
683686

684687
func addDetectedAccount() {
685688
guard let account = CodexAPIService.readAuthFile() else { return }
686-
if !accounts.contains(where: { $0.id == account.id }) {
687-
accounts.append(account)
689+
let canonicalAccount = normalizedAccountOnLoad(account)
690+
if !accounts.contains(where: { $0.id == canonicalAccount.id }) {
691+
accounts.append(canonicalAccount)
688692
persistAccounts()
689-
Task { await refreshAccount(account, trigger: .authFileSync) }
693+
Task { await refreshAccount(canonicalAccount, trigger: .authFileSync) }
690694
} else {
691-
_ = mergeAuthSnapshot(account)
695+
_ = mergeAuthSnapshot(canonicalAccount)
692696
}
693697
detectedUntrackedEmail = nil
694698
}
@@ -752,10 +756,11 @@ final class AccountsViewModel {
752756
private func handleAddAccountAuthFileChange() {
753757
Task {
754758
try? await Task.sleep(for: .milliseconds(500))
755-
guard let account = CodexAPIService.readAuthFile(codexHome: importCodexHome) else {
759+
guard let importedAccount = CodexAPIService.readAuthFile(codexHome: importCodexHome) else {
756760
addAccountStatus = .error("Could not read auth file. Try again.")
757761
return
758762
}
763+
let account = normalizedAccountOnLoad(importedAccount)
759764

760765
if let pendingReauthAccountID,
761766
pendingReauthAccountID != account.id,
@@ -1382,6 +1387,44 @@ final class AccountsViewModel {
13821387

13831388
private func normalizedAccountOnLoad(_ account: CodexAccount) -> CodexAccount {
13841389
var normalized = account
1390+
if let claims = JWTParser.parse(normalized.idToken ?? normalized.accessToken),
1391+
let tokenEmail = claims.email,
1392+
tokenEmail.caseInsensitiveCompare(normalized.email) != .orderedSame
1393+
{
1394+
normalized = CodexAccount(
1395+
email: tokenEmail,
1396+
planType: claims.planType ?? normalized.planType,
1397+
accessToken: normalized.accessToken,
1398+
refreshToken: normalized.refreshToken,
1399+
idToken: normalized.idToken,
1400+
accountId: claims.accountId ?? normalized.accountId,
1401+
codexHomePath: normalized.codexHomePath,
1402+
codexAuthJSON: normalized.codexAuthJSON,
1403+
lastTokenRefresh: normalized.lastTokenRefresh,
1404+
lastSuccessfulUsageAt: normalized.lastSuccessfulUsageAt,
1405+
lastSuccessfulTokenRefreshAt: normalized.lastSuccessfulTokenRefreshAt,
1406+
lastRefreshAttemptAt: normalized.lastRefreshAttemptAt,
1407+
lastRefreshFailureAt: normalized.lastRefreshFailureAt,
1408+
consecutiveRefreshFailures: normalized.consecutiveRefreshFailures,
1409+
authState: normalized.authState,
1410+
addedAt: normalized.addedAt,
1411+
isPinned: normalized.isPinned,
1412+
pinnedOrder: normalized.pinnedOrder,
1413+
weeklyAutoKickOverride: normalized.weeklyAutoKickOverride,
1414+
lastObservedWeeklyResetAt: normalized.lastObservedWeeklyResetAt,
1415+
lastWeeklyAutoKickCycleID: normalized.lastWeeklyAutoKickCycleID,
1416+
lastWeeklyAutoKickAttemptAt: normalized.lastWeeklyAutoKickAttemptAt,
1417+
lastWeeklyAutoKickSuccessAt: normalized.lastWeeklyAutoKickSuccessAt,
1418+
lastWeeklyAutoKickFailure: normalized.lastWeeklyAutoKickFailure,
1419+
weeklyAutoKickAttemptCount: normalized.weeklyAutoKickAttemptCount
1420+
)
1421+
}
1422+
let codexHomeURL = AccountStore.codexHomeURL(for: normalized)
1423+
normalized.codexHomePath = codexHomeURL.path
1424+
if normalized.codexAuthJSON == nil {
1425+
normalized.codexAuthJSON = CodexAPIService.makeAuthJSON(for: normalized)
1426+
}
1427+
CodexAPIService.persistAuthFile(for: normalized)
13851428
if normalized.lastSuccessfulTokenRefreshAt == nil {
13861429
normalized.lastSuccessfulTokenRefreshAt = normalized.lastTokenRefresh
13871430
}
@@ -1395,6 +1438,32 @@ final class AccountsViewModel {
13951438
return normalized
13961439
}
13971440

1441+
private func deduplicatedAccounts(_ loadedAccounts: [CodexAccount]) -> [CodexAccount] {
1442+
var byID: [String: CodexAccount] = [:]
1443+
for account in loadedAccounts {
1444+
guard let existing = byID[account.id] else {
1445+
byID[account.id] = account
1446+
continue
1447+
}
1448+
byID[account.id] = preferredAccount(existing, account)
1449+
}
1450+
return loadedAccounts.compactMap { account in
1451+
guard byID[account.id]?.addedAt == account.addedAt else { return nil }
1452+
defer { byID.removeValue(forKey: account.id) }
1453+
return byID[account.id]
1454+
}
1455+
}
1456+
1457+
private func preferredAccount(_ lhs: CodexAccount, _ rhs: CodexAccount) -> CodexAccount {
1458+
if lhs.authState != rhs.authState {
1459+
if lhs.authState == .healthy { return lhs }
1460+
if rhs.authState == .healthy { return rhs }
1461+
}
1462+
let lhsDate = lhs.lastAuthValidationAt ?? lhs.addedAt
1463+
let rhsDate = rhs.lastAuthValidationAt ?? rhs.addedAt
1464+
return lhsDate >= rhsDate ? lhs : rhs
1465+
}
1466+
13981467
private func syncWeeklyObservation(for accountID: String, usage: AccountUsage) {
13991468
guard let idx = accounts.firstIndex(where: { $0.id == accountID }) else { return }
14001469

@@ -1560,6 +1629,7 @@ final class AccountsViewModel {
15601629
refreshToken: account.refreshToken,
15611630
idToken: account.idToken,
15621631
accountId: account.accountId,
1632+
codexHomePath: account.codexHomePath ?? existing.codexHomePath,
15631633
codexAuthJSON: account.codexAuthJSON ?? existing.codexAuthJSON,
15641634
lastTokenRefresh: account.lastTokenRefresh,
15651635
lastSuccessfulUsageAt: account.lastSuccessfulUsageAt,

README.md

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ The Codex CLI has two rate limits: a rolling 5-hour window and a weekly window.
88

99
Codex Accounts shows the lowest remaining percentage across all your accounts directly in the menu bar. Click the icon to open a popover with a card for each account. Each card has both usage meters with time-to-reset countdowns, credit balance if your plan has one, and the plan type.
1010

11-
The app watches `~/.codex/auth.json`. When you run `codex auth` to switch accounts, it picks up the new account automatically. Tokens are refreshed proactively in the background and again on-demand if a request returns unauthorized.
11+
The app imports Codex CLI logins, then keeps each account in its own isolated Codex home under `~/Library/Application Support/CodexAccounts/CodexHomes/`. Tokens are refreshed proactively in the background and again on-demand if a request returns unauthorized.
1212

1313
## Requirements
1414

@@ -89,20 +89,19 @@ Output is written to `dist/`.
8989

9090
## Adding accounts
9191

92-
The app detects your current account from `~/.codex/auth.json` on launch. To add another, click "Add Account" in the popover, then in a terminal run:
92+
The app detects your current account from `~/.codex/auth.json` on launch. To add another, click "Add Account" in the popover, then sign in through the isolated Codex login window.
9393

9494
```
95-
codex logout
96-
codex auth
95+
CODEX_HOME="$HOME/.codex-accounts-import" codex login
9796
```
9897

99-
The app watches the auth file and adds the new account automatically.
98+
The app watches the import auth file, adds the account automatically, then copies it into that account's dedicated Codex home.
10099

101100
## Privacy
102101

103102
The only network requests are to `chatgpt.com/backend-api/wham/usage` to fetch usage numbers and `auth.openai.com/oauth/token` to refresh tokens when they expire. No analytics, no telemetry.
104103

105-
Account data (including tokens) is stored locally in `~/Library/Application Support/CodexAccounts/accounts.json`. Nothing else leaves your machine.
104+
Account data (including tokens) is stored locally in `~/Library/Application Support/CodexAccounts/accounts.json` and each account's isolated `auth.json` under `~/Library/Application Support/CodexAccounts/CodexHomes/`. Nothing else leaves your machine.
106105

107106
## License
108107

0 commit comments

Comments
 (0)