Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
08ff28a
Harden Codex OAuth source and refresh lifecycle
Yuxin-Qiao Aug 15, 2026
e79d789
Harden managed Codex account writes
Yuxin-Qiao Aug 15, 2026
ca7486d
Recover Codex account identity from JWT claims
Yuxin-Qiao Aug 15, 2026
032f224
Cover direct Codex JWT account claims
Yuxin-Qiao Aug 15, 2026
4db8972
Keep shared Codex auth read-only during refresh
Yuxin-Qiao Aug 15, 2026
921ac42
Preserve explicit OAuth recovery
Yuxin-Qiao Aug 15, 2026
666a72b
Gate native OAuth CLI recovery
Yuxin-Qiao Aug 15, 2026
b56b052
Merge remote-tracking branch 'origin/main' into codex/codex-oauth-har…
Yuxin-Qiao Aug 15, 2026
5cf3102
Keep Codex OAuth refresh in memory
Yuxin-Qiao Aug 15, 2026
b0c62e8
Keep shared OAuth refresh owner-safe
Yuxin-Qiao Aug 15, 2026
c7ab5e5
Align OAuth guide with read-only auth
Yuxin-Qiao Aug 15, 2026
47d03be
Skip blank JWT organization IDs
Yuxin-Qiao Aug 15, 2026
15acee5
Treat whitespace account IDs as absent
Yuxin-Qiao Aug 15, 2026
273c9a4
Align OAuth guide with recovery flow
Yuxin-Qiao Aug 15, 2026
87b2b36
Remove stale OAuth refresh examples
Yuxin-Qiao Aug 16, 2026
d58b1be
Preserve OAuth snapshot across credit enrichment
Yuxin-Qiao Aug 16, 2026
7204480
Clarify private workspace metadata boundary
Yuxin-Qiao Aug 16, 2026
91ed021
Fail closed CLI recovery for managed workspace scope
Yuxin-Qiao Aug 16, 2026
a29973f
fix: keep Codex auth read-only during refresh
steipete Aug 16, 2026
04766fd
Merge remote-tracking branch 'origin/main' into fix/2944-codex-auth-r…
steipete Aug 16, 2026
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
- Usage bars: make workday tick marks configurable with hidden, subtle, and high-contrast appearances (#2904, #2950). Thanks @dstier-git!

### Fixed
- Codex: keep CLI-owned `auth.json` read-only during usage refresh, delegate stale native credentials to CLI recovery, and fail closed for stale external OAuth files (#2944). Thanks @Yuxin-Qiao!
- Usage & Spend: keep safely priced Codex totals visible after completed history scans when request-tier uncertainty leaves some days unpriced (#2948). Thanks @Atopoz for the report!
- Vertex AI: match Cloud Monitoring quota usage without a `limit_name` to its unambiguous same-metric, same-location limit, restoring quota percentages (#2958). Thanks @MachApple!
- Cursor: rename the included-usage split to Cursor and Third Party across menu, widgets, and menu-bar windows, matching Cursor's dashboard labels (#2951). Thanks @baanish!
Expand Down
16 changes: 0 additions & 16 deletions Sources/CodexBar/ManagedCodexAccountService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -364,7 +364,6 @@ final class ManagedCodexAccountService {
else {
throw ManagedCodexAccountServiceError.workspaceSelectionCancelled
}
try self.persistSelectedWorkspaceID(selected.workspaceAccountID, homePath: homePath)
return selected
}

Expand All @@ -378,21 +377,6 @@ final class ManagedCodexAccountService {
providerAccountID: providerAccountID)
}

private func persistSelectedWorkspaceID(_ workspaceID: String, homePath: String) throws {
let env = CodexHomeScope.scopedEnvironment(
base: ProcessInfo.processInfo.environment,
codexHome: homePath)
let credentials = try CodexOAuthCredentialsStore.load(env: env)
try CodexOAuthCredentialsStore.save(
CodexOAuthCredentials(
accessToken: credentials.accessToken,
refreshToken: credentials.refreshToken,
idToken: credentials.idToken,
accountId: workspaceID,
lastRefresh: credentials.lastRefresh),
env: env)
}

private func reconciledExistingAccount(
authenticatedEmail: String,
providerAccountID: String?,
Expand Down
16 changes: 16 additions & 0 deletions Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ struct CodexProviderImplementation: ProviderImplementation {
_ = settings.codexUsageDataSource
_ = settings.codexCookieSource
_ = settings.codexCookieHeader
_ = settings.codexExternalOAuthSourcesAllowed
}

@MainActor
Expand Down Expand Up @@ -132,6 +133,21 @@ struct CodexProviderImplementation: ProviderImplementation {
onChange: nil,
onAppDidBecomeActive: nil,
onAppearWhenEnabled: nil),
ProviderSettingsToggleDescriptor(
id: "codex-external-oauth-sources",
title: "External Codex OAuth sources",
subtitle: [
"Explicitly allow read-only fallback to legacy Codex and OpenCode OAuth files.",
"CodexBar never refreshes or writes those external credentials.",
"Off by default because this shares another app's OAuth session with Codex usage requests.",
].joined(separator: " "),
binding: context.boolBinding(\.codexExternalOAuthSourcesAllowed),
statusText: nil,
actions: [],
isVisible: nil,
onChange: nil,
onAppDidBecomeActive: nil,
onAppearWhenEnabled: nil),
ProviderSettingsToggleDescriptor(
id: "codex-openai-web-battery-saver",
title: "OpenAI web battery saver",
Expand Down
3 changes: 2 additions & 1 deletion Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -696,7 +696,8 @@ extension SettingsStore {
cookieSource: self.codexSnapshotCookieSource(tokenOverride: tokenOverride),
manualCookieHeader: self.codexSnapshotCookieHeader(tokenOverride: tokenOverride),
reconciliationSnapshot: reconciliationSnapshot,
resolvedActiveSource: resolvedActiveSource))
resolvedActiveSource: resolvedActiveSource,
allowExternalOAuthSources: self.codexExternalOAuthSourcesAllowed))
}

private static func codexUsageDataSource(from source: ProviderSourceMode?) -> CodexUsageDataSource {
Expand Down
9 changes: 9 additions & 0 deletions Sources/CodexBar/SettingsStore+Defaults.swift
Original file line number Diff line number Diff line change
Expand Up @@ -723,6 +723,15 @@ extension SettingsStore {
}
}

var codexExternalOAuthSourcesAllowed: Bool {
get { self.defaultsState.codexExternalOAuthSourcesAllowed }
set {
self.defaultsState.codexExternalOAuthSourcesAllowed = newValue
self.userDefaults.set(newValue, forKey: "codexExternalOAuthSourcesAllowed")
self.noteBackgroundWorkSettingsChanged()
}
}

var openAIWebAccessEnabled: Bool {
get { self.defaultsState.openAIWebAccessEnabled }
set {
Expand Down
6 changes: 6 additions & 0 deletions Sources/CodexBar/SettingsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -534,6 +534,11 @@ extension SettingsStore {
if Self.isRunningTests, codexSparkUsageVisibleDefault == nil {
userDefaults.set(true, forKey: "codexSparkUsageVisible")
}
let codexExternalOAuthSourcesAllowed = userDefaults.object(
forKey: "codexExternalOAuthSourcesAllowed") as? Bool ?? false
if Self.isRunningTests, userDefaults.object(forKey: "codexExternalOAuthSourcesAllowed") == nil {
userDefaults.set(false, forKey: "codexExternalOAuthSourcesAllowed")
}
let openAIWebAccessDefault = userDefaults.object(forKey: "openAIWebAccessEnabled") as? Bool
let openAIWebAccessEnabled = openAIWebAccessDefault ?? false
if Self.isRunningTests, openAIWebAccessDefault == nil {
Expand Down Expand Up @@ -637,6 +642,7 @@ extension SettingsStore {
showOptionalCreditsAndExtraUsage: showOptionalCreditsAndExtraUsage,
claudeDailyRoutinesUsageVisible: claudeDailyRoutinesUsageVisible,
codexSparkUsageVisible: codexSparkUsageVisible,
codexExternalOAuthSourcesAllowed: codexExternalOAuthSourcesAllowed,
openAIWebAccessEnabled: openAIWebAccessEnabled,
openAIWebBatterySaverEnabled: openAIWebBatterySaverEnabled,
backgroundWorkLowPowerModeEnabled: backgroundWorkLowPowerModeEnabled,
Expand Down
1 change: 1 addition & 0 deletions Sources/CodexBar/SettingsStoreState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ struct SettingsDefaultsState {
var showOptionalCreditsAndExtraUsage: Bool
var claudeDailyRoutinesUsageVisible: Bool
var codexSparkUsageVisible: Bool
var codexExternalOAuthSourcesAllowed: Bool
var openAIWebAccessEnabled: Bool
var openAIWebBatterySaverEnabled: Bool
var backgroundWorkLowPowerModeEnabled: Bool
Expand Down
29 changes: 24 additions & 5 deletions Sources/CodexBar/UsageStore+CodexResetCredits.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@ extension UsageStore {
typealias CodexResetCreditsFetcher = @Sendable ([String: String]) async throws
-> CodexRateLimitResetCreditsSnapshot?

func codexResetCreditsFetcher() -> CodexResetCreditsFetcher {
func codexResetCreditsFetcher(workspaceAccountID: String? = nil) -> CodexResetCreditsFetcher {
if let override = self._test_codexResetCreditsFetcherOverride {
return override
}
return { env in
try await Self.fetchCodexResetCredits(env: env)
try await Self.fetchCodexResetCredits(
env: env,
workspaceAccountID: workspaceAccountID)
}
}

Expand All @@ -35,6 +37,14 @@ extension UsageStore {
if result.usage.codexResetCredits != nil {
return outcome
}
if result.codexResetCreditsAttempted {
// OAuth already tried the winning in-memory credential snapshot. Never reload
// auth.json here: a concurrent CLI login could attach another account's credits.
if requiresResetCreditRescue {
return outcome.replacingResult(with: .failure(UsageError.noRateLimitsFound))
}
return outcome
}

do {
try Task.checkCancellation()
Expand Down Expand Up @@ -68,13 +78,15 @@ extension UsageStore {
}

nonisolated static func fetchCodexResetCredits(
env: [String: String]) async throws -> CodexRateLimitResetCreditsSnapshot?
env: [String: String],
workspaceAccountID: String? = nil) async throws -> CodexRateLimitResetCreditsSnapshot?
{
try Task.checkCancellation()
let credentials = try CodexOAuthCredentialsStore.loadOAuthTokens(env: env)
return try await Self.fetchCodexResetCredits(
credentials: credentials,
env: env,
workspaceAccountID: workspaceAccountID,
request: { accessToken, accountId, requestEnvironment in
try await CodexOAuthUsageFetcher.fetchRateLimitResetCredits(
accessToken: accessToken,
Expand All @@ -86,23 +98,29 @@ extension UsageStore {
private nonisolated static func fetchCodexResetCredits(
credentials: CodexOAuthCredentials,
env: [String: String],
workspaceAccountID: String? = nil,
request: @escaping @Sendable (String, String?, [String: String]) async throws
-> CodexRateLimitResetCreditsSnapshot?) async throws -> CodexRateLimitResetCreditsSnapshot?
{
try Task.checkCancellation()
// Supplemental inventory is strictly read-only. The main OAuth usage strategy owns token refreshes;
// CLI/web winners with stale credentials simply skip this best-effort GET.
guard !credentials.needsRefresh else { return nil }
return try await request(credentials.accessToken, credentials.accountId, env)
return try await request(credentials.accessToken, workspaceAccountID ?? credentials.accountId, env)
}

nonisolated static func _fetchCodexResetCreditsForTesting(
credentials: CodexOAuthCredentials,
env: [String: String] = [:],
workspaceAccountID: String? = nil,
request: @escaping @Sendable (String, String?, [String: String]) async throws
-> CodexRateLimitResetCreditsSnapshot?) async throws -> CodexRateLimitResetCreditsSnapshot?
{
try await self.fetchCodexResetCredits(credentials: credentials, env: env, request: request)
try await self.fetchCodexResetCredits(
credentials: credentials,
env: env,
workspaceAccountID: workspaceAccountID,
request: request)
}
}

Expand All @@ -117,6 +135,7 @@ extension ProviderFetchOutcome {
sourceLabel: result.sourceLabel,
strategyID: result.strategyID,
strategyKind: result.strategyKind,
codexResetCreditsAttempted: result.codexResetCreditsAttempted,
diagnostic: result.diagnostic,
claudeOAuthKeychainPersistentRefHash: result.claudeOAuthKeychainPersistentRefHash,
claudeOAuthHistoryOwnerIdentifier: result.claudeOAuthHistoryOwnerIdentifier,
Expand Down
2 changes: 1 addition & 1 deletion Sources/CodexBar/UsageStore+Refresh.swift
Original file line number Diff line number Diff line change
Expand Up @@ -404,7 +404,7 @@ extension UsageStore {
: nil
let priorTokenAccountSnapshot = self.tokenAccountSnapshot(provider: provider, account: tokenAccount)
let descriptor = spec.descriptor
let codexResetCreditsFetcher = self.codexResetCreditsFetcher()
let codexResetCreditsFetcher = self.codexResetCreditsFetcher(workspaceAccountID: fetchContext.codexWorkspaceID)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid enriching unscoped CLI usage with managed workspace credits

When a managed account selects workspace B while its now-read-only auth.json remains scoped to workspace A, choosing the explicit CLI source still runs CodexCLIUsageStrategy, which cannot carry the selected workspace header, but this fetcher forces workspace B onto the supplemental reset-credit request. The resulting snapshot can therefore combine workspace A's CLI usage with workspace B's reset credits; either suppress the unscoped CLI route for managed workspaces or only apply this override when the winning usage result was fetched with the same workspace scope.

Useful? React with 👍 / 👎.

let previousCodexSnapshot = codexPreparation?.previousSnapshot
let codexMissingWindowBackfillSnapshot = codexPreparation?.missingWindowBackfillSnapshot
let fetchOutcome: @Sendable () async -> ProviderFetchOutcome = {
Expand Down
9 changes: 5 additions & 4 deletions Sources/CodexBar/UsageStore+TokenAccounts.swift
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ private struct CodexAccountFetchRequest {
let limitResetOwnerKey: CodexLimitResetOwnerKey?
let descriptor: ProviderDescriptor
let context: ProviderFetchContext
let resetCreditsFetcher: UsageStore.CodexResetCreditsFetcher
}

private struct CodexManagedVisibleAccountRuntimeState {
Expand Down Expand Up @@ -740,7 +741,7 @@ extension UsageStore {
return await Self.attachingCodexResetCreditsIfNeeded(
to: outcome,
env: context.env,
fetcher: self.codexResetCreditsFetcher())
fetcher: self.codexResetCreditsFetcher(workspaceAccountID: context.codexWorkspaceID))
}

private func fetchTokenAccountOutcomes(
Expand Down Expand Up @@ -817,7 +818,6 @@ extension UsageStore {
priorSnapshots: [CodexAccountUsageSnapshot],
activeVisibleAccountID: String?) async
-> [CodexAccountFetchResult] {
let resetCreditsFetcher = self.codexResetCreditsFetcher()
let requests: [CodexAccountFetchRequest] = accounts.enumerated().map { index, account in
let descriptor = self.providerSpecs[.codex]?.descriptor ?? ProviderDescriptorRegistry
.descriptor(for: .codex)
Expand Down Expand Up @@ -845,7 +845,8 @@ extension UsageStore {
missingWindowBackfillSnapshot: missingWindowBackfillSnapshot,
limitResetOwnerKey: limitResetOwnerKey,
descriptor: descriptor,
context: context)
context: context,
resetCreditsFetcher: self.codexResetCreditsFetcher(workspaceAccountID: context.codexWorkspaceID))
}

return await withTaskGroup(
Expand All @@ -859,7 +860,7 @@ extension UsageStore {
return await Self.attachingCodexResetCreditsIfNeeded(
to: baseOutcome,
env: request.context.env,
fetcher: resetCreditsFetcher)
fetcher: request.resetCreditsFetcher)
}
let initialOutcome = await fetchOutcome()
let outcome: ProviderFetchOutcome? = if Self.codexUsageOutcomeMatchesVisibleAccount(
Expand Down
14 changes: 4 additions & 10 deletions Sources/CodexBarCore/ManagedCodexAccountStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,10 @@ public struct FileManagedCodexAccountStore: ManagedCodexAccountStoring, @uncheck
if !self.fileManager.fileExists(atPath: directory.path) {
try self.fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
}
try data.write(to: self.fileURL, options: [.atomic])
try self.applySecurePermissionsIfNeeded()
// Managed account metadata contains account identities and private Codex home paths. Use
// the same staged 0600 writer as auth.json so a newly-created file is never briefly
// readable under a permissive umask.
try CredentialFileWriter.writePrivate(data, to: self.fileURL)
}

public func ensureFileExists() throws -> URL {
Expand All @@ -59,14 +61,6 @@ public struct FileManagedCodexAccountStore: ManagedCodexAccountStoring, @uncheck
return self.fileURL
}

private func applySecurePermissionsIfNeeded() throws {
#if os(macOS)
try self.fileManager.setAttributes([
.posixPermissions: NSNumber(value: Int16(0o600)),
], ofItemAtPath: self.fileURL.path)
#endif
}

private static func emptyAccountSet() -> ManagedCodexAccountSet {
ManagedCodexAccountSet(version: self.currentVersion, accounts: [])
}
Expand Down
Loading