Skip to content

Commit db18443

Browse files
authored
Merge pull request #1349 from steipete/fix/codex-account-reset-backfill
fix: preserve Codex reset windows across account refresh
2 parents b442183 + ca5ebd8 commit db18443

14 files changed

Lines changed: 3195 additions & 80 deletions

Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift

Lines changed: 204 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,19 @@ struct CodexAccountScopedRefreshGuard: Equatable {
1313
let source: CodexActiveSource
1414
let identity: CodexIdentity
1515
let accountKey: String?
16+
let authFingerprint: String?
17+
18+
init(
19+
source: CodexActiveSource,
20+
identity: CodexIdentity,
21+
accountKey: String?,
22+
authFingerprint: String? = nil)
23+
{
24+
self.source = source
25+
self.identity = identity
26+
self.accountKey = accountKey
27+
self.authFingerprint = CodexAuthFingerprint.normalize(authFingerprint)
28+
}
1629
}
1730

1831
@MainActor
@@ -34,7 +47,7 @@ extension UsageStore {
3447
phaseDidChange?(.credits)
3548

3649
if self.settings.codexCookieSource.isEnabled {
37-
let expectedGuard = self.currentCodexOpenAIWebRefreshGuard()
50+
let expectedGuard = self.freshCodexOpenAIWebRefreshGuard()
3851
await self.refreshOpenAIDashboardIfNeeded(
3952
force: true,
4053
expectedGuard: expectedGuard,
@@ -55,13 +68,15 @@ extension UsageStore {
5568

5669
@discardableResult
5770
func prepareCodexAccountScopedRefreshIfNeeded() -> Bool {
58-
let currentGuard = self.currentCodexAccountScopedRefreshGuard(
71+
let currentGuard = self.freshCodexAccountScopedRefreshGuard(
5972
preferCurrentSnapshot: false,
6073
allowLastKnownLiveFallback: false)
6174
let previousGuard = self.lastCodexAccountScopedRefreshGuard
6275
self.lastCodexAccountScopedRefreshGuard = currentGuard
6376

64-
guard previousGuard != nil, previousGuard != currentGuard else { return false }
77+
guard let previousGuard,
78+
!Self.codexScopedRefreshGuardsMatchAccount(previousGuard, currentGuard)
79+
else { return false }
6580

6681
self.snapshots.removeValue(forKey: .codex)
6782
self.errors[.codex] = nil
@@ -106,7 +121,8 @@ extension UsageStore {
106121
self.lastCodexAccountScopedRefreshGuard = CodexAccountScopedRefreshGuard(
107122
source: resolvedSource,
108123
identity: resolvedIdentity,
109-
accountKey: accountKey)
124+
accountKey: accountKey,
125+
authFingerprint: self.currentCodexAuthFingerprint(source: resolvedSource))
110126
}
111127

112128
func currentCodexAccountScopedRefreshGuard(
@@ -121,7 +137,8 @@ extension UsageStore {
121137
allowLastKnownLiveFallback: allowLastKnownLiveFallback),
122138
accountKey: self.codexAccountScopedRefreshKey(
123139
preferCurrentSnapshot: preferCurrentSnapshot,
124-
allowLastKnownLiveFallback: allowLastKnownLiveFallback))
140+
allowLastKnownLiveFallback: allowLastKnownLiveFallback),
141+
authFingerprint: self.currentCodexAuthFingerprint(source: self.settings.codexResolvedActiveSource))
125142
}
126143

127144
func currentCodexOpenAIWebRefreshGuard() -> CodexAccountScopedRefreshGuard {
@@ -137,36 +154,77 @@ extension UsageStore {
137154
return CodexAccountScopedRefreshGuard(
138155
source: source,
139156
identity: self.currentCodexOpenAIWebIdentity(source: source),
140-
accountKey: accountKey)
157+
accountKey: accountKey,
158+
authFingerprint: self.currentCodexAuthFingerprint(source: source))
159+
}
160+
161+
func freshCodexAccountScopedRefreshGuard(
162+
preferCurrentSnapshot: Bool = true,
163+
allowLastKnownLiveFallback: Bool = true) -> CodexAccountScopedRefreshGuard
164+
{
165+
self.settings.invalidateCodexAccountReconciliationSnapshotCache()
166+
return self.currentCodexAccountScopedRefreshGuard(
167+
preferCurrentSnapshot: preferCurrentSnapshot,
168+
allowLastKnownLiveFallback: allowLastKnownLiveFallback)
169+
}
170+
171+
func freshCodexOpenAIWebRefreshGuard() -> CodexAccountScopedRefreshGuard {
172+
self.settings.invalidateCodexAccountReconciliationSnapshotCache()
173+
return self.currentCodexOpenAIWebRefreshGuard()
141174
}
142175

143176
func shouldApplyCodexUsageResult(
144177
expectedGuard: CodexAccountScopedRefreshGuard,
145178
usage: UsageSnapshot) -> Bool
146179
{
147-
let currentGuard = self.currentCodexAccountScopedRefreshGuard()
180+
let currentGuard = self.freshCodexAccountScopedRefreshGuard()
148181
guard currentGuard.source == expectedGuard.source else { return false }
182+
let fingerprintsAllowApply = Self.codexGuardAuthFingerprintAllowsUsageApply(
183+
currentGuard,
184+
expectedGuard)
185+
let expectedAuthFingerprint = CodexAuthFingerprint.normalize(expectedGuard.authFingerprint)
186+
let currentAuthFingerprint = CodexAuthFingerprint.normalize(currentGuard.authFingerprint)
187+
let canProveNilToCurrentAuth = expectedAuthFingerprint == nil && currentAuthFingerprint != nil
188+
let resultIdentity = CodexIdentityResolver.resolve(accountId: nil, email: usage.accountEmail(for: .codex))
189+
let resultAccountKey = Self.normalizeCodexAccountScopedKey(usage.accountEmail(for: .codex))
190+
let resultMatchesCurrentAccountKey = Self.codexUsageResultAccountKeyMatchesCurrentGuard(
191+
resultAccountKey,
192+
expectedGuard: expectedGuard,
193+
currentGuard: currentGuard)
149194

150195
if expectedGuard.identity != .unresolved {
151-
return currentGuard.identity == expectedGuard.identity
196+
guard currentGuard.identity == expectedGuard.identity else { return false }
197+
if fingerprintsAllowApply {
198+
guard case .managedAccount = currentGuard.source else { return true }
199+
return resultMatchesCurrentAccountKey
200+
}
201+
guard canProveNilToCurrentAuth else { return false }
202+
guard resultMatchesCurrentAccountKey else { return false }
203+
return resultIdentity == currentGuard.identity ||
204+
(resultAccountKey != nil && resultAccountKey == currentGuard.accountKey)
152205
}
153206

154-
let resultIdentity = CodexIdentityResolver.resolve(accountId: nil, email: usage.accountEmail(for: .codex))
155207
if currentGuard.identity != .unresolved {
156-
return resultIdentity == currentGuard.identity
208+
guard resultIdentity == currentGuard.identity else { return false }
209+
return fingerprintsAllowApply || canProveNilToCurrentAuth
157210
}
158211

159212
switch currentGuard.source {
160213
case .liveSystem:
161-
return resultIdentity != .unresolved
214+
guard resultIdentity != .unresolved else { return false }
215+
if fingerprintsAllowApply { return true }
216+
guard canProveNilToCurrentAuth else { return false }
217+
guard let currentAccountKey = currentGuard.accountKey else { return true }
218+
return resultAccountKey == currentAccountKey
162219
case .managedAccount:
163220
return false
164221
}
165222
}
166223

167224
func shouldApplyCodexScopedFailure(expectedGuard: CodexAccountScopedRefreshGuard) -> Bool {
168-
let currentGuard = self.currentCodexAccountScopedRefreshGuard()
225+
let currentGuard = self.freshCodexAccountScopedRefreshGuard()
169226
guard currentGuard.source == expectedGuard.source else { return false }
227+
guard Self.codexGuardAuthFingerprintMatches(currentGuard, expectedGuard) else { return false }
170228

171229
if expectedGuard.identity != .unresolved {
172230
return currentGuard.identity == expectedGuard.identity
@@ -175,9 +233,25 @@ extension UsageStore {
175233
return currentGuard.identity == .unresolved
176234
}
177235

236+
func codexScopedNonUsageSuccessApplyGuard(
237+
expectedGuard: CodexAccountScopedRefreshGuard) -> CodexAccountScopedRefreshGuard?
238+
{
239+
let currentGuard = self.freshCodexAccountScopedRefreshGuard()
240+
guard currentGuard.source == expectedGuard.source else { return nil }
241+
guard Self.codexGuardAuthFingerprintAllowsUsageApply(currentGuard, expectedGuard) else { return nil }
242+
guard expectedGuard.identity != .unresolved else { return nil }
243+
guard currentGuard.identity == expectedGuard.identity else { return nil }
244+
return currentGuard
245+
}
246+
178247
func shouldApplyCodexScopedNonUsageResult(expectedGuard: CodexAccountScopedRefreshGuard) -> Bool {
179-
let currentGuard = self.currentCodexAccountScopedRefreshGuard()
248+
self.codexScopedNonUsageSuccessApplyGuard(expectedGuard: expectedGuard) != nil
249+
}
250+
251+
func shouldApplyCodexScopedNonUsageFailure(expectedGuard: CodexAccountScopedRefreshGuard) -> Bool {
252+
let currentGuard = self.freshCodexAccountScopedRefreshGuard()
180253
guard currentGuard.source == expectedGuard.source else { return false }
254+
guard Self.codexGuardAuthFingerprintMatches(currentGuard, expectedGuard) else { return false }
181255
guard expectedGuard.identity != .unresolved else { return false }
182256
return currentGuard.identity == expectedGuard.identity
183257
}
@@ -187,8 +261,9 @@ extension UsageStore {
187261
routingTargetEmail: String?) -> Bool
188262
{
189263
let normalizedRoutingTargetEmail = CodexIdentityResolver.normalizeEmail(routingTargetEmail)
190-
let currentGuard = self.currentCodexOpenAIWebRefreshGuard()
264+
let currentGuard = self.freshCodexOpenAIWebRefreshGuard()
191265
guard currentGuard.source == expectedGuard.source else { return false }
266+
guard Self.codexGuardAuthFingerprintAllowsUsageApply(currentGuard, expectedGuard) else { return false }
192267

193268
if expectedGuard.identity != .unresolved {
194269
return currentGuard.identity == expectedGuard.identity
@@ -206,9 +281,44 @@ extension UsageStore {
206281
expectedGuard: CodexAccountScopedRefreshGuard,
207282
routingTargetEmail: String?) -> Bool
208283
{
209-
self.shouldApplyOpenAIDashboardRefreshGuard(
210-
expectedGuard: expectedGuard,
211-
routingTargetEmail: routingTargetEmail)
284+
let normalizedRoutingTargetEmail = CodexIdentityResolver.normalizeEmail(routingTargetEmail)
285+
let currentGuard = self.freshCodexOpenAIWebRefreshGuard()
286+
guard currentGuard.source == expectedGuard.source else { return false }
287+
guard Self.codexGuardAuthFingerprintMatches(currentGuard, expectedGuard) else { return false }
288+
289+
if expectedGuard.identity != .unresolved {
290+
return currentGuard.identity == expectedGuard.identity
291+
}
292+
293+
guard case .liveSystem = expectedGuard.source else { return false }
294+
guard currentGuard.identity == .unresolved else { return false }
295+
return CodexIdentityResolver.normalizeEmail(
296+
self.currentCodexOpenAIWebTargetEmail(
297+
allowCurrentSnapshotFallback: true,
298+
allowLastKnownLiveFallback: false)) == normalizedRoutingTargetEmail
299+
}
300+
301+
func shouldApplyOpenAIDashboardPolicyResult(
302+
expectedGuard: CodexAccountScopedRefreshGuard,
303+
routingTargetEmail: String?) -> Bool
304+
{
305+
let normalizedRoutingTargetEmail = CodexIdentityResolver.normalizeEmail(routingTargetEmail)
306+
let currentGuard = self.freshCodexOpenAIWebRefreshGuard()
307+
guard currentGuard.source == expectedGuard.source else { return false }
308+
309+
if expectedGuard.identity != .unresolved {
310+
guard currentGuard.identity == expectedGuard.identity else { return false }
311+
return Self.codexGuardAuthFingerprintMatches(currentGuard, expectedGuard) ||
312+
Self.codexGuardAuthFingerprintAllowsSameProviderAccount(currentGuard, expectedGuard)
313+
}
314+
315+
guard case .liveSystem = expectedGuard.source else { return false }
316+
guard currentGuard.identity == .unresolved else { return false }
317+
guard Self.codexGuardAuthFingerprintMatches(currentGuard, expectedGuard) else { return false }
318+
return CodexIdentityResolver.normalizeEmail(
319+
self.currentCodexOpenAIWebTargetEmail(
320+
allowCurrentSnapshotFallback: true,
321+
allowLastKnownLiveFallback: false)) == normalizedRoutingTargetEmail
212322
}
213323

214324
func codexDashboardKnownOwnerCandidates() -> [CodexDashboardKnownOwnerCandidate] {
@@ -277,6 +387,83 @@ extension UsageStore {
277387
self.lastKnownLiveSystemCodexEmail = normalized
278388
}
279389

390+
nonisolated static func codexGuardAuthFingerprintMatches(
391+
_ lhs: CodexAccountScopedRefreshGuard,
392+
_ rhs: CodexAccountScopedRefreshGuard) -> Bool
393+
{
394+
let lhsFingerprint = CodexAuthFingerprint.normalize(lhs.authFingerprint)
395+
let rhsFingerprint = CodexAuthFingerprint.normalize(rhs.authFingerprint)
396+
if lhsFingerprint != nil || rhsFingerprint != nil {
397+
return lhsFingerprint == rhsFingerprint
398+
}
399+
return true
400+
}
401+
402+
nonisolated static func codexGuardAuthFingerprintAllowsUsageApply(
403+
_ lhs: CodexAccountScopedRefreshGuard,
404+
_ rhs: CodexAccountScopedRefreshGuard) -> Bool
405+
{
406+
if self.codexGuardAuthFingerprintMatches(lhs, rhs) {
407+
return true
408+
}
409+
let lhsFingerprint = CodexAuthFingerprint.normalize(lhs.authFingerprint)
410+
let rhsFingerprint = CodexAuthFingerprint.normalize(rhs.authFingerprint)
411+
guard lhsFingerprint != nil, rhsFingerprint != nil else { return false }
412+
guard case .providerAccount = rhs.identity, lhs.identity == rhs.identity else { return false }
413+
guard case .liveSystem = lhs.source else { return true }
414+
return lhs.accountKey != nil && lhs.accountKey == rhs.accountKey
415+
}
416+
417+
private nonisolated static func codexGuardAuthFingerprintAllowsSameProviderAccount(
418+
_ lhs: CodexAccountScopedRefreshGuard,
419+
_ rhs: CodexAccountScopedRefreshGuard) -> Bool
420+
{
421+
let lhsFingerprint = CodexAuthFingerprint.normalize(lhs.authFingerprint)
422+
let rhsFingerprint = CodexAuthFingerprint.normalize(rhs.authFingerprint)
423+
guard lhsFingerprint != nil, rhsFingerprint != nil else { return false }
424+
guard case .providerAccount = rhs.identity else { return false }
425+
return lhs.identity == rhs.identity
426+
}
427+
428+
nonisolated static func codexScopedRefreshGuardsMatchAccount(
429+
_ lhs: CodexAccountScopedRefreshGuard,
430+
_ rhs: CodexAccountScopedRefreshGuard) -> Bool
431+
{
432+
guard lhs.source == rhs.source else { return false }
433+
if lhs == rhs { return true }
434+
guard lhs.identity != .unresolved,
435+
lhs.identity == rhs.identity,
436+
lhs.accountKey == rhs.accountKey
437+
else {
438+
return false
439+
}
440+
return self.codexGuardAuthFingerprintAllowsUsageApply(lhs, rhs)
441+
}
442+
443+
private nonisolated static func codexUsageResultAccountKeyMatchesCurrentGuard(
444+
_ resultAccountKey: String?,
445+
expectedGuard: CodexAccountScopedRefreshGuard,
446+
currentGuard: CodexAccountScopedRefreshGuard) -> Bool
447+
{
448+
guard let currentAccountKey = currentGuard.accountKey else { return true }
449+
guard let resultAccountKey else {
450+
guard let expectedAccountKey = expectedGuard.accountKey else { return true }
451+
return expectedAccountKey == currentAccountKey
452+
}
453+
return resultAccountKey == currentAccountKey
454+
}
455+
456+
func currentCodexAuthFingerprint(source: CodexActiveSource) -> String? {
457+
let snapshot = self.settings.codexAccountReconciliationSnapshot
458+
switch source {
459+
case .liveSystem:
460+
return CodexAuthFingerprint.normalize(snapshot.liveSystemAccount?.authFingerprint)
461+
case let .managedAccount(id):
462+
guard let account = snapshot.storedAccounts.first(where: { $0.id == id }) else { return nil }
463+
return CodexAuthFingerprint.fingerprint(homePath: account.managedHomePath)
464+
}
465+
}
466+
280467
func codexAccountScopedRefreshKey(
281468
preferCurrentSnapshot: Bool = true,
282469
allowLastKnownLiveFallback: Bool = true) -> String?

Sources/CodexBar/Providers/Codex/UsageStore+CodexRefresh.swift

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ extension UsageStore {
1515

1616
func scheduleCreditsRefreshIfNeeded(minimumSnapshotUpdatedAt: Date? = nil) {
1717
let refreshKey = self.codexCreditsRefreshKey(
18-
expectedGuard: self.currentCodexAccountScopedRefreshGuard())
18+
expectedGuard: self.freshCodexAccountScopedRefreshGuard())
1919
if let existing = self.creditsRefreshTask,
2020
!existing.isCancelled,
2121
self.creditsRefreshTaskKey == refreshKey
@@ -71,18 +71,19 @@ extension UsageStore {
7171
sourceKey,
7272
identityKey,
7373
expectedGuard.accountKey ?? "account:nil",
74+
"auth:\(expectedGuard.authFingerprint ?? "nil")",
7475
].joined(separator: "|")
7576
}
7677

7778
func refreshCreditsIfNeeded(minimumSnapshotUpdatedAt: Date? = nil) async {
7879
guard self.isEnabled(.codex) else { return }
79-
var expectedGuard = self.currentCodexAccountScopedRefreshGuard()
80+
var expectedGuard = self.freshCodexAccountScopedRefreshGuard()
8081
if expectedGuard.identity == .unresolved,
8182
let minimumSnapshotUpdatedAt,
8283
case .liveSystem = expectedGuard.source
8384
{
8485
_ = await self.waitForCodexSnapshotOrRefreshCompletion(minimumUpdatedAt: minimumSnapshotUpdatedAt)
85-
expectedGuard = self.currentCodexAccountScopedRefreshGuard()
86+
expectedGuard = self.freshCodexAccountScopedRefreshGuard()
8687
}
8788
guard expectedGuard.identity != .unresolved,
8889
expectedGuard.accountKey != nil
@@ -92,15 +93,16 @@ extension UsageStore {
9293
do {
9394
let credits = try await self.loadLatestCodexCredits()
9495
guard !Task.isCancelled else { return }
95-
guard self.shouldApplyCodexScopedNonUsageResult(expectedGuard: expectedGuard) else { return }
96+
guard let applyGuard = self.codexScopedNonUsageSuccessApplyGuard(
97+
expectedGuard: expectedGuard) else { return }
9698
await MainActor.run {
9799
self.credits = credits
98100
self.lastCreditsError = nil
99101
self.lastCreditsSnapshot = credits
100-
self.lastCreditsSnapshotAccountKey = expectedGuard.accountKey
102+
self.lastCreditsSnapshotAccountKey = applyGuard.accountKey
101103
self.lastCreditsSource = .api
102104
self.creditsFailureStreak = 0
103-
self.lastCodexAccountScopedRefreshGuard = expectedGuard
105+
self.lastCodexAccountScopedRefreshGuard = applyGuard
104106
}
105107
let codexSnapshot = await MainActor.run {
106108
self.snapshots[.codex]
@@ -123,7 +125,7 @@ extension UsageStore {
123125
guard !Task.isCancelled else { return }
124126
let message = error.localizedDescription
125127
if message.localizedCaseInsensitiveContains("data not available yet") {
126-
guard self.shouldApplyCodexScopedNonUsageResult(expectedGuard: expectedGuard) else { return }
128+
guard self.shouldApplyCodexScopedNonUsageFailure(expectedGuard: expectedGuard) else { return }
127129
await MainActor.run {
128130
if let cached = self.lastCreditsSnapshot,
129131
self.lastCreditsSnapshotAccountKey == expectedGuard.accountKey
@@ -140,7 +142,7 @@ extension UsageStore {
140142
return
141143
}
142144

143-
guard self.shouldApplyCodexScopedNonUsageResult(expectedGuard: expectedGuard) else { return }
145+
guard self.shouldApplyCodexScopedNonUsageFailure(expectedGuard: expectedGuard) else { return }
144146
await MainActor.run {
145147
self.creditsFailureStreak += 1
146148
if let cached = self.lastCreditsSnapshot,

0 commit comments

Comments
 (0)