diff --git a/CHANGELOG.md b/CHANGELOG.md index 1444a9e010..55e643add5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### Fixed +- Codex: retry brief credential-file publication races before reporting refresh errors, and discard the previous plan's quota baseline after a subscription change so fresh usage can appear (#3635, #3389). - Development: restore test compilation on Xcode 26.3 / Swift 6.2 and check app, CLI, and test compatibility in CI (#4070). Thanks @RowboTony! - Configuration: treat empty or whitespace-only config files like missing files so usage keeps working; settings saves write valid JSON, while malformed non-empty files still report errors (#4071). - Menu bar: reject corrupt saved positions during status-item visibility changes and removal while preserving valid placement across restarts (#3355). @@ -21,6 +22,8 @@ - Kimi Code: mark shorter Code windows as blocked when the known monthly membership pool is exhausted, without showing fresh quota or pace forecasts (#3536). - z.ai: explain unavailable Coding Plan usage for empty or unsupported quota shapes while preserving recognized quotas and analytics (#2522). - Grok: keep local token totals visible in Usage & Spend and shared cards across wider history views and billing outages, with consistent daily scan windows (#3716). Thanks @Chipagosfinest! +- Adaptive refresh: recognize ChatGPT's nested Codex app-server with per-scan running-process validation and update-aware signed-bundle assessment caching, avoiding repeated Gatekeeper subprocesses while keeping idle servers at the normal cadence (#4069, #4090). +- Widgets: retain each eligible provider's last-good reading and original age after failed refreshes, even when another provider is unavailable, disabled, or changes accounts (#3500). - Token history: show observed model names when per-model totals are unavailable, including Grok local sessions (#4056). Fixes #4054. Thanks @BUKOWSKIREAL! - TypeSafe: show the credit balance in the menu bar and layout preview instead of missing-value dashes, using shared balance-label metadata (#4050). Thanks @lg! - Grok: retain the product usage breakdown on the grok.com billing fallback, with bounded shared protobuf decoding (#4041). Thanks @olddonkey! diff --git a/Sources/CodexBar/Providers/Codex/CodexWeeklyResetConfirmation.swift b/Sources/CodexBar/Providers/Codex/CodexWeeklyResetConfirmation.swift index 32af1da84f..1af794e8f6 100644 --- a/Sources/CodexBar/Providers/Codex/CodexWeeklyResetConfirmation.swift +++ b/Sources/CodexBar/Providers/Codex/CodexWeeklyResetConfirmation.swift @@ -192,7 +192,8 @@ struct CodexWeeklyResetConfirmation: Sendable { return .publishConfirmation } - guard initialWeekly.usedPercent <= Self.resetThreshold, + guard Self.normalizedPlan(initial) == Self.normalizedPlan(confirmation), + initialWeekly.usedPercent <= Self.resetThreshold, let initialBoundary = Self.validResetBoundary(initialWeekly, capturedAt: initial.updatedAt), let confirmationBoundary = Self.validResetBoundary( confirmationWeekly, @@ -404,6 +405,11 @@ struct CodexWeeklyResetConfirmation: Sendable { return identities.allSatisfy { $0 == first } } + static func normalizedPlan(_ snapshot: UsageSnapshot?) -> String? { + let plan = snapshot?.loginMethod(for: .codex)?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return plan?.isEmpty == false ? plan : nil + } + private static func haveCompatiblePlans(_ snapshots: UsageSnapshot...) -> Bool { // Codex exposes the subscription tier through loginMethod, so it is the plan identity here. let plans = snapshots.map { snapshot in diff --git a/Sources/CodexBar/Providers/Codex/UsageStore+CodexResetBackfill.swift b/Sources/CodexBar/Providers/Codex/UsageStore+CodexResetBackfill.swift index 481931d86b..3f77c79ec8 100644 --- a/Sources/CodexBar/Providers/Codex/UsageStore+CodexResetBackfill.swift +++ b/Sources/CodexBar/Providers/Codex/UsageStore+CodexResetBackfill.swift @@ -4,10 +4,18 @@ import Foundation /// Reset-time backfill for Codex rate windows: rebuilds raw snapshot slots from cached lane data so /// missing reset timestamps survive refreshes without disturbing fresh quota values. extension UsageStore { + nonisolated static func codexPlanChanged(from previous: UsageSnapshot?, to current: UsageSnapshot) -> Bool { + guard let previousPlan = CodexWeeklyResetConfirmation.normalizedPlan(previous), + let currentPlan = CodexWeeklyResetConfirmation.normalizedPlan(current) + else { return false } + return previousPlan != currentPlan + } + nonisolated static func codexBackfillingResetWindows( _ snapshot: UsageSnapshot, from cached: UsageSnapshot) -> UsageSnapshot { + guard !self.codexPlanChanged(from: cached, to: snapshot) else { return snapshot } let primary = self.codexBackfilledSlotWindow( slotWindow: snapshot.primary, lane: .session, @@ -132,3 +140,12 @@ extension UsageStore { resetDescription: cached.resetDescription) } } + +extension ProviderFetchOutcome { + nonisolated func backfillingCodexResetWindows(from cached: UsageSnapshot?) -> ProviderFetchOutcome { + guard let cached, case let .success(result) = self.result else { return self } + return self.replacingUsage(UsageStore.codexBackfillingResetWindows( + result.usage.scoped(to: .codex), + from: cached)) + } +} diff --git a/Sources/CodexBar/Providers/Codex/UsageStore+CodexWeeklyResetConfirmation.swift b/Sources/CodexBar/Providers/Codex/UsageStore+CodexWeeklyResetConfirmation.swift index 6ccd73e3a7..277e8a111a 100644 --- a/Sources/CodexBar/Providers/Codex/UsageStore+CodexWeeklyResetConfirmation.swift +++ b/Sources/CodexBar/Providers/Codex/UsageStore+CodexWeeklyResetConfirmation.swift @@ -49,16 +49,19 @@ extension UsageStore { return CodexWeeklyResetPublicationAdmission(outcome: initialOutcome, pendingCandidate: candidateForRetry) } let rawInitialSnapshot = rawInitialResult.usage.scoped(to: .codex) - let publicationBaseline = [previousSnapshot, missingWindowBackfillSnapshot] + let cachedBaseline = [previousSnapshot, missingWindowBackfillSnapshot] .compactMap(\.self) .max { $0.updatedAt < $1.updatedAt } - let publicationInitialOutcome = if let missingWindowBackfillSnapshot { - initialOutcome.replacingUsage(Self.codexBackfillingResetWindows( - rawInitialSnapshot, - from: missingWindowBackfillSnapshot)) - } else { - initialOutcome - } + let planBaseline = previousSnapshot ?? missingWindowBackfillSnapshot + let planChanged = Self.isExactCodexOAuthResult(rawInitialResult) + && rawInitialSnapshot.updatedAt > (cachedBaseline?.updatedAt ?? .distantFuture) + && Self.codexPlanChanged(from: planBaseline, to: rawInitialSnapshot) + // A new subscription has a different quota baseline, not evidence of a reset on the old plan. + let previousSnapshot = planChanged ? nil : previousSnapshot + let missingWindowBackfillSnapshot = planChanged ? nil : missingWindowBackfillSnapshot + let publicationBaseline = planChanged ? nil : cachedBaseline + if planChanged { candidateForRetry = nil } + let publicationInitialOutcome = initialOutcome.backfillingCodexResetWindows(from: missingWindowBackfillSnapshot) if CodexConsumerProjection.sourceRateWindow(for: .weekly, snapshot: rawInitialSnapshot) == nil { return Self.codexMissingWeeklyAdmission(input: CodexMissingWeeklyAdmissionInput( @@ -168,15 +171,8 @@ extension UsageStore { trace: confirmationTrace) switch confirmationDecision { case .publishConfirmation: - if let missingWindowBackfillSnapshot { - return CodexWeeklyResetPublicationAdmission( - outcome: confirmationOutcome.replacingUsage(Self.codexBackfillingResetWindows( - confirmationSnapshot, - from: missingWindowBackfillSnapshot)), - pendingCandidate: nil) - } return CodexWeeklyResetPublicationAdmission( - outcome: confirmationOutcome, + outcome: confirmationOutcome.backfillingCodexResetWindows(from: missingWindowBackfillSnapshot), pendingCandidate: nil) case .preservePrevious: let candidate = Self.makeCodexDelayedCandidate( diff --git a/Sources/CodexBar/UsageStore+Refresh.swift b/Sources/CodexBar/UsageStore+Refresh.swift index a67fbe71f3..5dfe4c3acf 100644 --- a/Sources/CodexBar/UsageStore+Refresh.swift +++ b/Sources/CodexBar/UsageStore+Refresh.swift @@ -821,6 +821,8 @@ extension UsageStore { resetBackfillSource: UsageSnapshot?, context: ProviderRefreshOutcomeContext) -> UsageSnapshot { + let resetBackfillSource = provider == .codex && Self.codexPlanChanged(from: resetBackfillSource, to: snapshot) + ? nil : resetBackfillSource let profileStable = self.preservingDeepSeekProfileCatalog(in: snapshot, provider: provider) let stabilized = Self.commandCodeSnapshotResolvingDepletionOnEnrichmentFailure( current: profileStable, diff --git a/Sources/CodexBar/UsageStore+TokenAccounts.swift b/Sources/CodexBar/UsageStore+TokenAccounts.swift index c9d6242a36..c0b6663a28 100644 --- a/Sources/CodexBar/UsageStore+TokenAccounts.swift +++ b/Sources/CodexBar/UsageStore+TokenAccounts.swift @@ -1313,7 +1313,9 @@ extension UsageStore { } let labeled = self.applyCodexVisibleAccountLabel(scoped, account: account) let backfilled = - Self.codexMergedResetBackfillSnapshot(resetBackfillSnapshots) + Self.codexMergedResetBackfillSnapshot(resetBackfillSnapshots.filter { + !Self.codexPlanChanged(from: $0, to: labeled) + }) .map { Self.codexBackfillingResetWindows(labeled, from: $0) } ?? labeled let credits = CodexMonthlyCreditPreservation.merging( incoming: result.credits, diff --git a/Sources/CodexBar/UsageStore+WidgetSnapshot.swift b/Sources/CodexBar/UsageStore+WidgetSnapshot.swift index c8abd0fc26..32ce42eccf 100644 --- a/Sources/CodexBar/UsageStore+WidgetSnapshot.swift +++ b/Sources/CodexBar/UsageStore+WidgetSnapshot.swift @@ -38,9 +38,7 @@ extension UsageStore { }() let snapshot = self.makeWidgetSnapshot(previousSnapshot: previousSnapshot) self.lastQueuedWidgetSnapshot = snapshot - self.lastQueuedWidgetSnapshotIsPreservable = snapshot.entries.allSatisfy { - !self.widgetUsagePreservationBlockedProviders.contains($0.provider) - } + self.invalidatedQueuedWidgetProviders = self.widgetUsagePreservationBlockedProviders NotificationCenter.default.post( name: .codexbarUsageSnapshotsDidChange, object: UsageSnapshotsDidChangeEvent(snapshots: self.cloudSyncAccountSnapshots())) @@ -191,33 +189,28 @@ extension UsageStore { self.lastWidgetSourceSnapshots[provider.instanceID] = nil self.widgetUsagePreservationBlockedProviders.insert(provider.instanceID) // A successful fetch cannot make an older queued account valid again. - if self.lastQueuedWidgetSnapshot?.entries.contains(where: { $0.provider == provider.instanceID }) == true { - self.lastQueuedWidgetSnapshotIsPreservable = false - } + self.invalidatedQueuedWidgetProviders.insert(provider.instanceID) } private func makeWidgetSnapshot(previousSnapshot: WidgetSnapshot?) -> WidgetSnapshot { let now = Date() let enabledProviders = self.enabledProviders() - var entries = UsageProvider.allCases.compactMap { provider in - self.makeWidgetEntry( + let entries = UsageProvider.allCases.compactMap { provider -> WidgetSnapshot.ProviderEntry? in + if let entry = self.makeWidgetEntry( for: provider, now: now, previousEntry: previousSnapshot?.entries.first { $0.provider == provider.instanceID }) - } - // Only reuse this process's publication; disk entries do not establish the current account's ownership. - if entries.isEmpty, self.lastQueuedWidgetSnapshotIsPreservable, - let previousSnapshot = self.lastQueuedWidgetSnapshot, - previousSnapshot.enabledProviders.allSatisfy(enabledProviders.contains), - previousSnapshot.entries.allSatisfy({ entry in - // Provider-specific by design: Claude's owner-aware preservation above remains authoritative. - entry.provider != .claude && enabledProviders.contains(entry.provider) && - self.errors[entry.provider] != nil && - (entry.providerCost == nil || self.settings.showOptionalCreditsAndExtraUsage) && - !self.widgetUsagePreservationBlockedProviders.contains(entry.provider) - }) - { - entries = previousSnapshot.entries.map { self.preservedWidgetEntryForCurrentMetric($0) } + { return entry } + // Provider-specific by design: Claude uses its owner-aware path; others require this process's publication. + guard provider != .claude, enabledProviders.contains(provider.instanceID), + self.errors[provider.instanceID] != nil, + !self.invalidatedQueuedWidgetProviders.contains(provider.instanceID), + !self.widgetUsagePreservationBlockedProviders.contains(provider.instanceID), + let entry = self.lastQueuedWidgetSnapshot?.entries + .first(where: { $0.provider == provider.instanceID }), + entry.providerCost == nil || self.settings.showOptionalCreditsAndExtraUsage + else { return nil } + return self.preservedWidgetEntryForCurrentMetric(entry) } return WidgetSnapshot( entries: entries, diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 6beeaeb89c..d022a7307c 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -332,7 +332,7 @@ final class UsageStore { TimeInterval) async throws -> Void)? @ObservationIgnored var widgetSnapshotPersistTask: Task? @ObservationIgnored var lastQueuedWidgetSnapshot: WidgetSnapshot? - @ObservationIgnored var lastQueuedWidgetSnapshotIsPreservable = false + @ObservationIgnored var invalidatedQueuedWidgetProviders: Set = [] @ObservationIgnored var lastWidgetSourceSnapshots: [ProviderInstanceID: UsageSnapshot] = [:] @ObservationIgnored let widgetSnapshotURL: URL? @ObservationIgnored let widgetTimelineReloader: @MainActor () -> Void diff --git a/Sources/CodexBarCore/AgentSession.swift b/Sources/CodexBarCore/AgentSession.swift index 436572852d..b04038f946 100644 --- a/Sources/CodexBarCore/AgentSession.swift +++ b/Sources/CodexBarCore/AgentSession.swift @@ -327,34 +327,25 @@ public enum AgentPSOutputParser { } } - static func chatGPTCodexAppServerExecutable( + static let chatGPTCodexExecutablePaths: Set = [ + "/Applications/ChatGPT.app/Contents/Resources/codex", + "/Applications/ChatGPT.app/Contents/Resources/codex-cli/CodexCLI.app/Contents/MacOS/codex", + ] + + static func hasTrustedChatGPTCodexAppServer( in records: [AgentProcessRecord], - homeDirectory: URL) -> String? + validator: (AgentProcessRecord) -> Bool) -> Bool { - let allowedPaths = Set([ - URL(fileURLWithPath: "/Applications/ChatGPT.app/Contents/Resources/codex") - .standardizedFileURL.path, - homeDirectory.appendingPathComponent("Applications/ChatGPT.app/Contents/Resources/codex") - .standardizedFileURL.path, - ]) - - return records.lazy.compactMap { record -> String? in - guard record.executableBasename.lowercased() == AgentSession.Provider.codex.rawValue, - self.arguments(record).contains("app-server"), - let executable = record.arguments?.first ?? record.command.split(whereSeparator: \ .isWhitespace) - .first.map(String.init) - else { return nil } - - let path = URL(fileURLWithPath: executable).standardizedFileURL.path - return allowedPaths.contains(path) ? path : nil - }.first + records.contains { record in + let executable = record.arguments?.first ?? record.command.split(whereSeparator: \ .isWhitespace) + .first.map(String.init) ?? "" + return self.chatGPTCodexExecutablePaths.contains(executable) && + self.arguments(record).contains("app-server") && validator(record) + } } private static func arguments(_ record: AgentProcessRecord) -> [String] { - if let arguments = record.arguments { - return Array(arguments.dropFirst()) - } - return self.arguments(record.command) + Array((record.arguments ?? record.command.split(whereSeparator: \ .isWhitespace).map(String.init)).dropFirst()) } private static func arguments(_ command: String) -> [String] { diff --git a/Sources/CodexBarCore/LocalAgentSessionScanner.swift b/Sources/CodexBarCore/LocalAgentSessionScanner.swift index df377b4cd0..2befcb3154 100644 --- a/Sources/CodexBarCore/LocalAgentSessionScanner.swift +++ b/Sources/CodexBarCore/LocalAgentSessionScanner.swift @@ -1,4 +1,7 @@ import Foundation +#if os(macOS) +import Security +#endif final class FutureModificationDateClamp: @unchecked Sendable { private let lock = NSLock() @@ -20,27 +23,102 @@ final class FutureModificationDateClamp: @unchecked Sendable { } } -private final class TrustedCodexAppServerCache: @unchecked Sendable { +enum ChatGPTCodexProcessTrust { + #if os(macOS) + static func isTrusted( + _ pid: Int32, + executablePath: (Int32) -> String? = DarwinProcessEnumerator.executablePath, + resolvePath: (String) -> String = { URL(fileURLWithPath: $0).resolvingSymlinksInPath().path }, + processIsTrusted: (Int32) -> Bool = Self.isOpenAIProcess, + appIsTrusted: (String) -> Bool = { ChatGPTBundleTrustCache.shared.isTrusted($0) }) -> Bool + { + guard let path = executablePath(pid), + AgentPSOutputParser.chatGPTCodexExecutablePaths.contains(path), + resolvePath(path) == path + else { return false } + // Check the running code, not argv or a cached on-disk pathname. Validate the outer app's seal and identity. + return processIsTrusted(pid) && appIsTrusted("/Applications/ChatGPT.app") + } + + private static func isOpenAIProcess(_ pid: Int32) -> Bool { + var code: SecCode? + guard SecCodeCopyGuestWithAttributes( + nil, [kSecGuestAttributePid: pid] as CFDictionary, SecCSFlags(), &code) == errSecSuccess, + let code + else { return false } + var requirement: SecRequirement? + let requirementText = "anchor apple generic and certificate leaf[subject.OU] = \"2DC432GLL2\"" + guard SecRequirementCreateWithString( + requirementText as CFString, SecCSFlags(), &requirement) == errSecSuccess, + let requirement + else { return false } + return SecCodeCheckValidity(code, SecCSFlags(), requirement) == errSecSuccess + } + #else + static func isTrusted(_: Int32) -> Bool { + false + } + #endif +} + +#if os(macOS) +final class ChatGPTBundleTrustCache: @unchecked Sendable { + typealias Identity = [URL: NSDictionary] + static let shared = ChatGPTBundleTrustCache() private let lock = NSLock() - private var trustedExecutablePaths = Set() + private var trustedIdentity: Identity? - func isTrusted(_ path: String, validator: @Sendable (String) -> Bool) -> Bool { + func isTrusted( + _ path: String, + identity: (String) -> Identity? = ChatGPTBundleTrustCache.identity, + assess: (String) -> Bool = { CodexLaunchPreflight.isLaunchCandidateAllowed(path: $0) }) -> Bool + { self.lock.withLock { - if self.trustedExecutablePaths.contains(path) { - return true + guard let current = identity(path) else { + self.trustedIdentity = nil + return false } - guard validator(path) else { return false } - self.trustedExecutablePaths.insert(path) + if self.trustedIdentity == current { return true } + self.trustedIdentity = nil + guard assess(path), identity(path) == current else { return false } + self.trustedIdentity = current return true } } + + static func identity(_ path: String) -> Identity? { + let bundle = URL(fileURLWithPath: path) + // Read Info.plist directly: Bundle caches it across in-process app updates. + let plist = bundle.appendingPathComponent("Contents/Info.plist") + guard let data = try? Data(contentsOf: plist), + let info = try? PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any], + let executable = info["CFBundleExecutable"] as? String, + !executable.isEmpty, !executable.contains("/"), executable != ".", executable != ".." + else { return nil } + var identity: Identity = [:] + for url in [ + bundle, + plist, + bundle.appendingPathComponent("Contents/MacOS/\(executable)"), + bundle.appendingPathComponent("Contents/_CodeSignature/CodeResources"), + ] { + guard url.resolvingSymlinksInPath().path == url.path, + let attributes = try? FileManager.default.attributesOfItem(atPath: url.path), + attributes[.systemNumber] != nil, attributes[.systemFileNumber] != nil, + attributes[.modificationDate] != nil + else { return nil } + identity[url] = attributes as NSDictionary + } + return identity + } } +#endif public struct LocalAgentSessionScanner: Sendable { typealias ProcessOutputProvider = @Sendable ([String: String]) async -> String typealias CWDProvider = @Sendable ([Int32], [String: String]) async -> [Int32: String] typealias ProcessEnvironmentProvider = @Sendable ([Int32]) async -> [Int32: [String: String]] - typealias AppServerTrustValidator = @Sendable (String) -> Bool + typealias AppServerTrustValidator = @Sendable (AgentProcessRecord) -> Bool private struct Rollout: Sendable { let url: URL @@ -53,15 +131,13 @@ public struct LocalAgentSessionScanner: Sendable { let host: String let now: Date let codexAppServerPresent: Bool - let includeFileOnlySessions: Bool - let includeTrustedCodexAppServerRollouts: Bool + let includeUnmatchedCodexRollouts: Bool let threadMetadata: [String: CodexThreadMetadata] let piFamilySessions: [AgentSession] } public let config: SessionScanConfig private let futureModificationDateClamp = FutureModificationDateClamp() - private let trustedCodexAppServerCache = TrustedCodexAppServerCache() private let processOutputProvider: ProcessOutputProvider? private let cwdProvider: CWDProvider? private let processEnvironmentProvider: ProcessEnvironmentProvider? @@ -69,21 +145,16 @@ public struct LocalAgentSessionScanner: Sendable { private let didVisitDirectoryEntry: (@Sendable () -> Void)? public init(config: SessionScanConfig = SessionScanConfig()) { - self.config = config - self.processOutputProvider = nil - self.cwdProvider = nil - self.processEnvironmentProvider = nil - self.appServerTrustValidator = { CodexLaunchPreflight.isLaunchCandidateAllowed(path: $0) } - self.didVisitDirectoryEntry = nil + self.init(config: config, processOutputProvider: nil, cwdProvider: nil) } init( config: SessionScanConfig = SessionScanConfig(), - processOutputProvider: @escaping ProcessOutputProvider, - cwdProvider: @escaping CWDProvider, + processOutputProvider: ProcessOutputProvider?, + cwdProvider: CWDProvider?, processEnvironmentProvider: ProcessEnvironmentProvider? = nil, appServerTrustValidator: @escaping AppServerTrustValidator = { - CodexLaunchPreflight.isLaunchCandidateAllowed(path: $0) + ChatGPTCodexProcessTrust.isTrusted($0.pid) }, didVisitDirectoryEntry: (@Sendable () -> Void)? = nil) { @@ -106,14 +177,8 @@ public struct LocalAgentSessionScanner: Sendable { AgentPSOutputParser.agentProcesses(from: allProcesses)) .prefix(max(0, self.config.maxProcessCount))) let homeDirectory = URL(fileURLWithPath: environment["HOME"] ?? NSHomeDirectory(), isDirectory: true) - let trustedCodexAppServerPresent = if let executable = AgentPSOutputParser.chatGPTCodexAppServerExecutable( - in: allProcesses, - homeDirectory: homeDirectory) - { - self.trustedCodexAppServerCache.isTrusted(executable, validator: self.appServerTrustValidator) - } else { - false - } + let trustedCodexAppServerPresent = AgentPSOutputParser.hasTrustedChatGPTCodexAppServer( + in: allProcesses, validator: self.appServerTrustValidator) guard Self.shouldScanSessionMetadata( hasAgentProcesses: !processes.isEmpty, includeFileOnlySessions: includeFileOnlySessions, @@ -121,15 +186,9 @@ public struct LocalAgentSessionScanner: Sendable { else { return [] } let codexAppServerPresent = AgentPSOutputParser.hasCodexAppServer(in: allProcesses) || trustedCodexAppServerPresent - let cwdByPID = if let cwdProvider = self.cwdProvider { - await cwdProvider(processes.map(\ .pid), environment) - } else { - await self.cwdByPID(processes.map(\ .pid), environment: environment) - } - let codexCWDs = processes.compactMap { process -> String? in - guard AgentPSOutputParser.provider(for: process) == .codex else { return nil } - return cwdByPID[process.pid] - } + let cwdByPID = await self.cwdByPID(processes.map(\.pid), environment: environment) + let codexCWDs = processes.filter { AgentPSOutputParser.provider(for: $0) == .codex } + .compactMap { cwdByPID[$0.pid] } let codexHomeDirectory = URL( fileURLWithPath: environment["CODEX_HOME"] ?? homeDirectory.appendingPathComponent(".codex").path, isDirectory: true) @@ -141,13 +200,7 @@ public struct LocalAgentSessionScanner: Sendable { ? self.config.directoryScanBudget : min(self.config.directoryScanBudget, self.config.adaptiveDirectoryScanBudget), didVisitEntry: self.didVisitDirectoryEntry) - var piFamilyDirectoryBudget = DirectoryMetadataScanBudget( - maxEntryCount: self.config.maxDirectoryEntryCount, - maxDepth: self.config.maxDirectoryDepth, - timeLimit: includeFileOnlySessions - ? self.config.directoryScanBudget - : min(self.config.directoryScanBudget, self.config.adaptiveDirectoryScanBudget), - didVisitEntry: self.didVisitDirectoryEntry) + var piFamilyDirectoryBudget = directoryBudget let piFamilySessions = PiFamilySessionScanner.scan( input: PiFamilySessionScanner.ScanInput( processes: processes, @@ -157,14 +210,12 @@ public struct LocalAgentSessionScanner: Sendable { host: host, config: self.config), directoryBudget: &piFamilyDirectoryBudget) - let includeTrustedCodexAppServerRollouts = trustedCodexAppServerPresent && !includeFileOnlySessions - let rollouts: [Rollout] = if includeFileOnlySessions || !codexCWDs.isEmpty || - includeTrustedCodexAppServerRollouts - { + let includeUnmatchedCodexRollouts = includeFileOnlySessions || trustedCodexAppServerPresent + let rollouts: [Rollout] = if includeUnmatchedCodexRollouts || !codexCWDs.isEmpty { self.codexRollouts( now: now, codexHomeDirectory: codexHomeDirectory, - matchingCWDs: includeFileOnlySessions || includeTrustedCodexAppServerRollouts ? nil : codexCWDs, + matchingCWDs: includeUnmatchedCodexRollouts ? nil : codexCWDs, directoryBudget: &directoryBudget) } else { [] @@ -182,8 +233,7 @@ public struct LocalAgentSessionScanner: Sendable { host: host, now: now, codexAppServerPresent: codexAppServerPresent, - includeFileOnlySessions: includeFileOnlySessions, - includeTrustedCodexAppServerRollouts: includeTrustedCodexAppServerRollouts, + includeUnmatchedCodexRollouts: includeUnmatchedCodexRollouts, threadMetadata: threadMetadata, piFamilySessions: piFamilySessions), directoryBudget: &directoryBudget) @@ -197,12 +247,7 @@ public struct LocalAgentSessionScanner: Sendable { { let contexts = await self.piSessionProcessContexts(environment: environment) var seen = Set() - return contexts.compactMap { context in - guard let workingDirectory = context.workingDirectory, - seen.insert(workingDirectory.path).inserted - else { return nil } - return workingDirectory - } + return contexts.compactMap(\.workingDirectory).filter { seen.insert($0.path).inserted } } /// Returns the command selectors and project directories of live Pi-family processes so cost scans can @@ -220,11 +265,7 @@ public struct LocalAgentSessionScanner: Sendable { .filter { AgentPSOutputParser.provider(for: $0) == .pi }) guard !processes.isEmpty, self.config.maxProcessCount > 0 else { return [] } - let cwdByPID = if let cwdProvider = self.cwdProvider { - await cwdProvider(processes.map(\.pid), environment) - } else { - await self.cwdByPID(processes.map(\.pid), environment: environment) - } + let cwdByPID = await self.cwdByPID(processes.map(\.pid), environment: environment) var seen = Set() let distinctContexts: [PiSessionProcessContext] = processes.compactMap { process in let workingDirectory = cwdByPID[process.pid] @@ -277,12 +318,7 @@ public struct LocalAgentSessionScanner: Sendable { environment: environment, resolvedWorkingDirectory: resolvedWorkingDirectory) let key = reader.databaseURL.path - if var group = groups[key] { - group.sessionIDs.insert(rollout.metadata.sessionID) - groups[key] = group - } else { - groups[key] = (reader, [rollout.metadata.sessionID]) - } + groups[key, default: (reader, [])].sessionIDs.insert(rollout.metadata.sessionID) } var metadata: [String: CodexThreadMetadata] = [:] @@ -325,62 +361,40 @@ public struct LocalAgentSessionScanner: Sendable { cwdByPID: cwdByPID) for process in processes { - guard let provider = AgentPSOutputParser.provider(for: process) else { continue } - let cwd = cwdByPID[process.pid] - switch provider { - case .claude: - let transcript = claudeTranscripts[process.pid] - sessions.append(AgentSession( - id: transcript?.url.deletingPathExtension().lastPathComponent ?? "pid:\(process.pid)", - provider: .claude, - source: AgentPSOutputParser.source(for: process), - state: self.config.state( - lastActivityAt: transcript?.modifiedAt, - now: context.now, - hasLiveProcess: true), - pid: process.pid, - cwd: cwd, - projectName: Self.projectName(cwd), - startedAt: process.startedAt, - lastActivityAt: transcript?.modifiedAt, - transcriptPath: transcript?.url.path, - host: context.host)) - case .codex: - let rollout = rollouts.first { candidate in - !matchedRolloutPaths.contains(candidate.url.path) && - AgentSessionCorrelation.codexWorkingDirectoriesMatch(candidate.metadata.cwd, cwd) - } - if let rollout { - matchedRolloutPaths.insert(rollout.url.path) - } - let rolloutSource = rollout?.metadata.sessionSource - sessions.append(AgentSession( - id: rollout?.metadata.sessionID ?? "pid:\(process.pid)", - provider: .codex, - source: rolloutSource == nil || rolloutSource == .unknown ? .cli : rolloutSource ?? .cli, - state: self.config.state( - lastActivityAt: rollout?.modifiedAt, - now: context.now, - hasLiveProcess: true), - pid: process.pid, - cwd: cwd ?? rollout?.metadata.cwd, - projectName: Self.projectName(cwd ?? rollout?.metadata.cwd), - sessionName: codexDescriptiveNamePIDs.contains(process.pid) - ? rollout?.metadata.descriptiveName( - threadMetadata: rollout.flatMap { context.threadMetadata[$0.metadata.sessionID] }) - : nil, - startedAt: process.startedAt, - lastActivityAt: rollout?.modifiedAt, - transcriptPath: rollout?.url.path, - host: context.host)) - // Provider-specific by design: Pi-family processes are correlated by PiFamilySessionScanner. - case .pi: - continue - } + // Pi-family processes are correlated by PiFamilySessionScanner. + guard let provider = AgentPSOutputParser.provider(for: process), provider != .pi else { continue } + let processCWD = cwdByPID[process.pid] + let rollout = provider == .codex ? rollouts.first { candidate in + !matchedRolloutPaths.contains(candidate.url.path) && + AgentSessionCorrelation.codexWorkingDirectoriesMatch(candidate.metadata.cwd, processCWD) + } : nil + if let rollout { matchedRolloutPaths.insert(rollout.url.path) } + let transcript = provider == .claude ? claudeTranscripts[process.pid] : nil + let modifiedAt = rollout?.modifiedAt ?? transcript?.modifiedAt + let cwd = processCWD ?? rollout?.metadata.cwd + let rolloutSource = rollout?.metadata.sessionSource + sessions.append(AgentSession( + id: rollout?.metadata.sessionID ?? transcript?.url.deletingPathExtension().lastPathComponent ?? + "pid:\(process.pid)", + provider: provider, + source: provider == .claude ? AgentPSOutputParser.source(for: process) : + (rolloutSource == .unknown ? nil : rolloutSource) ?? .cli, + state: self.config.state(lastActivityAt: modifiedAt, now: context.now, hasLiveProcess: true), + pid: process.pid, + cwd: cwd, + projectName: cwd.flatMap { $0.isEmpty ? nil : URL(fileURLWithPath: $0).lastPathComponent }, + sessionName: codexDescriptiveNamePIDs.contains(process.pid) + ? rollout?.metadata.descriptiveName( + threadMetadata: rollout.flatMap { context.threadMetadata[$0.metadata.sessionID] }) + : nil, + startedAt: process.startedAt, + lastActivityAt: modifiedAt, + transcriptPath: rollout?.url.path ?? transcript?.url.path, + host: context.host)) } for rollout in rollouts - where (context.includeFileOnlySessions || context.includeTrustedCodexAppServerRollouts) && + where context.includeUnmatchedCodexRollouts && !matchedRolloutPaths.contains(rollout.url.path) { guard var session = CodexRolloutFirstLineParser.makeSession( @@ -484,6 +498,7 @@ public struct LocalAgentSessionScanner: Sendable { #endif private func cwdByPID(_ pids: [Int32], environment: [String: String]) async -> [Int32: String] { + if let cwdProvider = self.cwdProvider { return await cwdProvider(pids, environment) } guard !pids.isEmpty else { return [:] } #if canImport(Darwin) return Dictionary(uniqueKeysWithValues: pids.compactMap { pid in @@ -565,13 +580,4 @@ public struct LocalAgentSessionScanner: Sendable { .map { String($0) + "/" + name } .first { FileManager.default.isExecutableFile(atPath: $0) } } - - private static func standardized(_ path: String?) -> String? { - path.map { URL(fileURLWithPath: $0).standardizedFileURL.path } - } - - private static func projectName(_ cwd: String?) -> String? { - guard let cwd, !cwd.isEmpty else { return nil } - return URL(fileURLWithPath: cwd).lastPathComponent - } } diff --git a/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthCredentials.swift b/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthCredentials.swift index 6a2b93f997..c2e6607558 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthCredentials.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthCredentials.swift @@ -241,10 +241,9 @@ public enum CodexOAuthCredentialsStore { private static func readAuthData(at url: URL) throws -> Data { guard CodexCredentialFileAccess.permits(url) else { throw CodexOAuthCredentialsError.notFound } do { - // Read once instead of checking existence first. Codex publishes auth.json atomically, - // so a single read avoids a TOCTOU window and lets us distinguish a missing file from a - // transiently unreadable/partially published one without logging credentials. - return try CodexCredentialFileAccess.read(at: url, options: [.mappedIfSafe]) + // Keep owned bytes while the owner may replace or truncate auth.json. The OAuth + // strategy retries publication races; retain filesystem error categories here. + return try CodexCredentialFileAccess.read(at: url) } catch { let nsError = error as NSError let missingFile = diff --git a/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift index 6ac0290b99..69f27e8a00 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift @@ -380,25 +380,44 @@ struct CodexOAuthFetchStrategy: ProviderFetchStrategy { let kind: ProviderFetchKind = .oauth func isAvailable(_ context: ProviderFetchContext) async -> Bool { - (try? CodexOAuthCredentialsStore.loadForUsage( - env: context.env, - allowExternalSources: context.settings?.codex?.allowExternalOAuthSources == true)) != nil + await (try? Self.loadCredentials(context, retryStale: false)) != nil } func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { - let credentials = try CodexOAuthCredentialsStore.loadForUsage( - env: context.env, - allowExternalSources: context.settings?.codex?.allowExternalOAuthSources == true) + let credentials = try await Self.loadCredentials(context, retryStale: true) return try await Self.fetch(context: context, credentials: credentials) } + private static func loadCredentials( + _ context: ProviderFetchContext, + retryStale: Bool) async throws -> CodexOAuthCredentials + { + var retriesRemaining = 2 + while true { + try Task.checkCancellation() + do { + let credentials = try CodexOAuthCredentialsStore.loadForUsage( + env: context.env, + allowExternalSources: context.settings?.codex?.allowExternalOAuthSources == true) + if !retryStale || credentials.source != .codexHome || !credentials + .needsRefresh || retriesRemaining == 0 + { + return credentials + } + } catch { + guard retriesRemaining > 0 else { throw error } + } + // The owner may be publishing replacement credentials. Reread without redeeming its token. + retriesRemaining -= 1 + try await Task.sleep(for: .milliseconds(50)) + } + } + private static func fetch( context: ProviderFetchContext, credentials initialCredentials: CodexOAuthCredentials) async throws -> ProviderFetchResult { - var credentials = try await Self.prepareCredentialsForUsage( - initialCredentials, - env: context.env) + var credentials = try Self.prepareCredentialsForUsage(initialCredentials) if let managedWorkspaceAccountID = context.settings?.codex?.managedWorkspaceAccountID, !managedWorkspaceAccountID.isEmpty { @@ -428,7 +447,7 @@ struct CodexOAuthFetchStrategy: ProviderFetchStrategy { credentials: credentials, updatedAt: updatedAt, includeCredits: context.includeCredits, - allowEmptyUsageForResetCreditEnrichment: Self.defersResetCreditFetchToApp(context), + allowEmptyUsageForResetCreditEnrichment: context.runtime == .app, codexResetCreditsAttempted: resetCreditsAttempted) let workspaceBalanceResult = try await Self.applyingWorkspaceRemainingBalance( oauthResult, @@ -444,21 +463,15 @@ struct CodexOAuthFetchStrategy: ProviderFetchStrategy { } private static func prepareCredentialsForUsage( - _ credentials: CodexOAuthCredentials, - env _: [String: String]) async throws -> CodexOAuthCredentials + _ credentials: CodexOAuthCredentials) throws -> CodexOAuthCredentials { guard credentials.needsRefresh else { return credentials } - switch credentials.source { - case .codexHome: - // Codex CLI owns the native auth file and its refresh-token lifecycle. Do not redeem - // that shared token in-process: a rotated response would strand the CLI with the old - // refresh token because CodexBar deliberately never publishes it back to auth.json. - throw CodexOAuthCredentialsError.nativeRefreshRequired - case .legacyCodexHome, .openCode: - // External OAuth files are explicitly read-only and have no safe writer handoff. - // Failing closed avoids consuming a refresh token owned by another application. - throw CodexOAuthCredentialsError.readOnlySource - } + // Native Codex CLI and external applications own their refresh tokens. Redeeming a + // shared token without publishing the rotated response strands its owner with the old + // token. No source has a safe writer handoff from the usage path. + throw credentials.source == .codexHome + ? CodexOAuthCredentialsError.nativeRefreshRequired + : CodexOAuthCredentialsError.readOnlySource } private static func shouldFetchResetCredits(_ context: ProviderFetchContext) -> Bool { @@ -532,12 +545,6 @@ struct CodexOAuthFetchStrategy: ProviderFetchStrategy { creditsAvailable: includeCredits || balance != nil ? creditsAvailable : nil) } - private static func attachingExtraUsage( - to result: ProviderFetchResult) -> ProviderFetchResult - { - self.replacingCredits(in: result, with: result.credits) - } - private static func replacingCredits( in result: ProviderFetchResult, with credits: CreditsSnapshot?) -> ProviderFetchResult @@ -624,69 +631,42 @@ struct CodexOAuthFetchStrategy: ProviderFetchStrategy { credentials: credentials, updatedAt: updatedAt) + let usage: UsageSnapshot if let reconciled { let dataConfidence: UsageDataConfidence = usageResponse.rateLimit?.hasWindowDecodeFailure == true || usageResponse.additionalRateLimitsDecodeFailed ? .unknown : .exact - let result = CodexOAuthFetchStrategy().makeResult( - usage: reconciled.toUsageSnapshot() - .withCodexResetCredits(resetCredits) - .withDataConfidence(dataConfidence), - credits: credits, - sourceLabel: "oauth") - return Self.markResetCreditsAttempted( - Self.attachingExtraUsage(to: result), - attempted: codexResetCreditsAttempted) - } - - guard credits != nil - || (resetCredits?.availableInventory(at: updatedAt).count ?? 0) > 0 - || allowEmptyUsageForResetCreditEnrichment - else { - throw UsageError.noRateLimitsFound - } - - // Credit balances and manual resets remain useful when OAuth omits - // rate-limit windows. Keep the partial result instead of discarding it. - let result = CodexOAuthFetchStrategy().makeResult( - usage: UsageSnapshot( + usage = reconciled.toUsageSnapshot() + .withCodexResetCredits(resetCredits) + .withDataConfidence(dataConfidence) + } else { + guard credits != nil + || (resetCredits?.availableInventory(at: updatedAt).count ?? 0) > 0 + || allowEmptyUsageForResetCreditEnrichment + else { + throw UsageError.noRateLimitsFound + } + // Credit balances and manual resets remain useful when OAuth omits + // rate-limit windows. Keep the partial result instead of discarding it. + usage = UsageSnapshot( primary: nil, secondary: nil, - tertiary: nil, codexResetCredits: resetCredits, updatedAt: updatedAt, identity: CodexReconciledState.oauthIdentity( response: usageResponse, - credentials: credentials)), - credits: credits, - sourceLabel: "oauth") - return Self.markResetCreditsAttempted( - Self.attachingExtraUsage(to: result), - attempted: codexResetCreditsAttempted) - } - - private static func markResetCreditsAttempted( - _ result: ProviderFetchResult, - attempted: Bool) -> ProviderFetchResult - { - guard attempted else { return result } + credentials: credentials)) + } + let strategy = Self() return ProviderFetchResult( - usage: result.usage, - credits: result.credits, - dashboard: result.dashboard, - sourceLabel: result.sourceLabel, - strategyID: result.strategyID, - strategyKind: result.strategyKind, - codexResetCreditsAttempted: true, - codexMonthlyLimitEnrichmentFailed: result.codexMonthlyLimitEnrichmentFailed, - diagnostic: result.diagnostic, - claudeOAuthKeychainPersistentRefHash: result.claudeOAuthKeychainPersistentRefHash, - claudeOAuthHistoryOwnerIdentifier: result.claudeOAuthHistoryOwnerIdentifier, - claudeOAuthCredentialOwner: result.claudeOAuthCredentialOwner, - claudeOAuthKeychainCredentialMismatch: result.claudeOAuthKeychainCredentialMismatch, - claudeOAuthKeychainCredentialAbsent: result.claudeOAuthKeychainCredentialAbsent, - claudeOAuthKeychainCredentialUnavailable: result.claudeOAuthKeychainCredentialUnavailable) + usage: CodexExtraUsageCost.attaching(to: usage, credits: credits), + credits: credits, + dashboard: nil, + sourceLabel: "oauth", + strategyID: strategy.id, + strategyKind: strategy.kind, + codexResetCreditsAttempted: codexResetCreditsAttempted) } private static func replacingWithCLIMonthlyLimitIfAvailable( @@ -815,13 +795,6 @@ struct CodexOAuthFetchStrategy: ProviderFetchStrategy { }) } - private static func defersResetCreditFetchToApp(_ context: ProviderFetchContext) -> Bool { - if case .app = context.runtime { - return true - } - return false - } - private static func fetchResetCreditsIfRequested( context: ProviderFetchContext, credentials: CodexOAuthCredentials, @@ -876,10 +849,9 @@ extension CodexOAuthFetchStrategy { } static func _prepareCredentialsForTesting( - _ credentials: CodexOAuthCredentials, - env: [String: String] = [:]) async throws -> CodexOAuthCredentials + _ credentials: CodexOAuthCredentials) async throws -> CodexOAuthCredentials { - try await self.prepareCredentialsForUsage(credentials, env: env) + try self.prepareCredentialsForUsage(credentials) } static func _applySpendControlsMonthlyLimitForTesting( diff --git a/Tests/CodexBarTests/AgentSessionParserTests.swift b/Tests/CodexBarTests/AgentSessionParserTests.swift index 842347f6e5..0005ef668f 100644 --- a/Tests/CodexBarTests/AgentSessionParserTests.swift +++ b/Tests/CodexBarTests/AgentSessionParserTests.swift @@ -1,6 +1,6 @@ -import CodexBarCore import Foundation import Testing +@testable import CodexBarCore struct AgentSessionParserTests { @Test @@ -112,3 +112,109 @@ struct AgentSessionParserTests { try String(contentsOf: self.fixtureURL(name, extension: fileExtension), encoding: .utf8) } } + +#if os(macOS) +struct ChatGPTCodexProcessTrustTests { + private static let nested = + "/Applications/ChatGPT.app/Contents/Resources/codex-cli/CodexCLI.app/Contents/MacOS/codex" + + @Test(arguments: [ + "/Applications/ChatGPT.app/Contents/Resources/codex", + Self.nested, + ]) + func `signed process validates outer ChatGPT bundle rather than nested CLI bundle`(path: String) { + let trusted = ChatGPTCodexProcessTrust.isTrusted( + 123, + executablePath: { pid in + #expect(pid == 123) + return path + }, + resolvePath: { $0 }, + processIsTrusted: { $0 == 123 }, + appIsTrusted: { bundle in + #expect(bundle == "/Applications/ChatGPT.app") + return true + }) + #expect(trusted) + } + + @Test(arguments: [ + nil, + "/tmp/codex", + "/Users/test/Applications/ChatGPT.app/Contents/Resources/codex", + "/Applications/ChatGPT-copy.app/Contents/Resources/codex", + ] as [String?]) + func `claimed command cannot replace kernel executable identity`(actualPath: String?) { + #expect(!ChatGPTCodexProcessTrust.isTrusted( + 123, + executablePath: { _ in actualPath }, + resolvePath: { $0 }, + processIsTrusted: { _ in + Issue.record("Unrecognized paths must be rejected before signature inspection") + return true + }, + appIsTrusted: { _ in true })) + } + + @Test(arguments: [false, true], [false, true]) + func `running signature and outer bundle assessment must both succeed`(processTrusted: Bool, bundleTrusted: Bool) { + let trusted = ChatGPTCodexProcessTrust.isTrusted( + 123, + executablePath: { _ in Self.nested }, + resolvePath: { $0 }, + processIsTrusted: { _ in processTrusted }, + appIsTrusted: { _ in bundleTrusted }) + #expect(trusted == (processTrusted && bundleTrusted)) + } + + @Test(arguments: [ + "/Users/test/Downloads/codex", + "/tmp/ChatGPT.app/Contents/Resources/codex-cli/CodexCLI.app/Contents/MacOS/codex", + "/Applications/ChatGPT.app/Contents/Resources/other-codex", + ]) + func `symlink redirects cannot authorize scanning even with trusted signatures`(resolvedPath: String) { + #expect(!ChatGPTCodexProcessTrust.isTrusted( + 123, + executablePath: { _ in Self.nested }, + resolvePath: { _ in resolvedPath }, + processIsTrusted: { _ in true }, + appIsTrusted: { _ in true })) + } + + @Test + func `untrusted first candidate cannot hide a later trusted app server`() { + let records = AgentPSOutputParser.parse(""" + 123 1 Mon Jul 6 09:03:00 2026 \(Self.nested) app-server + 124 1 Mon Jul 6 09:03:00 2026 /Applications/ChatGPT.app/Contents/Resources/codex app-server + """) + #expect(AgentPSOutputParser.hasTrustedChatGPTCodexAppServer(in: records, validator: { $0.pid == 124 })) + } + + @Test(arguments: ["exec", "app-server-helper", "--help"]) + func `nested executable requires app server argument and cannot bypass trust as a CLI`(argument: String) { + let records = AgentPSOutputParser.parse("123 1 Mon Jul 6 09:03:00 2026 \(Self.nested) \(argument)") + #expect(!AgentPSOutputParser.hasTrustedChatGPTCodexAppServer(in: records, validator: { _ in + Issue.record("Non-server processes must not reach the app-server validator") + return true + })) + #expect(AgentPSOutputParser.agentProcesses(from: records).isEmpty) + } + + @Test + func `forged app server command cannot borrow installed ChatGPT identity`() throws { + let sleeper = Process() + sleeper.executableURL = URL(fileURLWithPath: "/bin/sleep") + sleeper.arguments = ["30"] + try sleeper.run() + defer { + sleeper.terminate() + sleeper.waitUntilExit() + } + let records = AgentPSOutputParser.parse( + "\(sleeper.processIdentifier) 1 Mon Jul 6 09:03:00 2026 \(Self.nested) app-server") + #expect(!AgentPSOutputParser.hasTrustedChatGPTCodexAppServer(in: records, validator: { + ChatGPTCodexProcessTrust.isTrusted($0.pid) + })) + } +} +#endif diff --git a/Tests/CodexBarTests/ChatGPTBundleTrustCacheTests.swift b/Tests/CodexBarTests/ChatGPTBundleTrustCacheTests.swift new file mode 100644 index 0000000000..9b53ad8e8b --- /dev/null +++ b/Tests/CodexBarTests/ChatGPTBundleTrustCacheTests.swift @@ -0,0 +1,164 @@ +#if os(macOS) +import Foundation +import Testing +@testable import CodexBarCore + +struct ChatGPTBundleTrustCacheTests { + private static let appPath = "/Applications/ChatGPT.app" + private static let executable = "/Applications/ChatGPT.app/Contents/Resources/codex" + + @Test + func `ten scans assess unchanged bundle once and check every running PID`() { + let cache = ChatGPTBundleTrustCache() + var assessments = 0 + var checkedPIDs: [Int32] = [] + for pid in Int32(100)..<110 { + let trusted = ChatGPTCodexProcessTrust.isTrusted( + pid, + executablePath: { _ in Self.executable }, + resolvePath: { $0 }, + processIsTrusted: { checkedPIDs.append($0); return true }, + appIsTrusted: { path in + cache.isTrusted(path, identity: { _ in Self.identity(1) }, assess: { bundle in + #expect(bundle == Self.appPath) + return CodexLaunchPreflight.isLaunchCandidateAllowed( + path: "/synthetic/ChatGPT.app", + fileManager: .default, + hasExtendedAttribute: { _, _ in false }, + spctlAssessment: { assessedPath in + #expect(assessedPath == "/synthetic/ChatGPT.app") + assessments += 1 + return .init( + output: "\(assessedPath): accepted\nsource=Notarized Developer ID", + exitStatus: 0) + }, + appSignatureIsTrusted: { _ in true }, + isMachOExecutable: { _ in false }) + }) + }) + #expect(trusted) + } + #expect(assessments == 1) + #expect(checkedPIDs == Array(Int32(100)..<110)) + print("ChatGPT trust harness: 10 scans, \(assessments) spctl assessment calls, \(checkedPIDs.count) PID checks") + } + + @Test + func `identity changes reassess and failure is retried on the next scan`() { + let cache = ChatGPTBundleTrustCache() + var assessments = 0 + var generation = 1 + var allowed = true + func scan() -> Bool { + cache.isTrusted(Self.appPath, identity: { _ in Self.identity(generation) }, assess: { _ in + assessments += 1 + return allowed + }) + } + #expect(scan()) + #expect(scan()) + #expect(assessments == 1) + generation = 2 + allowed = false + #expect(!scan()) + #expect(!scan()) + #expect(assessments == 3) + allowed = true + #expect(scan()) + #expect(scan()) + #expect(assessments == 4) + } + + @Test + func `missing identity and replacement during assessment fail closed and clear cached success`() { + let cache = ChatGPTBundleTrustCache() + var current: ChatGPTBundleTrustCache.Identity? = Self.identity(1) + var assessments = 0 + func scan(changesDuringAssessment: Bool = false) -> Bool { + cache.isTrusted(Self.appPath, identity: { _ in current }, assess: { _ in + assessments += 1 + if changesDuringAssessment { current = Self.identity(3) } + return true + }) + } + #expect(scan()) + current = nil + #expect(!scan()) + #expect(assessments == 1) + current = Self.identity(1) + #expect(scan()) + #expect(assessments == 2) + current = Self.identity(2) + #expect(!scan(changesDuringAssessment: true)) + #expect(scan()) + #expect(assessments == 4) + } + + @Test(arguments: ["Contents/MacOS/ChatGPT", "Contents/_CodeSignature/CodeResources", "Contents/Info.plist"]) + func `bundle identity detects file modification and replacement`(relativePath: String) throws { + let root = try Self.makeBundle() + defer { try? FileManager.default.removeItem(at: root) } + let original = try #require(ChatGPTBundleTrustCache.identity(root.path)) + #expect(ChatGPTBundleTrustCache.identity(root.path) == original) + let file = root.appendingPathComponent(relativePath) + let attributes = try FileManager.default.attributesOfItem(atPath: file.path) + let modifiedAt = try #require(attributes[.modificationDate] as? Date) + try FileManager.default.setAttributes( + [.modificationDate: modifiedAt.addingTimeInterval(10)], + ofItemAtPath: file.path) + #expect(ChatGPTBundleTrustCache.identity(root.path) != original) + let bytes = try Data(contentsOf: file) + try bytes.write(to: file, options: .atomic) + try FileManager.default.setAttributes([.modificationDate: modifiedAt], ofItemAtPath: file.path) + #expect(ChatGPTBundleTrustCache.identity(root.path) != original) + } + + @Test + func `bundle identity rejects missing seal and symlink redirected files`() throws { + let root = try Self.makeBundle() + defer { try? FileManager.default.removeItem(at: root) } + let seal = root.appendingPathComponent("Contents/_CodeSignature/CodeResources") + try FileManager.default.removeItem(at: seal) + #expect(ChatGPTBundleTrustCache.identity(root.path) == nil) + try FileManager.default.createSymbolicLink( + at: seal, + withDestinationURL: root.appendingPathComponent("Contents/Info.plist")) + #expect(ChatGPTBundleTrustCache.identity(root.path) == nil) + } + + @Test(arguments: [false, true]) + func `warm bundle cache cannot bypass process signature or symlink rejection`(redirected: Bool) { + let cache = ChatGPTBundleTrustCache() + #expect(cache.isTrusted(Self.appPath, identity: { _ in Self.identity(1) }, assess: { _ in true })) + #expect(!ChatGPTCodexProcessTrust.isTrusted( + 123, + executablePath: { _ in Self.executable }, + resolvePath: { redirected ? "/tmp/codex" : $0 }, + processIsTrusted: { _ in redirected }, + appIsTrusted: { _ in + Issue.record("Rejected processes must not reach even a warm bundle cache") + return true + })) + } + + private static func identity(_ generation: Int) -> ChatGPTBundleTrustCache.Identity { + [URL(fileURLWithPath: self.appPath): ["generation": generation] as NSDictionary] + } + + private static func makeBundle() throws -> URL { + let root = FileManager.default.temporaryDirectory.resolvingSymlinksInPath() + .appendingPathComponent("chatgpt-trust-\(UUID().uuidString).app") + for directory in ["Contents/MacOS", "Contents/_CodeSignature"] { + try FileManager.default.createDirectory( + at: root.appendingPathComponent(directory), withIntermediateDirectories: true) + } + let info = try PropertyListSerialization.data( + fromPropertyList: ["CFBundleExecutable": "ChatGPT", "CFBundleVersion": "1"], format: .xml, options: 0) + try info.write(to: root.appendingPathComponent("Contents/Info.plist")) + for file in ["Contents/MacOS/ChatGPT", "Contents/_CodeSignature/CodeResources"] { + try Data("synthetic".utf8).write(to: root.appendingPathComponent(file)) + } + return root + } +} +#endif diff --git a/Tests/CodexBarTests/CodexOAuthCredentialReadTests.swift b/Tests/CodexBarTests/CodexOAuthCredentialReadTests.swift index 698694ff91..66e6d794d5 100644 --- a/Tests/CodexBarTests/CodexOAuthCredentialReadTests.swift +++ b/Tests/CodexBarTests/CodexOAuthCredentialReadTests.swift @@ -246,9 +246,7 @@ struct CodexOAuthCredentialReadTests { homeDirectory: home, allowExternalSources: true) let error = await #expect(throws: CodexOAuthCredentialsError.self) { - try await CodexOAuthFetchStrategy._prepareCredentialsForTesting( - credentials, - env: ["XDG_DATA_HOME": dataHome.path]) + try await CodexOAuthFetchStrategy._prepareCredentialsForTesting(credentials) } guard case .readOnlySource = error else { Issue.record("Expired external credentials must fail closed") @@ -321,9 +319,7 @@ struct CodexOAuthCredentialReadTests { lastRefresh: Date(timeIntervalSince1970: 0), source: .codexHome) let error = await #expect(throws: CodexOAuthCredentialsError.self) { - try await CodexOAuthFetchStrategy._prepareCredentialsForTesting( - credentials, - env: ["CODEX_HOME": "/tmp/codexbar-native-refresh-memory"]) + try await CodexOAuthFetchStrategy._prepareCredentialsForTesting(credentials) } guard case .nativeRefreshRequired = error else { Issue.record("Native stale credentials must be handed to Codex CLI") diff --git a/Tests/CodexBarTests/CodexOAuthExpiryPipelineTests.swift b/Tests/CodexBarTests/CodexOAuthExpiryPipelineTests.swift index 3823502268..4817bf7e10 100644 --- a/Tests/CodexBarTests/CodexOAuthExpiryPipelineTests.swift +++ b/Tests/CodexBarTests/CodexOAuthExpiryPipelineTests.swift @@ -4,6 +4,127 @@ import Testing @Suite(CodexCredentialFixtures()) struct CodexOAuthExpiryPipelineTests { + private typealias Reader = @Sendable (CodexCredentialFileAccess.Operation, URL) throws -> Data + + @Test(arguments: ["missing", "partial", "incomplete", "expired", "near-expiry"]) + func `OAuth fetch retries an owner publication in progress`(publication: String) async throws { + let fresh = try Self.fixture(expiration: 4_102_444_800, lastRefresh: "2000-01-01T00:00:00Z") + let stale = try Self.fixture( + expiration: publication == "near-expiry" ? Int64(Date().timeIntervalSince1970 + 120) : 1, + lastRefresh: "2000-01-01T00:00:00Z") + let reads = LockIsolated(0) + let transport = ProviderHTTPTransportStub { request in + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer \(fresh.token)") + #expect(request.value(forHTTPHeaderField: "ChatGPT-Account-Id") == "fixture-workspace") + return try Self.response(request, body: Self.usageBody) + } + let reader: Reader = { operation, url in + guard case .read = operation else { return Data(url.resolvingSymlinksInPath().path.utf8) } + let attempt = reads.value + 1 + reads.setValue(attempt) + guard attempt == 1 else { return fresh.data } + switch publication { + case "missing": throw CocoaError(.fileReadNoSuchFile) + case "partial": return Data(#"{"tokens":"#.utf8) + case "incomplete": return Data(#"{"tokens":{}}"#.utf8) + default: return stale.data + } + } + let result = try await CodexCredentialFileAccess.$testIO.withValue(reader) { + try await CodexAuthenticatedHTTPTransport.$overrideForTesting.withValue(transport) { + try await CodexOAuthFetchStrategy().fetch(Self.context(mode: .oauth, managed: true, home: fresh.home)) + } + } + #expect(result.usage.primary?.usedPercent == 22) + #expect(reads.value == 2) + #expect(await transport.requests().count == 1) + try fresh.expectUnchanged() + } + + @Test + func `OAuth availability retries a partial credential publication`() async throws { + let fresh = try Self.fixture(expiration: 4_102_444_800, lastRefresh: "2000-01-01T00:00:00Z") + let reads = LockIsolated(0) + let reader: Reader = { operation, url in + guard case .read = operation else { return Data(url.resolvingSymlinksInPath().path.utf8) } + reads.setValue(reads.value + 1) + return reads.value == 1 ? Data("{".utf8) : fresh.data + } + let available = await CodexCredentialFileAccess.$testIO.withValue(reader) { + await CodexOAuthFetchStrategy().isAvailable(Self.context(mode: .auto, managed: true, home: fresh.home)) + } + #expect(available) + #expect(reads.value == 2) + } + + @Test(arguments: ["missing", "partial", "incomplete", "expired", "unreadable"]) + func `OAuth read retries are bounded and preserve the final error`(failure: String) async throws { + let stale = try Self.fixture(expiration: 1, lastRefresh: "2000-01-01T00:00:00Z") + let reads = LockIsolated(0) + let transport = ProviderHTTPTransportStub { _ in + Issue.record("Unusable credentials must never reach HTTP") + throw URLError(.cancelled) + } + let reader: Reader = { operation, url in + guard case .read = operation else { return Data(url.resolvingSymlinksInPath().path.utf8) } + reads.setValue(reads.value + 1) + switch failure { + case "missing": throw CocoaError(.fileReadNoSuchFile) + case "unreadable": throw CocoaError(.fileReadNoPermission) + case "partial": return Data("{".utf8) + case "incomplete": return Data(#"{"tokens":{}}"#.utf8) + default: return stale.data + } + } + await CodexCredentialFileAccess.$testIO.withValue(reader) { + await CodexAuthenticatedHTTPTransport.$overrideForTesting.withValue(transport) { + do { + _ = try await CodexOAuthFetchStrategy().fetch( + Self.context(mode: .oauth, managed: true, home: stale.home)) + Issue.record("Expected a credential error") + } catch let error as CodexOAuthCredentialsError { + switch (failure, error) { + case ("missing", .notFound), ("partial", .decodeFailed), ("incomplete", .missingTokens), + ("expired", .nativeRefreshRequired), ("unreadable", .unreadable): break + default: Issue.record("The final credential failure was misclassified") + } + } catch { + Issue.record("Unexpected error type") + } + } + } + #expect(reads.value == 3) + #expect(await transport.requests().isEmpty) + try stale.expectUnchanged() + } + + @Test + func `cancelled OAuth fetch does not read credentials`() async throws { + let fresh = try Self.fixture(expiration: 4_102_444_800, lastRefresh: "2000-01-01T00:00:00Z") + let reads = LockIsolated(0) + let reader: Reader = { operation, url in + guard case .read = operation else { return Data(url.resolvingSymlinksInPath().path.utf8) } + reads.setValue(reads.value + 1) + throw CocoaError(.fileReadNoSuchFile) + } + let task = Task { + withUnsafeCurrentTask { $0?.cancel() } + return await CodexCredentialFileAccess.$testIO.withValue(reader) { + do { + _ = try await CodexOAuthFetchStrategy().fetch( + Self.context(mode: .oauth, managed: true, home: fresh.home)) + return false + } catch is CancellationError { + return true + } catch { + return false + } + } + } + #expect(await task.value) + #expect(reads.value == 0) + } + @Test(arguments: [ProviderSourceMode.auto, .oauth]) func `managed refresh observes owner credential replacement on the next fetch`( mode: ProviderSourceMode) async throws diff --git a/Tests/CodexBarTests/CodexPlanTransitionPublicationTests.swift b/Tests/CodexBarTests/CodexPlanTransitionPublicationTests.swift new file mode 100644 index 0000000000..4f54d927c6 --- /dev/null +++ b/Tests/CodexBarTests/CodexPlanTransitionPublicationTests.swift @@ -0,0 +1,171 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct CodexPlanTransitionPublicationTests { + private let epoch = Int(Date().timeIntervalSince1970) - 30 + + @Test + func `new plan cannot borrow missing weekly usage from the old plan`() async throws { + let previous = try self.snapshot(plan: "plus", usedPercent: 80, offset: 0, resetOffset: 86400) + let current = try self.snapshot(plan: "pro", usedPercent: 5, offset: 10, resetOffset: 3600) + .with(primary: nil, secondary: nil) + #expect(UsageStore.codexBackfillingResetWindows(current, from: previous).secondary == nil) + let admission = await UsageStore.codexOutcomeAdmittedForPublication( + initialOutcome: self.outcome(current), + previousSnapshot: previous, + previousSourceLabel: "oauth", + missingWindowBackfillSnapshot: previous, + fetchConfirmation: { self.outcome(current) }) + let published = try #require(admission.outcome).result.get().usage + #expect(published.secondary == nil) + } + + @Test(arguments: [0, 5], [false, true]) + func `new token plan replaces previous plan quota baseline`( + usedPercent: Int, missingPrevious: Bool) async throws + { + let previous = try self.snapshot(plan: "plus", usedPercent: 80, offset: 0, resetOffset: 86400) + let current = try self.snapshot(plan: "pro", usedPercent: usedPercent, offset: 10, resetOffset: 3600) + let confirmation = try self.snapshot(plan: "pro", usedPercent: usedPercent, offset: 20, resetOffset: 3600) + let admission = await UsageStore.codexOutcomeAdmittedForPublication( + initialOutcome: self.outcome(current), + previousSnapshot: missingPrevious ? nil : previous, + previousSourceLabel: "oauth", + missingWindowBackfillSnapshot: previous, + fetchConfirmation: { self.outcome(confirmation) }) + let published = try #require(admission.outcome).result.get().usage + #expect(published.loginMethod(for: .codex) == "pro") + #expect(published.secondary?.usedPercent == Double(usedPercent)) + #expect(published.secondary?.resetsAt == current.secondary?.resetsAt) + #expect(admission.pendingCandidate == nil) + } + + @Test(arguments: ["plus", " PLUS ", ""]) + func `same or unknown token plan cannot discard previous quota evidence`(plan: String) async throws { + let previous = try self.snapshot(plan: "plus", usedPercent: 80, offset: 0, resetOffset: 86400) + let current = try self.snapshot(plan: plan, usedPercent: 0, offset: 10, resetOffset: 3600) + let admission = await UsageStore.codexOutcomeAdmittedForPublication( + initialOutcome: self.outcome(current), + previousSnapshot: previous, + previousSourceLabel: "oauth", + missingWindowBackfillSnapshot: previous, + fetchConfirmation: { self.outcome(current) }) + #expect(admission.outcome == nil) + } + + @Test(arguments: ["plus", ""], [false, true]) + func `near zero confirmation must retain the initial plan`(plan: String, hasPrevious: Bool) async throws { + let previous = try self.snapshot(plan: "plus", usedPercent: 80, offset: 0, resetOffset: 86400) + let initial = try self.snapshot(plan: "pro", usedPercent: 0, offset: 10, resetOffset: 3600) + let confirmation = try self.snapshot(plan: plan, usedPercent: 0, offset: 20, resetOffset: 3600) + let admission = await UsageStore.codexOutcomeAdmittedForPublication( + initialOutcome: self.outcome(initial), + previousSnapshot: hasPrevious ? previous : nil, + previousSourceLabel: "oauth", + missingWindowBackfillSnapshot: hasPrevious ? previous : nil, + fetchConfirmation: { self.outcome(confirmation) }) + #expect(admission.outcome == nil) + #expect(admission.pendingCandidate == nil) + } + + @Test + func `fresh nonzero confirmation can publish its own plan`() async throws { + let initial = try self.snapshot(plan: "pro", usedPercent: 0, offset: 10, resetOffset: 3600) + let confirmation = try self.snapshot(plan: "plus", usedPercent: 5, offset: 20, resetOffset: 3600) + let admission = await UsageStore.codexOutcomeAdmittedForPublication( + initialOutcome: self.outcome(initial), + previousSnapshot: nil, + previousSourceLabel: nil, + missingWindowBackfillSnapshot: nil, + fetchConfirmation: { self.outcome(confirmation) }) + let published = try #require(admission.outcome).result.get().usage + #expect(published.loginMethod(for: .codex) == "plus") + #expect(published.secondary?.usedPercent == 5) + } + + @Test(arguments: [false, true]) + func `older or incomplete new plan cannot discard previous quota evidence`(older: Bool) async throws { + let previous = try self.snapshot(plan: "plus", usedPercent: 80, offset: 0, resetOffset: 86400) + let current = try self.snapshot(plan: "pro", usedPercent: 0, offset: older ? -1 : 10, resetOffset: 3600) + .withDataConfidence(older ? .exact : .unknown) + let admission = await UsageStore.codexOutcomeAdmittedForPublication( + initialOutcome: self.outcome(current), + previousSnapshot: previous, + previousSourceLabel: "oauth", + missingWindowBackfillSnapshot: previous, + fetchConfirmation: { self.outcome(current) }) + #expect(admission.outcome == nil) + } + + fileprivate func snapshot(plan: String, usedPercent: Int, offset: Int, resetOffset: Int) throws -> UsageSnapshot { + let epoch = self.epoch + let payload = try JSONSerialization.data(withJSONObject: [ + "email": "fixture@example.com", + "https://api.openai.com/auth": ["chatgpt_plan_type": plan], + ]).base64EncodedString() + let credentials = CodexOAuthCredentials( + accessToken: "fixture-access", + refreshToken: "fixture-refresh", + idToken: "fixture.\(payload).signature", + accountId: "fixture-account", + lastRefresh: nil) + let body = """ + {"rate_limit":{"primary_window":{"used_percent":5,"reset_at":\(epoch + 3600), + "limit_window_seconds":18000},"secondary_window":{"used_percent":\(usedPercent), + "reset_at":\(epoch + resetOffset),"limit_window_seconds":604800}}} + """ + let response = try JSONDecoder().decode(CodexUsageResponse.self, from: Data(body.utf8)) + let reconciled = try #require(CodexReconciledState.fromOAuth( + response: response, + credentials: credentials, + updatedAt: Date(timeIntervalSince1970: Double(epoch + offset)))) + return reconciled.toUsageSnapshot().withDataConfidence(.exact) + } + + private func outcome(_ snapshot: UsageSnapshot) -> ProviderFetchOutcome { + let result = ProviderFetchResult( + usage: snapshot, + credits: nil, + dashboard: nil, + sourceLabel: "oauth", + strategyID: "codex.oauth", + strategyKind: .oauth) + return ProviderFetchOutcome(result: .success(result), attempts: []) + } +} + +@MainActor +extension CodexAccountScopedRefreshTests { + @Test + func `subscription upgrade publishes new plan and quota without disabling Codex`() async throws { + let suite = "CodexPlanTransitionPublicationTests-upgrade" + let settings = self.makeSettingsStore(suite: suite) + settings.refreshFrequency = .manual + settings.codexCookieSource = .off + settings._test_liveSystemCodexAccount = self.liveAccount( + email: "fixture@example.com", identity: .providerAccount(id: "fixture-account")) + defer { settings._test_liveSystemCodexAccount = nil } + let fixture = CodexPlanTransitionPublicationTests() + let previous = try fixture.snapshot(plan: "plus", usedPercent: 80, offset: 0, resetOffset: 86400) + let current = try fixture.snapshot(plan: "pro", usedPercent: 0, offset: 10, resetOffset: 3600) + let confirmation = try fixture.snapshot(plan: "pro", usedPercent: 0, offset: 20, resetOffset: 3600) + let store = self.makeCodexWeeklyPublicationStore(settings: settings, suite: suite) + _ = await self.seedCodexWeeklyPublicationState( + store: store, settings: settings, snapshot: previous, error: nil) + store.lastSourceLabels[.codex] = "oauth" + let loader = SequencedCodexSnapshotLoader(steps: [.success(current), .success(confirmation)]) + self.installContextualCodexProvider(on: store, sourceLabel: "oauth", kind: .oauth) { _ in + try await loader.load() + } + + await store.refreshProvider(.codex, allowDisabled: true) + + #expect(store.snapshots[.codex]?.loginMethod(for: .codex) == "pro") + #expect(store.snapshots[.codex]?.secondary?.usedPercent == 0) + #expect(store.lastKnownResetSnapshots[.codex]?.loginMethod(for: .codex) == "pro") + #expect(store.errors[.codex] == nil) + #expect(await loader.callCount == 2) + } +} diff --git a/Tests/CodexBarTests/CodexSessionRolloutTests.swift b/Tests/CodexBarTests/CodexSessionRolloutTests.swift index d7c1f0b9c1..0ec9e6b5dc 100644 --- a/Tests/CodexBarTests/CodexSessionRolloutTests.swift +++ b/Tests/CodexBarTests/CodexSessionRolloutTests.swift @@ -5,9 +5,15 @@ import SQLite3 import CSQLite3 #endif import Testing +@testable import CodexBar @testable import CodexBarCore struct CodexSessionRolloutTests { + private static let chatGPTExecutables = [ + "/Applications/ChatGPT.app/Contents/Resources/codex", + "/Applications/ChatGPT.app/Contents/Resources/codex-cli/CodexCLI.app/Contents/MacOS/codex", + ] + @Test func `first rollout line maps to file only agent session`() throws { let url = try AgentSessionParserTests.fixtureURL("agent-session-rollout", extension: "jsonl") @@ -68,10 +74,12 @@ struct CodexSessionRolloutTests { #expect(!AgentSessionCorrelation.codexWorkingDirectoriesMatch("/repo/alpha", nil)) } - @Test - func `trusted chatgpt app server projects recent codex rollout activity without an agent process`() async throws { + @Test(arguments: Self.chatGPTExecutables) + func `trusted chatgpt app server projects recent codex rollout activity without an agent process`( + executable: String) async throws + { let now = Date() - let fixture = try Self.makeAdaptiveChatGPTFixture(now: now, rolloutAge: 30) + let fixture = try Self.makeAdaptiveChatGPTFixture(now: now, rolloutAge: 30, appServerExecutable: executable) defer { try? FileManager.default.removeItem(at: fixture.root) } let sessions = await fixture.scanner.scan( @@ -88,10 +96,15 @@ struct CodexSessionRolloutTests { #expect(try abs(#require(session.lastActivityAt).timeIntervalSince(now.addingTimeInterval(-30))) < 0.01) } - @Test - func `idle chatgpt app server with a stale rollout does not produce coding activity`() async throws { + @Test(arguments: Self.chatGPTExecutables) + func `idle chatgpt app server with a stale rollout does not produce coding activity`( + executable: String) async throws + { let now = Date() - let fixture = try Self.makeAdaptiveChatGPTFixture(now: now, rolloutAge: 31 * 60) + let fixture = try Self.makeAdaptiveChatGPTFixture( + now: now, + rolloutAge: 31 * 60, + appServerExecutable: executable) defer { try? FileManager.default.removeItem(at: fixture.root) } let sessions = await fixture.scanner.scan( @@ -102,10 +115,12 @@ struct CodexSessionRolloutTests { #expect(sessions.isEmpty) } - @Test - func `continuing an existing chatgpt codex rollout advances the adaptive activity signal`() async throws { + @Test(arguments: Self.chatGPTExecutables) + func `continuing an existing chatgpt codex rollout advances the adaptive activity signal`( + executable: String) async throws + { let now = Date() - let fixture = try Self.makeAdaptiveChatGPTFixture(now: now, rolloutAge: 30) + let fixture = try Self.makeAdaptiveChatGPTFixture(now: now, rolloutAge: 30, appServerExecutable: executable) defer { try? FileManager.default.removeItem(at: fixture.root) } let firstSessions = await fixture.scanner.scan( @@ -129,10 +144,11 @@ struct CodexSessionRolloutTests { #expect(abs(continuedActivity.timeIntervalSince(nextActivity)) < 0.01) } - @Test - func `untrusted chatgpt app server cannot authorize adaptive rollout inspection`() async throws { + @Test(arguments: Self.chatGPTExecutables) + func `untrusted chatgpt app server cannot authorize adaptive rollout inspection`(executable: String) async throws { let now = Date() - let fixture = try Self.makeAdaptiveChatGPTFixture(now: now, rolloutAge: 30, appServerIsTrusted: false) + let fixture = try Self.makeAdaptiveChatGPTFixture( + now: now, rolloutAge: 30, appServerExecutable: executable, appServerIsTrusted: false) defer { try? FileManager.default.removeItem(at: fixture.root) } let sessions = await fixture.scanner.scan( @@ -143,13 +159,20 @@ struct CodexSessionRolloutTests { #expect(sessions.isEmpty) } - @Test - func `unrelated chatgpt named bundle cannot authorize adaptive rollout inspection`() async throws { + @Test(arguments: [ + "codex", + "/tmp/codex", + "/tmp/ChatGPT.app/Contents/Resources/codex", + "/Applications/ChatGPT.app/Contents/Resources/codex", + "/Applications/ChatGPT.app/Contents/Resources/codex-cli/CodexCLI.app/Contents/MacOS/codex-fake", + "/Applications/ChatGPT.app/Contents/Resources/../Resources/codex", + ]) + func `unrecognized app server path cannot authorize adaptive rollout inspection`(executable: String) async throws { let now = Date() let fixture = try Self.makeAdaptiveChatGPTFixture( now: now, rolloutAge: 30, - appServerExecutable: "/tmp/ChatGPT.app/Contents/Resources/codex") + appServerExecutable: executable) defer { try? FileManager.default.removeItem(at: fixture.root) } let sessions = await fixture.scanner.scan( @@ -353,6 +376,48 @@ struct CodexSessionRolloutTests { #expect(sessions.allSatisfy { $0.sessionName == nil }) } + @Test(arguments: Self.chatGPTExecutables, [30.0, 6 * 60.0]) + func `chatgpt rollout freshness controls five versus thirty minute cadence`( + executable: String, age: TimeInterval) async throws + { + let now = Date() + let fixture = try Self.makeAdaptiveChatGPTFixture(now: now, rolloutAge: age, appServerExecutable: executable) + defer { try? FileManager.default.removeItem(at: fixture.root) } + let sessions = await fixture.scanner.scan( + now: now, environment: fixture.environment, includeFileOnlySessions: false) + let decision = UsageStore.adaptiveRefreshDecision( + now: now, + lastMenuOpenAt: nil, + lastCodingActivityAt: AgentSessionsStore.latestActivityAt(in: sessions), + lowPowerModeEnabled: false, + thermalState: .nominal) + + #expect(decision.reason == (age < 300 ? .codingActivity : .longIdle)) + #expect(decision.delay == .seconds(age < 300 ? 300 : 1800)) + } + + @Test(arguments: Self.chatGPTExecutables) + func `app server trust is revalidated after a successful scan`(executable: String) async throws { + let marker = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try Data().write(to: marker) + defer { try? FileManager.default.removeItem(at: marker) } + let now = Date() + let fixture = try Self.makeAdaptiveChatGPTFixture( + now: now, + rolloutAge: 30, + appServerExecutable: executable, + appServerTrustValidator: { _ in FileManager.default.fileExists(atPath: marker.path) }) + defer { try? FileManager.default.removeItem(at: fixture.root) } + + let trusted = await fixture.scanner.scan( + now: now, environment: fixture.environment, includeFileOnlySessions: false) + #expect(trusted.count == 1) + try FileManager.default.removeItem(at: marker) + let untrusted = await fixture.scanner.scan( + now: now, environment: fixture.environment, includeFileOnlySessions: false) + #expect(untrusted.isEmpty) + } + private struct AdaptiveChatGPTFixture { let root: URL let rollout: URL @@ -364,7 +429,9 @@ struct CodexSessionRolloutTests { now: Date, rolloutAge: TimeInterval, appServerExecutable: String = "/Applications/ChatGPT.app/Contents/Resources/codex", - appServerIsTrusted: Bool = true) throws -> AdaptiveChatGPTFixture + appServerIsTrusted: Bool = true, + appServerTrustValidator: LocalAgentSessionScanner + .AppServerTrustValidator? = nil) throws -> AdaptiveChatGPTFixture { let fileManager = FileManager.default let root = fileManager.temporaryDirectory @@ -385,13 +452,14 @@ struct CodexSessionRolloutTests { [.modificationDate: now.addingTimeInterval(-rolloutAge)], ofItemAtPath: rollout.path) + let executable = appServerExecutable.replacingOccurrences(of: "", with: root.path) let scanner = LocalAgentSessionScanner( processOutputProvider: { _ in - "4234 1 Mon Jul 6 09:03:00 2026 \(appServerExecutable) " + + "4234 1 Mon Jul 6 09:03:00 2026 \(executable) " + "-c features.code_mode_host=true app-server --analytics-default-enabled" }, cwdProvider: { _, _ in [:] }, - appServerTrustValidator: { _ in appServerIsTrusted }) + appServerTrustValidator: appServerTrustValidator ?? { _ in appServerIsTrusted }) return AdaptiveChatGPTFixture( root: root, rollout: rollout, diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index 02e0a6d600..2cf24015a8 100644 --- a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift +++ b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift @@ -1239,6 +1239,11 @@ struct ProviderArchitectureGatekeeperTests { anchor: "self.sessionEquivalentBurnCache.removeValue(forKey: .codex)", expectedProviderIDs: ["codex"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), + SuppressedProviderReference( + path: "Sources/CodexBar/UsageStore+Refresh.swift", + anchor: "let resetBackfillSource = provider == .codex && Self.codexPlanChanged(from: resetBackfillSource, to: snapshot)", + expectedProviderIDs: ["codex"], + reason: "Codex subscription changes must not inherit reset times from the previous plan."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+Refresh.swift", anchor: "previousSourceLabel: hydratedPrior?.sourceLabel ?? self.lastSourceLabels[.codex],", @@ -1505,11 +1510,6 @@ struct ProviderArchitectureGatekeeperTests { anchor: "provider: .codex,", expectedProviderIDs: ["codex"], reason: "This provider-specific core branch passes its already-selected identity to a shared helper."), - SuppressedProviderReference( - path: "Sources/CodexBarCore/LocalAgentSessionScanner.swift", - anchor: "provider: .codex,", - expectedProviderIDs: ["codex"], - reason: "This provider-specific core branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardBrowserCookieImporter.swift", anchor: "CookieHeaderCache.loadSerialized(provider: .codex, scope: cacheScope)", @@ -3385,13 +3385,6 @@ struct ProviderArchitectureGatekeeperTests { expectedReferenceCount: 4, expectedReferenceFingerprint: ["pi@0", "pi@1", "claude@13", "codex@19"], reason: "This exact host integration maps a provider-owned process, path, or window contract."), - AllowedProviderConstruct( - path: "Sources/CodexBarCore/AgentSession.swift", - anchor: "guard record.executableBasename.lowercased() == AgentSession.Provider.codex.rawValue,", - expectedProviderIDs: ["codex"], - expectedReferenceCount: 1, - expectedReferenceFingerprint: ["codex@0"], - reason: "This exact host integration recognizes only the Codex app-server bundled in ChatGPT.app."), AllowedProviderConstruct( path: "Sources/CodexBarCore/AgentSession.swift", anchor: "URL(fileURLWithPath: $0).lastPathComponent == AgentSession.Provider.claude.rawValue", @@ -3458,10 +3451,10 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."), AllowedProviderConstruct( path: "Sources/CodexBarCore/LocalAgentSessionScanner.swift", - anchor: "guard AgentPSOutputParser.provider(for: process) == .codex else { return nil }", + anchor: "let codexCWDs = processes.filter { AgentPSOutputParser.provider(for: $0) == .codex }", expectedProviderIDs: ["codex"], expectedReferenceCount: 2, - expectedReferenceFingerprint: ["codex@0", "codex@4"], + expectedReferenceFingerprint: ["codex@0", "codex@3"], reason: "This exact host integration maps a provider-owned process, path, or window contract."), AllowedProviderConstruct( path: "Sources/CodexBarCore/LocalAgentSessionScanner.swift", @@ -3473,16 +3466,9 @@ struct ProviderArchitectureGatekeeperTests { AllowedProviderConstruct( path: "Sources/CodexBarCore/LocalAgentSessionScanner.swift", anchor: "let codexProcesses = processes.filter { AgentPSOutputParser.provider(for: $0) == .codex }", - expectedProviderIDs: ["claude", "codex"], - expectedReferenceCount: 3, - expectedReferenceFingerprint: ["codex@0", "claude@9", "claude@13"], - reason: "This exact host integration maps a provider-owned process, path, or window contract."), - AllowedProviderConstruct( - path: "Sources/CodexBarCore/LocalAgentSessionScanner.swift", - anchor: "case .codex:", - expectedProviderIDs: ["codex"], - expectedReferenceCount: 1, - expectedReferenceFingerprint: ["codex@0"], + expectedProviderIDs: ["claude", "codex", "pi"], + expectedReferenceCount: 5, + expectedReferenceFingerprint: ["codex@0", "pi@7", "codex@9", "claude@14", "claude@22"], reason: "This exact host integration maps a provider-owned process, path, or window contract."), AllowedProviderConstruct( path: "Sources/CodexBarCore/OpenAIDashboardModels.swift", diff --git a/Tests/CodexBarTests/WidgetEmptyProjectionTests.swift b/Tests/CodexBarTests/WidgetEmptyProjectionTests.swift index e06848610e..08db3f6e0b 100644 --- a/Tests/CodexBarTests/WidgetEmptyProjectionTests.swift +++ b/Tests/CodexBarTests/WidgetEmptyProjectionTests.swift @@ -1,11 +1,82 @@ +import AppKit import CodexBarCore import Foundation +import SwiftUI import Testing +import WidgetKit @testable import CodexBar +@testable import CodexBarWidget @Suite(.serialized, ProviderTransportRegressionFixtures()) @MainActor struct WidgetEmptyProjectionTests { + @Test(arguments: ["claude", "disabled", "retired", "partial"]) + func `one ineligible provider cannot erase another providers last good widget reading`( + scenario: String) async throws + { + let (store, settings) = self.makeStore(providers: [.minimax, .deepseek, .claude]) + var saved: WidgetSnapshot? + store._test_widgetSnapshotSaveOverride = { saved = $0 } + self.seed(store, measuredAt: Date().addingTimeInterval(-3600)) + if scenario == "claude" { self.seed(store, providers: [.claude]) } + store.persistWidgetSnapshot(reason: "synthetic-before-wake") + await store.widgetSnapshotPersistTask?.value + let before = try #require(saved?.entries.first { $0.provider == .deepseek }) + #expect(before.balanceText == "$25.00") + store.snapshots.removeAll() + store.errors = [ + .minimax: "Synthetic offline failure", + .deepseek: "Synthetic offline failure", + .claude: "Synthetic offline failure", + ] + switch scenario { + case "claude": store.widgetUsagePreservationBlockedProviders.insert(.claude) + case "disabled": + settings.setProviderEnabled(provider: .minimax, metadata: store.metadata(for: .minimax), enabled: false) + case "retired": + store.clearProviderRuntimeState(.minimax) + store.errors[.minimax] = "Synthetic offline failure" + case "partial": self.seed(store, providers: [.minimax]) + default: break + } + store.persistWidgetSnapshot(reason: "synthetic-after-wake") + await store.widgetSnapshotPersistTask?.value + try self.renderProof(#require(saved), scenario: scenario) + let after = try #require(saved?.entries.first { $0.provider == .deepseek }) + #expect(after.updatedAt == before.updatedAt) + #expect(after.primary == before.primary) + #expect(after.balanceText == before.balanceText) + #expect(saved?.entries.contains { $0.provider == .claude } == false) + if scenario == "disabled" || scenario == "retired" { + #expect(saved?.entries.contains { $0.provider == .minimax } == false) + } + } + + private func renderProof(_ snapshot: WidgetSnapshot, scenario: String) throws { + guard let path = ProcessInfo.processInfo.environment["CODEXBAR_WIDGET_RETENTION_PROOF_DIR"] else { return } + let output = URL(fileURLWithPath: path, isDirectory: true) + try FileManager.default.createDirectory(at: output, withIntermediateDirectories: true) + let entry = CodexBarSwitcherEntry( + date: snapshot.generatedAt, + provider: .deepseek, + availableProviders: [.minimax, .deepseek, .claude], + snapshot: snapshot) + let view = CodexBarSwitcherWidgetView(entry: entry) + .environment(\.widgetRenderingMode, .fullColor) + .environment(\.colorScheme, .light) + .padding(14) + .frame(width: 360, height: 170) + .background(.background) + let hosting = NSHostingView(rootView: view) + hosting.frame = NSRect(x: 0, y: 0, width: 360, height: 170) + hosting.appearance = NSAppearance(named: .aqua) + hosting.layoutSubtreeIfNeeded() + let bitmap = try #require(hosting.bitmapImageRepForCachingDisplay(in: hosting.bounds)) + hosting.cacheDisplay(in: hosting.bounds, to: bitmap) + try #require(bitmap.representation(using: .png, properties: [:])) + .write(to: output.appendingPathComponent("\(scenario).png")) + } + @Test(arguments: [false, true]) func `all failed providers retain published entries and original ages`(queued: Bool) async throws { let (store, settings) = self.makeStore() @@ -63,7 +134,12 @@ struct WidgetEmptyProjectionTests { store.persistWidgetSnapshot(reason: "synthetic-invalidation") await store.widgetSnapshotPersistTask?.value if scenario == "cold-start" { saved = WidgetSnapshotStore.load(from: url) } - #expect(saved?.entries.count == (scenario == "partial" ? 1 : 0)) + let expected: Set = switch scenario { + case "disabled", "blocked", "retired": [.deepseek] + case "partial": [.minimax, .deepseek] + default: [] + } + #expect(Set(saved?.entries.map(\.provider) ?? []) == expected) } @Test(arguments: [false, true]) @@ -334,13 +410,21 @@ struct WidgetEmptyProjectionTests { return (store, settings) } - private func seed(_ store: UsageStore, providers: [UsageProvider] = [.minimax, .deepseek]) { + private func seed( + _ store: UsageStore, + providers: [UsageProvider] = [.minimax, .deepseek], + measuredAt: Date = Date(timeIntervalSince1970: 1_800_000_000)) + { for (index, provider) in providers.enumerated() { store._setSnapshotForTesting( UsageSnapshot( - primary: RateWindow(usedPercent: 25, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + primary: RateWindow( + usedPercent: 25, + windowMinutes: 300, + resetsAt: nil, + resetDescription: provider == .deepseek ? "$25.00 (Paid: $25.00 / Granted: $0.00)" : nil), secondary: nil, - updatedAt: Date(timeIntervalSince1970: 1_800_000_000 + Double(index))), + updatedAt: measuredAt.addingTimeInterval(Double(index))), provider: provider) } } diff --git a/docs/codex-oauth.md b/docs/codex-oauth.md index 35cf7bcf9e..86613fdd89 100644 --- a/docs/codex-oauth.md +++ b/docs/codex-oauth.md @@ -53,6 +53,11 @@ If expiry is unavailable, the existing eight-day `last_refresh` rule applies; a timestamp still requires refresh. This keeps a future-expiry token on the OAuth path, including its model-specific usage windows, even when the refresh timestamp is old (#3221, #3222). +OAuth strategy reads allow three attempts, with cancellable 50-millisecond delays, to observe an owner publication +that overlaps availability or usage fetching. Usage rereads native credentials inside the renewal window; this is +not token redemption and does not alter the five-minute expiry margin. After the bounded retry, missing, unreadable, +malformed, incomplete, and stale credentials retain their separate error categories. No credentials are written. + The claim must be a signed integer JSON spelling within Codex's supported UTC date range (`-8334601228800...8210266876799` seconds). Booleans, strings, fractions, integral floating-point or exponent spellings, overflow, duplicate claims, and out-of-range dates fall back to age. diff --git a/docs/codex.md b/docs/codex.md index d57de9bff4..277f2c8935 100644 --- a/docs/codex.md +++ b/docs/codex.md @@ -29,6 +29,9 @@ Usage source picker: ### OAuth API (preferred for the app) - Reads OAuth tokens from `~/.codex/auth.json` (or `$CODEX_HOME/auth.json`). +- OAuth availability and usage reads retry a missing, unreadable, or partially published credential file twice, + 50 milliseconds apart. Usage also rereads a native token due for renewal before reporting that it needs refresh. + A successful retry retains the selected workspace; unchanged stale credentials still require their owner's renewal. - CodexBar never publishes refreshed native tokens into `auth.json`; when native credentials are stale, the explicit OAuth path delegates recovery to the Codex CLI, which owns that file. If the CLI is unavailable, the OAuth error is surfaced instead of mutating the shared file. @@ -42,6 +45,9 @@ Usage source picker: - Suspicious weekly resets keep the last trusted usage while confirmation is pending. A successful refresh for the same account and workspace clears stale connectivity errors even when the reading is withheld; failed, cancelled, or superseded refreshes do not clear them. Cached usage, credits, and other accounts remain unchanged. +- A fresh exact OAuth result with a changed, known plan starts a new quota baseline for that account. Previous-plan + reset backfill and pending reset candidates cannot hold the old plan on screen. A first near-zero weekly reading + still requires confirmation from the same plan; missing or unchanged plans retain the normal reset safeguards. - Credits-only updates preserve pending weekly-reset evidence in memory and account-snapshot storage, including when published credits are cleared. Candidate admission, expiry, boundary tolerances, and account guards remain unchanged; preserving evidence does not make an otherwise incompatible reset eligible for publication. diff --git a/docs/refresh-loop.md b/docs/refresh-loop.md index 62fd4ac64d..f637f0668a 100644 --- a/docs/refresh-loop.md +++ b/docs/refresh-loop.md @@ -59,8 +59,8 @@ read_when: persisted `adaptiveActivityScanConsent` value is `undecided`, `allowed`, or `declined`; missing or invalid values are repaired to `undecided`, which never authorizes a scan. Declining selects plain Adaptive; explicitly selecting the agent-aware option again asks again. -- An allowed scan runs `ps -axo ... command=` to inspect the running-process list and identify Codex/Claude, then runs - `lsof` when needed and enumerates known session metadata only when an agent process is detected. It then reads +- An allowed scan inspects running processes and their arguments (through native process APIs on macOS, `ps` elsewhere), + resolves working directories, and enumerates known session metadata only when an agent process is detected. It then reads recent Codex rollouts, reads rollout first-line metadata and mtimes, and inspects Claude transcript metadata. When the Agent Sessions UI is off, CodexBar discards the resulting session records and retains only the latest `Date`. Each scan considers at most 64 agent processes, parses at most 128 Codex rollout metadata records, keeps at most 64 @@ -72,6 +72,18 @@ read_when: Sessions continues to authorize its local scan independently of the Adaptive consent choice. Tailscale discovery and SSH remain behind the Agent Sessions setting. The activity timestamp is not persisted, logged, or uploaded, and it is cleared when consent is revoked. +- ChatGPT's Codex `app-server` can authorize that local rollout scan at exactly + `/Applications/ChatGPT.app/Contents/Resources/codex` or + `/Applications/ChatGPT.app/Contents/Resources/codex-cli/CodexCLI.app/Contents/MacOS/codex`. + The scanner requires an `app-server` argument, verifies the running PID's kernel-reported executable path and + Apple-anchored OpenAI signing team (`2DC432GLL2`), rejects symlink redirects, and validates the outer ChatGPT + bundle (`com.openai.codex`) with the existing signature and Gatekeeper preflight. Running-process trust is rechecked + on each scan. Successful bundle assessments are reused while the resolved paths and filesystem attributes + (including device, inode, and modification date) of the bundle, Info.plist, main executable, and CodeResources + remain unchanged. Updates trigger a new assessment; missing metadata and failed assessments are never cached. + A matching process name or command line alone is insufficient. Home-directory installations, temporary paths, + and similarly named bundles do not qualify for this app-server gate. Recent rollout modification times determine + coding activity; the app-server's presence alone never keeps the 5-minute cadence active. - Each adaptive tick recomputes the delay after the previous refresh completes, sleeps, then calls the same `UsageStore.refresh()` used by fixed-interval mode, so the existing `isRefreshing` coalescing guard still applies — only one provider-batch refresh runs at a time regardless of cadence mode. diff --git a/docs/widgets.md b/docs/widgets.md index bbea482540..59fb448267 100644 --- a/docs/widgets.md +++ b/docs/widgets.md @@ -15,7 +15,7 @@ read_when: - WidgetKit owns the outer margins. All sizes share rendering and quota-selection rules, with overflow labels for omitted rows. Native relative-date text keeps snapshot ages and resets current between timeline reloads. Token-cost rows show their own saved age when more than ten minutes behind quota data. New usage still requires an app refresh and an accepted WidgetKit timeline. - The app writes snapshots after the main refresh pipeline and token-usage refreshes; narrow single-provider refresh paths may wait for the next snapshot write. - Claude-swap refreshes and cleared adapter state publish snapshots even when account widgets are off. When Claude-swap owns account presentation, provider widgets follow its active slot and measurement time. Missing quota can retain only that slot owner's saved reading, never ambient or another slot's quota. Local cost remains provider-wide. -- If every provider entry disappears during a failed refresh, the writer can retain its last queued entries while their providers remain enabled and preservation has not been invalidated. Measurement timestamps stay unchanged, so the widgets show the data's original age. Account invalidation keeps a queued publication retired until valid replacement usage is published. This fallback is limited to the current app session; it does not restore generic provider entries from disk across account changes or restarts. Claude keeps its existing ownership-checked preservation path. +- When a failed refresh has no usage for a provider, the writer can retain that provider's last queued entry while it remains enabled and preservation has not been invalidated. Another provider's missing, disabled, or invalidated entry does not discard eligible readings. Measurement timestamps stay unchanged, so widgets show the data's original age. Account invalidation retires only that provider's queued entry until valid replacement usage is published. This fallback is limited to the current app session; it does not restore generic provider entries from disk across account changes or restarts. Claude keeps its existing ownership-checked preservation path. - Scheduled provider refreshes trigger token/cost refreshes when their TTL permits, with a 15-minute local-history minimum (30 minutes in low-power mode). Manual disables the recurring timer; startup and pending Codex catch-up may still scan. These limits bound history work and WidgetKit reload requests without changing provider usage/status cadence. - Claude local cost/token history remains eligible for widget snapshots when its account does not expose numeric session or weekly quota data. @@ -140,6 +140,16 @@ extension and `chronod` logs. The reporter recovered by quitting only the `Codex extension process and allowing macOS to relaunch it. This is a manual diagnostic workaround, not an automatic recovery policy; restarting the main app may leave that process alive. +After an update, distinguish the installed extension from the executable already mapped by +its running process. In #2838 the reporter found an old extension mapped from a deleted +Sparkle staging directory while the installed app and extension had matching new versions. +`chronod` reported `bundleStubNotSupported` and "Bundle version did not match" before error +1050. The process command shown by `ps` and the installed `Info.plist` do not establish the +version of the running executable. Compare its mapped executable using `lsof -p ` with +the installed extension, and redact paths before sharing logs. Reload requests and a fresh +snapshot alone do not replace a stale extension process. This failure is separate from +Homebrew deleting widget placements and from a snapshot containing no provider entries. + ### 1) Verify the extension bundle exists where macOS expects it ``` APP="/Applications/CodexBar.app"