From fc2b12b5c109b3705bfc29d85e6bd750d590dbef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Mon, 1 Jun 2026 16:55:47 +0100 Subject: [PATCH 01/14] Add Copilot budget extras - Import optional GitHub web budget windows for Copilot - Add settings for budget extras, cookies, and icon selection - Cover the new resolver, fetcher, and settings persistence --- Sources/CodexBar/IconRemainingResolver.swift | 30 +- .../CodexBar/MenuCardView+ModelHelpers.swift | 3 + Sources/CodexBar/MenuCardView.swift | 3 + .../CopilotProviderImplementation.swift | 136 ++++ .../Copilot/CopilotSettingsStore.swift | 50 +- .../Copilot/UsageStore+CopilotBudgets.swift | 34 + Sources/CodexBar/SettingsStore+Defaults.swift | 19 + .../SettingsStore+MenuObservation.swift | 2 + Sources/CodexBar/SettingsStore.swift | 21 +- Sources/CodexBar/SettingsStoreState.swift | 2 + .../StatusItemController+Animation.swift | 6 +- .../CodexBar/StatusItemController+Menu.swift | 3 +- .../StatusItemController+MenuCardModel.swift | 1 + .../Copilot/CopilotBudgetWebFetcher.swift | 628 ++++++++++++++++++ .../Copilot/CopilotProviderDescriptor.swift | 61 +- .../Providers/ProviderSettingsSnapshot.swift | 14 +- Tests/CodexBarTests/CodexbarTests.swift | 39 ++ .../CopilotBudgetWebFetcherTests.swift | 103 +++ Tests/CodexBarTests/MenuCardModelTests.swift | 38 ++ .../ProviderSettingsDescriptorTests.swift | 12 + .../SettingsStoreCoverageTests.swift | 18 + docs/copilot.md | 24 +- 22 files changed, 1231 insertions(+), 16 deletions(-) create mode 100644 Sources/CodexBar/Providers/Copilot/UsageStore+CopilotBudgets.swift create mode 100644 Sources/CodexBarCore/Providers/Copilot/CopilotBudgetWebFetcher.swift create mode 100644 Tests/CodexBarTests/CopilotBudgetWebFetcherTests.swift diff --git a/Sources/CodexBar/IconRemainingResolver.swift b/Sources/CodexBar/IconRemainingResolver.swift index 6d2b0e2ca7..437d08d780 100644 --- a/Sources/CodexBar/IconRemainingResolver.swift +++ b/Sources/CodexBar/IconRemainingResolver.swift @@ -23,7 +23,8 @@ enum IconRemainingResolver { static func resolvedWindows( snapshot: UsageSnapshot, - style: IconStyle) + style: IconStyle, + secondaryOverrideWindowID: String? = nil) -> (primary: RateWindow?, secondary: RateWindow?) { if style == .perplexity { @@ -44,6 +45,14 @@ enum IconRemainingResolver { primary: windows.first, secondary: windows.dropFirst().first) } + if style == .copilot, + let secondaryOverrideWindowID, + let extraWindow = snapshot.extraRateWindows?.first(where: { $0.id == secondaryOverrideWindowID })?.window + { + return ( + primary: snapshot.primary, + secondary: extraWindow) + } return ( primary: snapshot.primary, secondary: snapshot.secondary) @@ -51,7 +60,8 @@ enum IconRemainingResolver { static func resolvedRemaining( snapshot: UsageSnapshot, - style: IconStyle) + style: IconStyle, + secondaryOverrideWindowID: String? = nil) -> (primary: Double?, secondary: Double?) { if style == .perplexity { @@ -72,6 +82,14 @@ enum IconRemainingResolver { primary: windows.first?.remainingPercent, secondary: windows.dropFirst().first?.remainingPercent) } + if style == .copilot, + let secondaryOverrideWindowID, + let extraWindow = snapshot.extraRateWindows?.first(where: { $0.id == secondaryOverrideWindowID })?.window + { + return ( + primary: snapshot.primary?.remainingPercent, + secondary: extraWindow.remainingPercent) + } return ( primary: snapshot.primary?.remainingPercent, secondary: snapshot.secondary?.remainingPercent) @@ -80,10 +98,14 @@ enum IconRemainingResolver { static func resolvedPercents( snapshot: UsageSnapshot, style: IconStyle, - showUsed: Bool) + showUsed: Bool, + secondaryOverrideWindowID: String? = nil) -> (primary: Double?, secondary: Double?) { - let windows = Self.resolvedWindows(snapshot: snapshot, style: style) + let windows = Self.resolvedWindows( + snapshot: snapshot, + style: style, + secondaryOverrideWindowID: secondaryOverrideWindowID) return ( primary: showUsed ? windows.primary?.usedPercent : windows.primary?.remainingPercent, secondary: showUsed ? windows.secondary?.usedPercent : windows.secondary?.remainingPercent) diff --git a/Sources/CodexBar/MenuCardView+ModelHelpers.swift b/Sources/CodexBar/MenuCardView+ModelHelpers.swift index 8e45c3bbe0..d54b2e92b7 100644 --- a/Sources/CodexBar/MenuCardView+ModelHelpers.swift +++ b/Sources/CodexBar/MenuCardView+ModelHelpers.swift @@ -206,6 +206,9 @@ extension UsageMenuCardView.Model { if input.provider == .codex, !input.showOptionalCreditsAndExtraUsage { return [] } + if input.provider == .copilot, !input.copilotBudgetExtrasEnabled { + return [] + } return extraRateWindows.map { namedWindow in let paceDetail = Self.extraRateWindowPaceDetail( provider: input.provider, diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 1c3532da14..785e99dd39 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -716,6 +716,7 @@ extension UsageMenuCardView.Model { let resetTimeDisplayStyle: ResetTimeDisplayStyle let tokenCostUsageEnabled: Bool let showOptionalCreditsAndExtraUsage: Bool + let copilotBudgetExtrasEnabled: Bool let sourceLabel: String? let kiloAutoMode: Bool let hidePersonalInfo: Bool @@ -742,6 +743,7 @@ extension UsageMenuCardView.Model { resetTimeDisplayStyle: ResetTimeDisplayStyle, tokenCostUsageEnabled: Bool, showOptionalCreditsAndExtraUsage: Bool, + copilotBudgetExtrasEnabled: Bool = true, sourceLabel: String? = nil, kiloAutoMode: Bool = false, hidePersonalInfo: Bool, @@ -767,6 +769,7 @@ extension UsageMenuCardView.Model { self.resetTimeDisplayStyle = resetTimeDisplayStyle self.tokenCostUsageEnabled = tokenCostUsageEnabled self.showOptionalCreditsAndExtraUsage = showOptionalCreditsAndExtraUsage + self.copilotBudgetExtrasEnabled = copilotBudgetExtrasEnabled self.sourceLabel = sourceLabel self.kiloAutoMode = kiloAutoMode self.hidePersonalInfo = hidePersonalInfo diff --git a/Sources/CodexBar/Providers/Copilot/CopilotProviderImplementation.swift b/Sources/CodexBar/Providers/Copilot/CopilotProviderImplementation.swift index 660a2d2303..668a271f9c 100644 --- a/Sources/CodexBar/Providers/Copilot/CopilotProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Copilot/CopilotProviderImplementation.swift @@ -17,6 +17,9 @@ struct CopilotProviderImplementation: ProviderImplementation { func observeSettings(_ settings: SettingsStore) { _ = settings.copilotAPIToken _ = settings.copilotEnterpriseHost + _ = settings.copilotBudgetExtrasEnabled + _ = settings.copilotBudgetCookieSource + _ = settings.copilotBudgetCookieHeader } @MainActor @@ -31,9 +34,142 @@ struct CopilotProviderImplementation: ProviderImplementation { ("Add Account...", .addProviderAccount(.copilot)) } + @MainActor + func settingsToggles(context: ProviderSettingsContext) -> [ProviderSettingsToggleDescriptor] { + let budgetExtrasBinding = Binding( + get: { context.settings.copilotBudgetExtrasEnabled }, + set: { enabled in + context.settings.copilotBudgetExtrasEnabled = enabled + }) + let budgetExtrasStatus: () -> String? = { + if context.store.snapshot(for: .copilot)?.extraRateWindows?.isEmpty == false { + return nil + } + if context.settings.copilotBudgetCookieSource == .manual, + context.settings.copilotBudgetCookieHeader.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + return [ + "Paste a github.com Cookie header, then refresh Copilot.", + "Copilot reauth does not provide the GitHub web cookie used for budgets.", + ].joined(separator: " ") + } + return [ + "Refresh Copilot to load budget bars.", + "Budget extras require a logged-in github.com browser session or a manual Cookie header.", + ].joined(separator: " ") + } + + return [ + ProviderSettingsToggleDescriptor( + id: "copilot-budget-extras", + title: "Budget extras", + subtitle: [ + "Optional.", + "Turn this on to fetch configured GitHub Copilot budget limits and show them as extra bars.", + ].joined(separator: " "), + binding: budgetExtrasBinding, + statusText: budgetExtrasStatus, + actions: [], + isVisible: nil, + onChange: { enabled in + if enabled { + await context.store.refreshProvider(.copilot, allowDisabled: true) + } else { + context.store.clearCopilotBudgetExtras() + } + }, + onAppDidBecomeActive: nil, + onAppearWhenEnabled: nil), + ] + } + + @MainActor + func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] { + let extraWindows = context.store.snapshot(for: .copilot)?.extraRateWindows ?? [] + let cookieBinding = Binding( + get: { context.settings.copilotBudgetCookieSource.rawValue }, + set: { raw in + context.settings.copilotBudgetCookieSource = ProviderCookieSource(rawValue: raw) ?? .auto + }) + let cookieOptions = ProviderCookieSourceUI.options( + allowsOff: false, + keychainDisabled: context.settings.debugDisableKeychainAccess) + let cookieSubtitle: () -> String? = { + ProviderCookieSourceUI.subtitle( + source: context.settings.copilotBudgetCookieSource, + keychainDisabled: context.settings.debugDisableKeychainAccess, + auto: "Automatic imports browser cookies for github.com budget extras.", + manual: "Paste a Cookie header from github.com.", + off: "GitHub cookies are disabled.") + } + let options = [ + ProviderSettingsPickerOption( + id: CopilotIconSecondaryWindowSelection.chat, + title: "Chat"), + ] + extraWindows.map { window in + ProviderSettingsPickerOption(id: window.id, title: window.title) + } + + return [ + ProviderSettingsPickerDescriptor( + id: "copilot-icon-secondary-window", + title: "Menu bar secondary metric", + subtitle: "Choose the second meter shown in the menu bar icon.", + dynamicSubtitle: { + extraWindows.isEmpty + ? "Budget options appear after a refresh finds configured Copilot budgets." + : nil + }, + binding: Binding( + get: { + let selected = context.settings.copilotIconSecondaryWindowID + if selected == CopilotIconSecondaryWindowSelection.chat { + return selected + } + return extraWindows.contains(where: { $0.id == selected }) + ? selected + : CopilotIconSecondaryWindowSelection.chat + }, + set: { selection in + context.settings.copilotIconSecondaryWindowID = selection + }), + options: options, + isVisible: { context.settings.copilotBudgetExtrasEnabled }, + onChange: nil), + ProviderSettingsPickerDescriptor( + id: "copilot-budget-cookie-source", + title: "GitHub cookies", + subtitle: "Automatic imports browser cookies for budget extras.", + dynamicSubtitle: cookieSubtitle, + binding: cookieBinding, + options: cookieOptions, + isVisible: { context.settings.copilotBudgetExtrasEnabled }, + onChange: nil, + trailingText: { + guard context.settings.copilotBudgetCookieSource != .manual else { return nil } + guard let entry = CookieHeaderCache.load(provider: .copilot) else { return nil } + let when = entry.storedAt.relativeDescription() + return "Cached: \(entry.sourceLabel) • \(when)" + }), + ] + } + @MainActor func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { [ + ProviderSettingsFieldDescriptor( + id: "copilot-budget-cookie-header", + title: "", + subtitle: "", + kind: .secure, + placeholder: "Cookie: ...", + binding: context.stringBinding(\.copilotBudgetCookieHeader), + actions: [], + isVisible: { + context.settings.copilotBudgetExtrasEnabled && + context.settings.copilotBudgetCookieSource == .manual + }, + onActivate: nil), ProviderSettingsFieldDescriptor( id: "copilot-enterprise-host", title: "Enterprise host", diff --git a/Sources/CodexBar/Providers/Copilot/CopilotSettingsStore.swift b/Sources/CodexBar/Providers/Copilot/CopilotSettingsStore.swift index 38c875e8b9..0425626d6f 100644 --- a/Sources/CodexBar/Providers/Copilot/CopilotSettingsStore.swift +++ b/Sources/CodexBar/Providers/Copilot/CopilotSettingsStore.swift @@ -1,6 +1,10 @@ import CodexBarCore import Foundation +enum CopilotIconSecondaryWindowSelection { + static let chat = "chat" +} + extension SettingsStore { var copilotAPIToken: String { get { self.configSnapshot.providerConfig(for: .copilot)?.sanitizedAPIKey ?? "" } @@ -21,7 +25,48 @@ extension SettingsStore { } } + var copilotBudgetCookieHeader: String { + get { self.configSnapshot.providerConfig(for: .copilot)?.sanitizedCookieHeader ?? "" } + set { + self.updateProviderConfig(provider: .copilot) { entry in + entry.cookieHeader = self.normalizedConfigValue(newValue) + } + self.logSecretUpdate(provider: .copilot, field: "cookieHeader", value: newValue) + } + } + + var copilotBudgetCookieSource: ProviderCookieSource { + get { self.resolvedCookieSource(provider: .copilot, fallback: .auto) } + set { + self.updateProviderConfig(provider: .copilot) { entry in + entry.cookieSource = newValue + } + self.logProviderModeChange(provider: .copilot, field: "cookieSource", value: newValue.rawValue) + } + } + func ensureCopilotAPITokenLoaded() {} + + var copilotIconSecondaryWindowID: String { + get { + let raw = self.copilotIconSecondaryWindowIDRaw.trimmingCharacters(in: .whitespacesAndNewlines) + return raw.isEmpty ? CopilotIconSecondaryWindowSelection.chat : raw + } + set { + let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines) + self.copilotIconSecondaryWindowIDRaw = trimmed.isEmpty + ? CopilotIconSecondaryWindowSelection.chat + : trimmed + } + } + + func copilotIconSecondaryWindowOverrideID(snapshot: UsageSnapshot?) -> String? { + guard self.copilotBudgetExtrasEnabled else { return nil } + let selected = self.copilotIconSecondaryWindowID + guard selected != CopilotIconSecondaryWindowSelection.chat else { return nil } + guard snapshot?.extraRateWindows?.contains(where: { $0.id == selected }) == true else { return nil } + return selected + } } extension SettingsStore { @@ -36,6 +81,9 @@ extension SettingsStore { let host = CopilotDeviceFlow.normalizedHost(self.copilotEnterpriseHost) return ProviderSettingsSnapshot.CopilotProviderSettings( apiToken: self.normalizedConfigValue(token), - enterpriseHost: host == CopilotDeviceFlow.defaultHost ? nil : host) + enterpriseHost: host == CopilotDeviceFlow.defaultHost ? nil : host, + budgetExtrasEnabled: self.copilotBudgetExtrasEnabled, + budgetCookieSource: self.copilotBudgetCookieSource, + manualBudgetCookieHeader: self.normalizedConfigValue(self.copilotBudgetCookieHeader)) } } diff --git a/Sources/CodexBar/Providers/Copilot/UsageStore+CopilotBudgets.swift b/Sources/CodexBar/Providers/Copilot/UsageStore+CopilotBudgets.swift new file mode 100644 index 0000000000..9b2d35a316 --- /dev/null +++ b/Sources/CodexBar/Providers/Copilot/UsageStore+CopilotBudgets.swift @@ -0,0 +1,34 @@ +import CodexBarCore +import Foundation + +@MainActor +extension UsageStore { + func clearCopilotBudgetExtras() { + guard let snapshot = self.snapshots[.copilot], + snapshot.extraRateWindows?.isEmpty == false + else { return } + + let updated = UsageSnapshot( + primary: snapshot.primary, + secondary: snapshot.secondary, + tertiary: snapshot.tertiary, + extraRateWindows: nil, + kiroUsage: snapshot.kiroUsage, + providerCost: snapshot.providerCost, + zaiUsage: snapshot.zaiUsage, + minimaxUsage: snapshot.minimaxUsage, + deepseekUsage: snapshot.deepseekUsage, + openRouterUsage: snapshot.openRouterUsage, + openAIAPIUsage: snapshot.openAIAPIUsage, + claudeAdminAPIUsage: snapshot.claudeAdminAPIUsage, + mistralUsage: snapshot.mistralUsage, + deepgramUsage: snapshot.deepgramUsage, + cursorRequests: snapshot.cursorRequests, + updatedAt: snapshot.updatedAt, + identity: snapshot.identity) + self.snapshots[.copilot] = updated + if self.lastKnownResetSnapshots[.copilot]?.extraRateWindows?.isEmpty == false { + self.lastKnownResetSnapshots[.copilot] = updated + } + } +} diff --git a/Sources/CodexBar/SettingsStore+Defaults.swift b/Sources/CodexBar/SettingsStore+Defaults.swift index cc966c1463..c4632d2b65 100644 --- a/Sources/CodexBar/SettingsStore+Defaults.swift +++ b/Sources/CodexBar/SettingsStore+Defaults.swift @@ -288,6 +288,14 @@ extension SettingsStore { } } + var copilotIconSecondaryWindowIDRaw: String { + get { self.defaultsState.copilotIconSecondaryWindowIDRaw } + set { + self.defaultsState.copilotIconSecondaryWindowIDRaw = newValue + self.userDefaults.set(newValue, forKey: "copilotIconSecondaryWindowID") + } + } + var costUsageEnabled: Bool { get { self.defaultsState.costUsageEnabled } set { @@ -375,6 +383,17 @@ extension SettingsStore { set { self.claudeWebExtrasEnabledRaw = newValue } } + var copilotBudgetExtrasEnabled: Bool { + get { self.defaultsState.copilotBudgetExtrasEnabled } + set { + self.defaultsState.copilotBudgetExtrasEnabled = newValue + self.userDefaults.set(newValue, forKey: "copilotBudgetExtrasEnabled") + CodexBarLog.logger(LogCategories.settings).info( + "Copilot budget extras updated", + metadata: ["enabled": newValue ? "1" : "0"]) + } + } + private var claudeWebExtrasEnabledRaw: Bool { get { self.defaultsState.claudeWebExtrasEnabledRaw } set { diff --git a/Sources/CodexBar/SettingsStore+MenuObservation.swift b/Sources/CodexBar/SettingsStore+MenuObservation.swift index f81c06fb43..ccff585ce6 100644 --- a/Sources/CodexBar/SettingsStore+MenuObservation.swift +++ b/Sources/CodexBar/SettingsStore+MenuObservation.swift @@ -30,6 +30,7 @@ extension SettingsStore { _ = self.historicalTrackingEnabled _ = self.multiAccountMenuLayout _ = self.menuBarMetricPreferencesRaw + _ = self.copilotIconSecondaryWindowIDRaw _ = self.costUsageEnabled _ = self.costUsageHistoryDays _ = self.appLanguage @@ -39,6 +40,7 @@ extension SettingsStore { _ = self.claudeOAuthKeychainPromptMode _ = self.claudeOAuthKeychainReadStrategy _ = self.claudeWebExtrasEnabled + _ = self.copilotBudgetExtrasEnabled _ = self.showOptionalCreditsAndExtraUsage _ = self.openAIWebAccessEnabled _ = self.openAIWebBatterySaverEnabled diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index 7a05a305bc..d4d85eaa14 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -372,11 +372,10 @@ extension SettingsStore { let kiroMenuBarDisplayModeRaw = userDefaults.string(forKey: "kiroMenuBarDisplayMode") ?? KiroMenuBarDisplayMode.automatic.rawValue let historicalTrackingEnabled = userDefaults.object(forKey: "historicalTrackingEnabled") as? Bool ?? false - let multiAccountMenuLayoutRaw = userDefaults.string(forKey: "multiAccountMenuLayout") ?? { - let legacyShowAll = userDefaults.object(forKey: "showAllTokenAccountsInMenu") as? Bool ?? false - return legacyShowAll ? MultiAccountMenuLayout.stacked.rawValue : MultiAccountMenuLayout.segmented.rawValue - }() + let multiAccountMenuLayoutRaw = Self.loadMultiAccountMenuLayoutRaw(userDefaults: userDefaults) let resolvedPreferences = Self.loadMenuBarMetricPreferences(userDefaults: userDefaults) + let copilotBudgetExtrasEnabled = userDefaults.object(forKey: "copilotBudgetExtrasEnabled") as? Bool ?? false + let copilotIconSecondaryWindowIDRaw = Self.loadCopilotIconSecondaryWindowIDRaw(userDefaults: userDefaults) let costUsageEnabled = userDefaults.object(forKey: "tokenCostUsageEnabled") as? Bool ?? false let rawCostUsageHistoryDays = userDefaults.object(forKey: "tokenCostUsageHistoryDays") as? Int ?? 30 let costUsageHistoryDays = max(1, min(365, rawCostUsageHistoryDays)) @@ -447,6 +446,8 @@ extension SettingsStore { historicalTrackingEnabled: historicalTrackingEnabled, multiAccountMenuLayoutRaw: multiAccountMenuLayoutRaw, menuBarMetricPreferencesRaw: resolvedPreferences, + copilotBudgetExtrasEnabled: copilotBudgetExtrasEnabled, + copilotIconSecondaryWindowIDRaw: copilotIconSecondaryWindowIDRaw, costUsageEnabled: costUsageEnabled, costUsageHistoryDays: costUsageHistoryDays, hidePersonalInfo: hidePersonalInfo, @@ -482,6 +483,18 @@ extension SettingsStore { return Dictionary(uniqueKeysWithValues: UsageProvider.allCases.map { ($0.rawValue, legacyPreference.rawValue) }) } + private static func loadMultiAccountMenuLayoutRaw(userDefaults: UserDefaults) -> String { + if let layout = userDefaults.string(forKey: "multiAccountMenuLayout") { + return layout + } + let legacyShowAll = userDefaults.object(forKey: "showAllTokenAccountsInMenu") as? Bool ?? false + return legacyShowAll ? MultiAccountMenuLayout.stacked.rawValue : MultiAccountMenuLayout.segmented.rawValue + } + + private static func loadCopilotIconSecondaryWindowIDRaw(userDefaults: UserDefaults) -> String { + userDefaults.string(forKey: "copilotIconSecondaryWindowID") ?? CopilotIconSecondaryWindowSelection.chat + } + private struct LoadedQuotaWarningDefaults { var notificationsEnabled: Bool var thresholdsRaw: [Int] diff --git a/Sources/CodexBar/SettingsStoreState.swift b/Sources/CodexBar/SettingsStoreState.swift index 9ed022a978..dfd384d0e4 100644 --- a/Sources/CodexBar/SettingsStoreState.swift +++ b/Sources/CodexBar/SettingsStoreState.swift @@ -29,6 +29,8 @@ struct SettingsDefaultsState { var historicalTrackingEnabled: Bool var multiAccountMenuLayoutRaw: String var menuBarMetricPreferencesRaw: [String: String] + var copilotBudgetExtrasEnabled: Bool + var copilotIconSecondaryWindowIDRaw: String var costUsageEnabled: Bool var costUsageHistoryDays: Int var hidePersonalInfo: Bool diff --git a/Sources/CodexBar/StatusItemController+Animation.swift b/Sources/CodexBar/StatusItemController+Animation.swift index 3b545e6dc4..b6dc3e09c4 100644 --- a/Sources/CodexBar/StatusItemController+Animation.swift +++ b/Sources/CodexBar/StatusItemController+Animation.swift @@ -253,7 +253,8 @@ extension StatusItemController { IconRemainingResolver.resolvedPercents( snapshot: $0, style: style, - showUsed: showUsed) + showUsed: showUsed, + secondaryOverrideWindowID: self.settings.copilotIconSecondaryWindowOverrideID(snapshot: $0)) } var primary = resolved?.primary var weekly = resolved?.secondary @@ -479,7 +480,8 @@ extension StatusItemController { IconRemainingResolver.resolvedPercents( snapshot: $0, style: style, - showUsed: showUsed) + showUsed: showUsed, + secondaryOverrideWindowID: self.settings.copilotIconSecondaryWindowOverrideID(snapshot: $0)) } var primary = resolved?.primary var weekly = resolved?.secondary diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index dbc61ab627..b7e22208dd 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -1365,7 +1365,8 @@ extension StatusItemController { IconRemainingResolver.resolvedPercents( snapshot: $0, style: style, - showUsed: showUsed) + showUsed: showUsed, + secondaryOverrideWindowID: self.settings.copilotIconSecondaryWindowOverrideID(snapshot: $0)) } let primary = resolved?.primary var weekly = resolved?.secondary diff --git a/Sources/CodexBar/StatusItemController+MenuCardModel.swift b/Sources/CodexBar/StatusItemController+MenuCardModel.swift index eebc4b830f..ac26780e4d 100644 --- a/Sources/CodexBar/StatusItemController+MenuCardModel.swift +++ b/Sources/CodexBar/StatusItemController+MenuCardModel.swift @@ -109,6 +109,7 @@ extension StatusItemController { resetTimeDisplayStyle: self.settings.resetTimeDisplayStyle, tokenCostUsageEnabled: self.settings.isCostUsageEffectivelyEnabled(for: target), showOptionalCreditsAndExtraUsage: self.settings.showOptionalCreditsAndExtraUsage, + copilotBudgetExtrasEnabled: self.settings.copilotBudgetExtrasEnabled, sourceLabel: sourceLabel, kiloAutoMode: kiloAutoMode, hidePersonalInfo: self.settings.hidePersonalInfo, diff --git a/Sources/CodexBarCore/Providers/Copilot/CopilotBudgetWebFetcher.swift b/Sources/CodexBarCore/Providers/Copilot/CopilotBudgetWebFetcher.swift new file mode 100644 index 0000000000..873696bf9c --- /dev/null +++ b/Sources/CodexBarCore/Providers/Copilot/CopilotBudgetWebFetcher.swift @@ -0,0 +1,628 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif +#if os(macOS) +import SweetCookieKit +#endif + +public struct CopilotBudgetWebFetcher: Sendable { + public enum Error: Swift.Error, LocalizedError, Equatable { + case noSessionCookie + case notLoggedIn + case badStatus(Int) + case invalidResponse + + public var errorDescription: String? { + switch self { + case .noSessionCookie: + "No GitHub browser session cookie found." + case .notLoggedIn: + "GitHub browser session is not logged in." + case let .badStatus(status): + "GitHub budgets request failed with HTTP \(status)." + case .invalidResponse: + "GitHub budgets response could not be decoded." + } + } + } + + struct BudgetResponse: Decodable, Sendable { + let budgets: [Budget] + let hasNextPage: Bool? + + private enum CodingKeys: String, CodingKey { + case budgets + case payload + case hasNextPage + case hasNextPageSnake = "has_next_page" + } + + init(budgets: [Budget], hasNextPage: Bool? = nil) { + self.budgets = budgets + self.hasNextPage = hasNextPage + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + if let payload = try container.decodeIfPresent(BudgetResponse.self, forKey: .payload) { + self = payload + return + } + self.budgets = try container.decodeIfPresent([Budget].self, forKey: .budgets) ?? [] + self.hasNextPage = try container.decodeIfPresent(Bool.self, forKey: .hasNextPage) + ?? container.decodeIfPresent(Bool.self, forKey: .hasNextPageSnake) + } + } + + struct Budget: Decodable, Equatable, Sendable { + let id: String? + let name: String? + let budgetType: String? + let budgetProductSkus: [String] + let budgetScope: String? + let budgetEntityName: String? + let budgetAmount: Double + let currentAmount: Double + + init( + id: String? = nil, + name: String? = nil, + budgetType: String? = nil, + budgetProductSkus: [String] = [], + budgetScope: String? = nil, + budgetEntityName: String? = nil, + budgetAmount: Double, + currentAmount: Double = 0) + { + self.id = id + self.name = name + self.budgetType = budgetType + self.budgetProductSkus = budgetProductSkus + self.budgetScope = budgetScope + self.budgetEntityName = budgetEntityName + self.budgetAmount = budgetAmount + self.currentAmount = currentAmount + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKey.self) + self.id = Self.decodeString(container: container, keys: ["id", "uuid", "budget_id", "budgetId"]) + self.name = Self.decodeString(container: container, keys: ["name", "display_name", "displayName", "title"]) + self.budgetType = Self.decodeString( + container: container, + keys: ["budget_type", "budgetType", "type", "pricing_target_type", "pricingTargetType"]) + self.budgetProductSkus = Self.decodeStringArray( + container: container, + keys: [ + "budget_product_skus", + "budgetProductSkus", + "budget_product_sku", + "budgetProductSku", + "product_skus", + "productSkus", + "skus", + "sku", + "product", + "product_name", + "productName", + "pricing_target_id", + "pricingTargetId", + ]) + self.budgetScope = Self.decodeString(container: container, keys: ["budget_scope", "budgetScope", "scope"]) + self.budgetEntityName = Self.decodeString( + container: container, + keys: [ + "budget_entity_name", + "budgetEntityName", + "entity_name", + "entityName", + "target_name", + "targetName", + ]) + self.budgetAmount = Self.decodeDouble( + container: container, + keys: [ + "budget_amount", + "budgetAmount", + "target_amount", + "targetAmount", + "spending_limit", + "spendingLimit", + "limit", + "amount", + "max", + ]) ?? 0 + self.currentAmount = Self.decodeDouble( + container: container, + keys: [ + "current_usage", + "currentUsage", + "current_amount", + "currentAmount", + "usage_amount", + "usageAmount", + "usage", + "spent", + "amount_used", + "amountUsed", + ]) ?? 0 + } + + var normalizedSelectors: Set { + let values = self.budgetProductSkus + [ + self.budgetType, + self.budgetEntityName, + self.name, + ].compactMap(\.self) + return Set(values.compactMap(CopilotBudgetWebFetcher.normalizedBillingIdentifier)) + } + + private static func decodeString( + container: KeyedDecodingContainer, + keys: [String]) -> String? + { + for key in keys { + guard let codingKey = DynamicCodingKey(key) else { continue } + if let value = try? container.decodeIfPresent(String.self, forKey: codingKey), !value.isEmpty { + return value + } + if let value = try? container.decodeIfPresent(Int.self, forKey: codingKey) { + return String(value) + } + } + return nil + } + + private static func decodeStringArray( + container: KeyedDecodingContainer, + keys: [String]) -> [String] + { + for key in keys { + guard let codingKey = DynamicCodingKey(key) else { continue } + if let values = try? container.decodeIfPresent([String].self, forKey: codingKey), !values.isEmpty { + return values + } + if let value = try? container.decodeIfPresent(String.self, forKey: codingKey), !value.isEmpty { + return [value] + } + if let values = try? container.decodeIfPresent([ProductSKU].self, forKey: codingKey), + !values.isEmpty + { + return values.flatMap(\.selectors) + } + } + return [] + } + + private static func decodeDouble( + container: KeyedDecodingContainer, + keys: [String]) -> Double? + { + for key in keys { + guard let codingKey = DynamicCodingKey(key) else { continue } + if let value = try? container.decodeIfPresent(Double.self, forKey: codingKey) { + return value + } + if let value = try? container.decodeIfPresent(Int.self, forKey: codingKey) { + return Double(value) + } + if let value = try? container.decodeIfPresent(String.self, forKey: codingKey), + let parsed = Self.parseAmount(value) + { + return parsed + } + if let value = try? container.decodeIfPresent(AmountValue.self, forKey: codingKey) { + return value.amount + } + } + return nil + } + + fileprivate static func parseAmount(_ value: String) -> Double? { + let filtered = value.filter { $0.isNumber || $0 == "." || $0 == "-" } + guard !filtered.isEmpty else { return nil } + return Double(filtered) + } + } + + private struct ProductSKU: Decodable, Sendable, Equatable { + let selectors: [String] + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKey.self) + self.selectors = [ + "sku", + "name", + "display_name", + "displayName", + "product", + "product_name", + "productName", + ].compactMap { key in + guard let codingKey = DynamicCodingKey(key) else { return nil } + return try? container.decodeIfPresent(String.self, forKey: codingKey) + } + } + } + + private struct AmountValue: Decodable, Sendable { + let amount: Double? + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKey.self) + self.amount = [ + "amount", + "value", + "total", + "cents", + "formatted", + ].lazy.compactMap { key -> Double? in + guard let codingKey = DynamicCodingKey(key) else { return nil } + if let value = try? container.decodeIfPresent(Double.self, forKey: codingKey) { + return key == "cents" ? value / 100 : value + } + if let value = try? container.decodeIfPresent(Int.self, forKey: codingKey) { + return key == "cents" ? Double(value) / 100 : Double(value) + } + if let value = try? container.decodeIfPresent(String.self, forKey: codingKey) { + return Budget.parseAmount(value) + } + return nil + }.first + } + } + + private struct DynamicCodingKey: CodingKey { + let stringValue: String + let intValue: Int? + + init?(_ stringValue: String) { + self.stringValue = stringValue + self.intValue = nil + } + + init?(stringValue: String) { + self.init(stringValue) + } + + init?(intValue: Int) { + self.stringValue = String(intValue) + self.intValue = intValue + } + } + + private static let copilotProductID = "copilot" + private static let copilotPremiumRequestSKU = "copilot_premium_request" + private static let copilotAgentPremiumRequestSKU = "copilot_agent_premium_request" + private static let sparkPremiumRequestSKU = "spark_premium_request" + private static let copilotBudgetSelectors: Set = [ + copilotProductID, + copilotPremiumRequestSKU, + copilotAgentPremiumRequestSKU, + sparkPremiumRequestSKU, + ] + + private let cookieHeaderOverride: String? + private let browserDetection: BrowserDetection + private let transport: any ProviderHTTPTransport + private let now: @Sendable () -> Date + + public init( + cookieHeaderOverride: String? = nil, + browserDetection: BrowserDetection = BrowserDetection(), + transport: any ProviderHTTPTransport = ProviderHTTPClient.shared, + now: @escaping @Sendable () -> Date = { Date() }) + { + self.cookieHeaderOverride = CookieHeaderNormalizer.normalize(cookieHeaderOverride ?? "") + self.browserDetection = browserDetection + self.transport = transport + self.now = now + } + + public func fetchBudgetWindows() async throws -> [NamedRateWindow] { + if let cookieHeaderOverride, !cookieHeaderOverride.isEmpty { + return try await self.fetchBudgetWindows(cookieHeader: cookieHeaderOverride) + } + + if let cached = CookieHeaderCache.load(provider: .copilot), + !cached.cookieHeader.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + do { + return try await self.fetchBudgetWindows(cookieHeader: cached.cookieHeader) + } catch { + if case Error.notLoggedIn = error { + CookieHeaderCache.clear(provider: .copilot) + } + } + } + + #if os(macOS) + for session in CopilotGitHubCookieImporter.importSessions(browserDetection: self.browserDetection) { + do { + let windows = try await self.fetchBudgetWindows(cookieHeader: session.cookieHeader) + CookieHeaderCache.store( + provider: .copilot, + cookieHeader: session.cookieHeader, + sourceLabel: session.sourceLabel) + return windows + } catch { + if case Error.notLoggedIn = error { + continue + } + } + } + #endif + + throw Error.noSessionCookie + } + + func fetchBudgetWindows(cookieHeader: String) async throws -> [NamedRateWindow] { + let nonce = try? await self.fetchNonce(cookieHeader: cookieHeader) + var allBudgets: [Budget] = [] + var page = 1 + var shouldContinue = true + while shouldContinue, page <= 20 { + let response = try await self.fetchBudgetPage( + cookieHeader: cookieHeader, + nonce: nonce, + page: page) + allBudgets.append(contentsOf: response.budgets) + shouldContinue = response.hasNextPage == true + page += 1 + } + return Self.extraRateWindows(from: allBudgets, now: self.now()) + } + + private func fetchNonce(cookieHeader: String) async throws -> String? { + guard let url = URL(string: "https://github.com/settings/billing/budgets") else { + throw URLError(.badURL) + } + var request = URLRequest(url: url) + request.timeoutInterval = 15 + request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") + request.setValue("text/html,application/xhtml+xml", forHTTPHeaderField: "Accept") + request.setValue("CodexBar", forHTTPHeaderField: "User-Agent") + + let response = try await self.transport.response(for: request) + switch response.statusCode { + case 200: + guard let html = String(data: response.data, encoding: .utf8) else { return nil } + return Self.extractFetchNonce(from: html) + case 401, 403: + throw Error.notLoggedIn + default: + throw Error.badStatus(response.statusCode) + } + } + + private func fetchBudgetPage(cookieHeader: String, nonce: String?, page: Int) async throws -> BudgetResponse { + guard var components = URLComponents(string: "https://github.com/settings/billing/budgets") else { + throw URLError(.badURL) + } + components.queryItems = [ + URLQueryItem(name: "page", value: "\(page)"), + URLQueryItem(name: "page_size", value: "10"), + URLQueryItem(name: "scope", value: "customer"), + ] + guard let url = components.url else { throw URLError(.badURL) } + var request = URLRequest(url: url) + request.timeoutInterval = 15 + request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("https://github.com/settings/billing/budgets", forHTTPHeaderField: "Referer") + request.setValue("XMLHttpRequest", forHTTPHeaderField: "X-Requested-With") + request.setValue("true", forHTTPHeaderField: "GitHub-Verified-Fetch") + request.setValue("CodexBar", forHTTPHeaderField: "User-Agent") + if let nonce, !nonce.isEmpty { + request.setValue(nonce, forHTTPHeaderField: "X-Fetch-Nonce") + } + + let response = try await self.transport.response(for: request) + switch response.statusCode { + case 200: + return try JSONDecoder().decode(BudgetResponse.self, from: response.data) + case 401, 403: + throw Error.notLoggedIn + default: + throw Error.badStatus(response.statusCode) + } + } + + static func extractFetchNonce(from html: String) -> String? { + let patterns = [ + #"x-fetch-nonce"\s+content="([^"]+)""#, + #"X-Fetch-Nonce"\s*:\s*"([^"]+)""#, + #"fetchNonce"\s*:\s*"([^"]+)""#, + #"data-fetch-nonce="([^"]+)""#, + ] + for pattern in patterns { + guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else { + continue + } + let range = NSRange(html.startIndex.. [NamedRateWindow] { + var usedIDs = Set() + return budgets + .filter(Self.isCopilotBudget) + .map { budget in + let id = self.uniqueWindowID(for: budget, usedIDs: &usedIDs) + let usedPercent = budget.budgetAmount > 0 + ? min(999, max(0, budget.currentAmount / budget.budgetAmount * 100)) + : 0 + let window = RateWindow( + usedPercent: usedPercent, + windowMinutes: nil, + resetsAt: self.nextMonthResetDate(now: now), + resetDescription: self.nextMonthResetDate(now: now).map { + UsageFormatter.resetDescription(from: $0, now: now) + }) + return NamedRateWindow(id: id, title: self.windowTitle(for: budget), window: window) + } + } + + static func isCopilotBudget(_ budget: Budget) -> Bool { + guard budget.budgetAmount > 0 else { return false } + return !budget.normalizedSelectors.isDisjoint(with: self.copilotBudgetSelectors) + } + + static func normalizedBillingIdentifier(_ value: String?) -> String? { + guard let value else { return nil } + let slug = self.slug(value) + guard !slug.isEmpty else { return nil } + let underscored = slug.replacingOccurrences(of: "-", with: "_") + if underscored == self.copilotProductID { + return self.copilotProductID + } + if underscored == "premium_request" || underscored == "premium_requests" { + return self.copilotPremiumRequestSKU + } + if underscored == "coding_agent_premium_request" || underscored == "coding_agent_premium_requests" { + return self.copilotAgentPremiumRequestSKU + } + if underscored.contains("spark"), underscored.contains("premium"), underscored.contains("request") { + return self.sparkPremiumRequestSKU + } + if underscored.contains("cloud") || underscored.contains("coding"), + underscored.contains("agent"), + underscored.contains("premium"), + underscored.contains("request") + { + return self.copilotAgentPremiumRequestSKU + } + if underscored.contains("bundled"), underscored.contains("premium"), underscored.contains("request") { + return self.copilotPremiumRequestSKU + } + if underscored.contains("copilot"), + underscored.contains("agent"), + underscored.contains("premium"), + underscored.contains("request") + { + return self.copilotAgentPremiumRequestSKU + } + if underscored.contains("copilot"), underscored.contains("premium"), underscored.contains("request") { + return self.copilotPremiumRequestSKU + } + return underscored + } + + private static func windowTitle(for budget: Budget) -> String { + let selectors = budget.normalizedSelectors + let budgetType = if selectors == [self.copilotProductID] { + "Copilot" + } else if selectors.contains(self.copilotAgentPremiumRequestSKU) { + "Copilot Agent Premium Requests" + } else if selectors.contains(self.sparkPremiumRequestSKU) { + "Spark Premium Requests" + } else if selectors.contains(self.copilotPremiumRequestSKU) { + "All Premium Request SKUs" + } else if let name = budget.name?.trimmingCharacters(in: .whitespacesAndNewlines), + !name.isEmpty + { + name + } else { + "Copilot Premium Requests" + } + return "Budget - \(budgetType)" + } + + private static func uniqueWindowID(for budget: Budget, usedIDs: inout Set) -> String { + let source = budget.id ?? budget.budgetProductSkus.joined(separator: "-") + let slug = self.slug(source.isEmpty ? self.windowTitle(for: budget) : source) + let base = slug.isEmpty ? "copilot-budget" : "copilot-budget-\(slug)" + var candidate = base + var suffix = 2 + while !usedIDs.insert(candidate).inserted { + candidate = "\(base)-\(suffix)" + suffix += 1 + } + return candidate + } + + private static func nextMonthResetDate(now: Date) -> Date? { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0) ?? .current + let components = calendar.dateComponents([.year, .month], from: now) + guard let monthStart = calendar.date(from: DateComponents( + year: components.year, + month: components.month, + day: 1)) + else { + return nil + } + return calendar.date(byAdding: .month, value: 1, to: monthStart) + } + + private static func slug(_ value: String) -> String { + var result = "" + var lastWasDash = false + for scalar in value.lowercased().unicodeScalars { + if CharacterSet.alphanumerics.contains(scalar) { + result.unicodeScalars.append(scalar) + lastWasDash = false + } else if !lastWasDash { + result.append("-") + lastWasDash = true + } + } + return result.trimmingCharacters(in: CharacterSet(charactersIn: "-")) + } +} + +#if os(macOS) +private enum CopilotGitHubCookieImporter { + private static let cookieClient = BrowserCookieClient() + private static let cookieImportOrder: BrowserCookieImportOrder = + ProviderDefaults.metadata[.copilot]?.browserCookieOrder ?? Browser.defaultImportOrder + private static let sessionCookieNames: Set = [ + "user_session", + "__Host-user_session_same_site", + "_gh_sess", + "logged_in", + "dotcom_user", + ] + + struct SessionInfo: Sendable { + let cookies: [HTTPCookie] + let sourceLabel: String + + var cookieHeader: String { + self.cookies.map { "\($0.name)=\($0.value)" }.joined(separator: "; ") + } + } + + static func importSessions(browserDetection: BrowserDetection) -> [SessionInfo] { + let installedBrowsers = self.cookieImportOrder.cookieImportCandidates(using: browserDetection) + return installedBrowsers.flatMap { browser -> [SessionInfo] in + do { + return try self.importSessions(from: browser) + } catch { + BrowserCookieAccessGate.recordIfNeeded(error) + return [] + } + } + } + + private static func importSessions(from browser: Browser) throws -> [SessionInfo] { + let query = BrowserCookieQuery(domains: ["github.com", "www.github.com"]) + let sources = try self.cookieClient.codexBarRecords(matching: query, in: browser) + return sources.compactMap { source -> SessionInfo? in + guard !source.records.isEmpty else { return nil } + let cookies = BrowserCookieClient.makeHTTPCookies(source.records, origin: query.origin) + guard cookies.contains(where: { self.sessionCookieNames.contains($0.name) }) else { + return nil + } + return SessionInfo(cookies: cookies, sourceLabel: source.label) + } + } +} +#endif diff --git a/Sources/CodexBarCore/Providers/Copilot/CopilotProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Copilot/CopilotProviderDescriptor.swift index 3709cabed7..02c54ed08d 100644 --- a/Sources/CodexBarCore/Providers/Copilot/CopilotProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Copilot/CopilotProviderDescriptor.swift @@ -21,6 +21,7 @@ public enum CopilotProviderDescriptor { defaultEnabled: false, isPrimaryProvider: false, usesAccountFallback: false, + browserCookieOrder: ProviderBrowserCookieDefaults.defaultImportOrder, dashboardURL: "https://github.com/settings/copilot", statusPageURL: "https://www.githubstatus.com/"), branding: ProviderBranding( @@ -54,7 +55,8 @@ struct CopilotAPIFetchStrategy: ProviderFetchStrategy { let fetcher = CopilotUsageFetcher( token: token, enterpriseHost: context.settings?.copilot?.enterpriseHost) - let snap = try await fetcher.fetch() + let usage = try await fetcher.fetch() + let snap = await self.addBudgetWindowsIfNeeded(to: usage, context: context) return self.makeResult( usage: snap, sourceLabel: "api") @@ -70,4 +72,61 @@ struct CopilotAPIFetchStrategy: ProviderFetchStrategy { "COPILOT_API_TOKEN": context.settings?.copilot?.apiToken ?? "", ])?.token } + + private func addBudgetWindowsIfNeeded( + to usage: UsageSnapshot, + context: ProviderFetchContext) async -> UsageSnapshot + { + guard let settings = context.settings?.copilot, + settings.budgetExtrasEnabled, + settings.budgetCookieSource != .off + else { return usage } + + let manualCookieHeader: String? + if settings.budgetCookieSource == .manual { + let cookieHeader = settings.manualBudgetCookieHeader?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !cookieHeader.isEmpty else { return usage } + manualCookieHeader = cookieHeader + } else { + let cookieHeader = settings.manualBudgetCookieHeader?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + manualCookieHeader = cookieHeader.isEmpty ? nil : cookieHeader + } + do { + let extraRateWindows = try await CopilotBudgetWebFetcher( + cookieHeaderOverride: manualCookieHeader, + browserDetection: context.browserDetection) + .fetchBudgetWindows() + guard !extraRateWindows.isEmpty else { return usage } + return Self.snapshot(usage, withExtraRateWindows: extraRateWindows) + } catch { + CodexBarLog.logger(LogCategories.providers).warning( + "Copilot budget extras unavailable", + metadata: ["error": "\(error.localizedDescription)"]) + return usage + } + } + + private static func snapshot( + _ snapshot: UsageSnapshot, + withExtraRateWindows extraRateWindows: [NamedRateWindow]) -> UsageSnapshot + { + UsageSnapshot( + primary: snapshot.primary, + secondary: snapshot.secondary, + tertiary: snapshot.tertiary, + extraRateWindows: extraRateWindows, + kiroUsage: snapshot.kiroUsage, + providerCost: snapshot.providerCost, + zaiUsage: snapshot.zaiUsage, + minimaxUsage: snapshot.minimaxUsage, + deepseekUsage: snapshot.deepseekUsage, + openRouterUsage: snapshot.openRouterUsage, + openAIAPIUsage: snapshot.openAIAPIUsage, + claudeAdminAPIUsage: snapshot.claudeAdminAPIUsage, + mistralUsage: snapshot.mistralUsage, + deepgramUsage: snapshot.deepgramUsage, + cursorRequests: snapshot.cursorRequests, + updatedAt: snapshot.updatedAt, + identity: snapshot.identity) + } } diff --git a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift index af033f4b46..126ab18a2c 100644 --- a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift +++ b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift @@ -206,10 +206,22 @@ public struct ProviderSettingsSnapshot: Sendable { public struct CopilotProviderSettings: Sendable { public let apiToken: String? public let enterpriseHost: String? + public let budgetExtrasEnabled: Bool + public let budgetCookieSource: ProviderCookieSource + public let manualBudgetCookieHeader: String? - public init(apiToken: String? = nil, enterpriseHost: String? = nil) { + public init( + apiToken: String? = nil, + enterpriseHost: String? = nil, + budgetExtrasEnabled: Bool = false, + budgetCookieSource: ProviderCookieSource = .auto, + manualBudgetCookieHeader: String? = nil) + { self.apiToken = apiToken self.enterpriseHost = enterpriseHost + self.budgetExtrasEnabled = budgetExtrasEnabled + self.budgetCookieSource = budgetCookieSource + self.manualBudgetCookieHeader = manualBudgetCookieHeader } } diff --git a/Tests/CodexBarTests/CodexbarTests.swift b/Tests/CodexBarTests/CodexbarTests.swift index 4a907a53cc..7573731ac2 100644 --- a/Tests/CodexBarTests/CodexbarTests.swift +++ b/Tests/CodexBarTests/CodexbarTests.swift @@ -163,6 +163,45 @@ struct CodexBarTests { #expect(regionHasFill(xRange: 3...33, yRange: 19...31)) } + @Test + func `copilot icon can use selected budget as secondary lane`() { + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 20, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 30, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + extraRateWindows: [ + NamedRateWindow( + id: "copilot-budget-agent", + title: "Budget - Copilot Agent Premium Requests", + window: RateWindow(usedPercent: 65, windowMinutes: nil, resetsAt: nil, resetDescription: nil)), + ], + updatedAt: Date()) + + let remaining = IconRemainingResolver.resolvedRemaining( + snapshot: snapshot, + style: .copilot, + secondaryOverrideWindowID: "copilot-budget-agent") + + #expect(remaining.primary == 80) + #expect(remaining.secondary == 35) + } + + @Test + func `copilot icon falls back to chat lane when selected budget is unavailable`() { + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 20, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 30, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + extraRateWindows: nil, + updatedAt: Date()) + + let remaining = IconRemainingResolver.resolvedRemaining( + snapshot: snapshot, + style: .copilot, + secondaryOverrideWindowID: "copilot-budget-agent") + + #expect(remaining.primary == 80) + #expect(remaining.secondary == 70) + } + @Test func `codex icon promotes weekly only window into primary display lane`() { let snapshot = UsageSnapshot( diff --git a/Tests/CodexBarTests/CopilotBudgetWebFetcherTests.swift b/Tests/CodexBarTests/CopilotBudgetWebFetcherTests.swift new file mode 100644 index 0000000000..749792bcf3 --- /dev/null +++ b/Tests/CodexBarTests/CopilotBudgetWebFetcherTests.swift @@ -0,0 +1,103 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct CopilotBudgetWebFetcherTests { + @Test + func `maps positive copilot budgets to extra rate windows`() { + let budgets: [CopilotBudgetWebFetcher.Budget] = [ + .init( + id: "product-budget", + budgetProductSkus: ["copilot"], + budgetAmount: 100, + currentAmount: 15), + .init( + id: "agent-budget", + budgetProductSkus: ["copilot_agent_premium_request"], + budgetAmount: 20, + currentAmount: 5), + .init( + id: "zero-budget", + budgetProductSkus: ["spark_premium_request"], + budgetAmount: 0, + currentAmount: 0), + ] + + let windows = CopilotBudgetWebFetcher.extraRateWindows( + from: budgets, + now: Date(timeIntervalSince1970: 1_780_358_400)) + + #expect(windows.map(\.id) == ["copilot-budget-product-budget", "copilot-budget-agent-budget"]) + #expect(windows.map(\.title) == ["Budget - Copilot", "Budget - Copilot Agent Premium Requests"]) + #expect(windows[0].window.usedPercent == 15) + #expect(windows[1].window.usedPercent == 25) + #expect(windows.allSatisfy { $0.window.resetsAt != nil }) + } + + @Test + func `decodes github web budget response shape`() throws { + let data = Data(""" + { + "payload": { + "budgets": [ + { + "uuid": "budget-1", + "targetName": "Example", + "pricingTargetType": "BundlePricing", + "pricingTargetId": "premium_requests", + "targetAmount": 30.0, + "currentAmount": 0.0 + } + ], + "has_next_page": false + } + } + """.utf8) + + let response = try JSONDecoder().decode(CopilotBudgetWebFetcher.BudgetResponse.self, from: data) + let budget = try #require(response.budgets.first) + #expect(response.hasNextPage == false) + #expect(budget.id == "budget-1") + #expect(budget.budgetEntityName == "Example") + #expect(budget.budgetAmount == 30) + #expect(budget.currentAmount == 0) + + let windows = CopilotBudgetWebFetcher.extraRateWindows( + from: response.budgets, + now: Date(timeIntervalSince1970: 1_780_358_400)) + #expect(windows.map(\.title) == ["Budget - All Premium Request SKUs"]) + #expect(windows.first?.window.usedPercent == 0) + } + + @Test + func `normalizes documented copilot billing names`() { + #expect(CopilotBudgetWebFetcher.normalizedBillingIdentifier("Copilot") == "copilot") + #expect( + CopilotBudgetWebFetcher.normalizedBillingIdentifier("Copilot Premium Request") == + "copilot_premium_request") + #expect( + CopilotBudgetWebFetcher.normalizedBillingIdentifier("Copilot Agent Premium Request") == + "copilot_agent_premium_request") + #expect( + CopilotBudgetWebFetcher.normalizedBillingIdentifier("Spark Premium Request") == + "spark_premium_request") + #expect( + CopilotBudgetWebFetcher.normalizedBillingIdentifier("Premium requests") == + "copilot_premium_request") + #expect( + CopilotBudgetWebFetcher.normalizedBillingIdentifier("Bundled premium request budget") == + "copilot_premium_request") + #expect( + CopilotBudgetWebFetcher.normalizedBillingIdentifier("Copilot cloud agent premium requests") == + "copilot_agent_premium_request") + #expect( + CopilotBudgetWebFetcher.normalizedBillingIdentifier("coding_agent_premium_request") == + "copilot_agent_premium_request") + } + + @Test + func `extracts github fetch nonce from html`() { + let html = #""# + #expect(CopilotBudgetWebFetcher.extractFetchNonce(from: html) == "v2:abc-123") + } +} diff --git a/Tests/CodexBarTests/MenuCardModelTests.swift b/Tests/CodexBarTests/MenuCardModelTests.swift index 5842663519..6a95fc7616 100644 --- a/Tests/CodexBarTests/MenuCardModelTests.swift +++ b/Tests/CodexBarTests/MenuCardModelTests.swift @@ -961,6 +961,44 @@ struct MenuCardModelTests { #expect(model.metrics.map(\.title) == ["Session", "Weekly", "Sonnet", "Daily Routines"]) } + @Test + func `hides copilot budget bars when budget extras are disabled`() throws { + let now = Date() + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 20, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 30, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + extraRateWindows: [ + NamedRateWindow( + id: "copilot-budget-agent", + title: "Budget - Copilot Agent Premium Requests", + window: RateWindow(usedPercent: 65, windowMinutes: nil, resetsAt: nil, resetDescription: nil)), + ], + updatedAt: now) + let metadata = try #require(ProviderDefaults.metadata[.copilot]) + let model = UsageMenuCardView.Model.make(.init( + provider: .copilot, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + copilotBudgetExtrasEnabled: false, + hidePersonalInfo: false, + now: now)) + + #expect(model.metrics.map(\.title) == ["Premium", "Chat"]) + } + @Test func `shows error subtitle when present`() throws { let metadata = try #require(ProviderDefaults.metadata[.codex]) diff --git a/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift b/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift index 29c9178ec8..70c08954a7 100644 --- a/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift +++ b/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift @@ -153,6 +153,18 @@ struct ProviderSettingsDescriptorTests { #expect(fields.contains(where: { $0.id == "kilo-api-key" })) } + @Test + func `copilot budget secondary picker appears before cookie picker`() throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-copilot-budget-pickers") + fixture.settings.copilotBudgetExtrasEnabled = true + let context = fixture.settingsContext(provider: .copilot) + + let pickers = CopilotProviderImplementation().settingsPickers(context: context) + + #expect(pickers.map(\.id) == ["copilot-icon-secondary-window", "copilot-budget-cookie-source"]) + #expect(pickers.first?.title == "Menu bar secondary metric") + } + @Test func `deepgram exposes api key and project id fields`() throws { let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-deepgram") diff --git a/Tests/CodexBarTests/SettingsStoreCoverageTests.swift b/Tests/CodexBarTests/SettingsStoreCoverageTests.swift index fc68cba5b6..2bc96c7352 100644 --- a/Tests/CodexBarTests/SettingsStoreCoverageTests.swift +++ b/Tests/CodexBarTests/SettingsStoreCoverageTests.swift @@ -99,6 +99,24 @@ struct SettingsStoreCoverageTests { #expect(snapshot.manualCookieHeader == "HERTZ-SESSION=global") } + @Test + func `copilot budget extras default off and persist in provider snapshot`() throws { + let suite = "SettingsStoreCoverageTests-copilot-budget-extras" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + + let initial = Self.makeSettingsStore(userDefaults: defaults, configStore: configStore) + #expect(initial.copilotBudgetExtrasEnabled == false) + #expect(initial.copilotSettingsSnapshot(tokenOverride: nil).budgetExtrasEnabled == false) + + initial.copilotBudgetExtrasEnabled = true + + let reloaded = Self.makeSettingsStore(userDefaults: defaults, configStore: configStore) + #expect(reloaded.copilotBudgetExtrasEnabled) + #expect(reloaded.copilotSettingsSnapshot(tokenOverride: nil).budgetExtrasEnabled) + } + @Test func `multi account menu layout persists and bridges legacy show all token accounts`() throws { let suite = "SettingsStoreCoverageTests-multi-account-layout" diff --git a/docs/copilot.md b/docs/copilot.md index 23f19a016e..17e3091887 100644 --- a/docs/copilot.md +++ b/docs/copilot.md @@ -1,5 +1,5 @@ --- -summary: "Copilot provider data sources: GitHub device flow + Copilot internal usage API." +summary: "Copilot provider data sources: GitHub device flow, Copilot internal usage API, and optional GitHub web budgets." read_when: - Debugging Copilot login or usage parsing - Updating GitHub OAuth device flow behavior @@ -34,11 +34,31 @@ Copilot uses GitHub OAuth device flow and the Copilot internal usage API. No bro - `Editor-Version: vscode/1.96.2` - `Editor-Plugin-Version: copilot-chat/0.26.7` - `User-Agent: GitHubCopilotChat/0.26.7` - - `X-Github-Api-Version: 2025-04-01` + - `X-Github-Api-Version: 2025-04-01` + +3) **Budget fetch** (optional GitHub web endpoint, best-effort) + - Disabled by default. The Copilot provider's "Budget extras" setting must be enabled before CodexBar imports + github.com cookies or renders budget bars. + - CodexBar asks the logged-in GitHub web endpoint for customer-scope budgets: + - `GET https://github.com/settings/billing/budgets?page=&page_size=10&scope=customer` + - Headers: + - `Cookie: ` + - `Accept: application/json` + - `X-Requested-With: XMLHttpRequest` + - `GitHub-Verified-Fetch: true` + - `X-Fetch-Nonce: ` + - CodexBar first tries to read a fresh nonce from `https://github.com/settings/billing/budgets`, then calls the JSON + endpoint. If GitHub rejects the web request, CodexBar keeps the normal Copilot quota bars and omits budget bars. + - This is intentionally not the public GitHub REST billing API. The REST API did not expose the personal budget list + for the tested individual account. ## Snapshot mapping - Primary: `quotaSnapshots.premiumInteractions` percent remaining → used percent. - Secondary: `quotaSnapshots.chat` percent remaining → used percent. +- Extra: positive Copilot billing budgets from the GitHub web endpoint → `extraRateWindows`, only when "Budget extras" + is enabled. + - Product budget: `copilot` + - SKU budgets: `copilot_premium_request`, `copilot_agent_premium_request`, `spark_premium_request` - Reset dates are not provided by the API. - Plan label from `copilotPlan`. From 4f65b9d3f781f19224b6a451aee150207d3690ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Mon, 1 Jun 2026 17:07:51 +0100 Subject: [PATCH 02/14] Fix Copilot budget cookie imports - Default Copilot budget cookie import to Chrome only - Surface budget fetch and decode failures consistently - Cover cookie defaults and invalid budget JSON --- .../Copilot/CopilotBudgetWebFetcher.swift | 9 ++++-- .../Copilot/CopilotProviderDescriptor.swift | 2 +- .../CodexBarCore/Providers/Providers.swift | 9 ++++++ .../BrowserCookieOrderLabelTests.swift | 5 ++++ .../CopilotBudgetWebFetcherTests.swift | 29 +++++++++++++++++++ 5 files changed, 51 insertions(+), 3 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Copilot/CopilotBudgetWebFetcher.swift b/Sources/CodexBarCore/Providers/Copilot/CopilotBudgetWebFetcher.swift index 873696bf9c..81585cd731 100644 --- a/Sources/CodexBarCore/Providers/Copilot/CopilotBudgetWebFetcher.swift +++ b/Sources/CodexBarCore/Providers/Copilot/CopilotBudgetWebFetcher.swift @@ -350,6 +350,7 @@ public struct CopilotBudgetWebFetcher: Sendable { if case Error.notLoggedIn = error { continue } + throw error } } #endif @@ -422,7 +423,11 @@ public struct CopilotBudgetWebFetcher: Sendable { let response = try await self.transport.response(for: request) switch response.statusCode { case 200: - return try JSONDecoder().decode(BudgetResponse.self, from: response.data) + do { + return try JSONDecoder().decode(BudgetResponse.self, from: response.data) + } catch { + throw Error.invalidResponse + } case 401, 403: throw Error.notLoggedIn default: @@ -582,7 +587,7 @@ public struct CopilotBudgetWebFetcher: Sendable { private enum CopilotGitHubCookieImporter { private static let cookieClient = BrowserCookieClient() private static let cookieImportOrder: BrowserCookieImportOrder = - ProviderDefaults.metadata[.copilot]?.browserCookieOrder ?? Browser.defaultImportOrder + ProviderDefaults.metadata[.copilot]?.browserCookieOrder ?? [.chrome] private static let sessionCookieNames: Set = [ "user_session", "__Host-user_session_same_site", diff --git a/Sources/CodexBarCore/Providers/Copilot/CopilotProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Copilot/CopilotProviderDescriptor.swift index 02c54ed08d..2693a2cf25 100644 --- a/Sources/CodexBarCore/Providers/Copilot/CopilotProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Copilot/CopilotProviderDescriptor.swift @@ -21,7 +21,7 @@ public enum CopilotProviderDescriptor { defaultEnabled: false, isPrimaryProvider: false, usesAccountFallback: false, - browserCookieOrder: ProviderBrowserCookieDefaults.defaultImportOrder, + browserCookieOrder: ProviderBrowserCookieDefaults.copilotCookieImportOrder, dashboardURL: "https://github.com/settings/copilot", statusPageURL: "https://www.githubstatus.com/"), branding: ProviderBranding( diff --git a/Sources/CodexBarCore/Providers/Providers.swift b/Sources/CodexBarCore/Providers/Providers.swift index 6678c6cc96..e44982276d 100644 --- a/Sources/CodexBarCore/Providers/Providers.swift +++ b/Sources/CodexBarCore/Providers/Providers.swift @@ -243,4 +243,13 @@ public enum ProviderBrowserCookieDefaults { nil #endif } + + /// Copilot budget imports should stay Chrome-only by default to avoid prompting unrelated browsers. + public static var copilotCookieImportOrder: BrowserCookieImportOrder? { + #if os(macOS) + [.chrome] + #else + nil + #endif + } } diff --git a/Tests/CodexBarTests/BrowserCookieOrderLabelTests.swift b/Tests/CodexBarTests/BrowserCookieOrderLabelTests.swift index d2bdd21d66..c8cf68d48a 100644 --- a/Tests/CodexBarTests/BrowserCookieOrderLabelTests.swift +++ b/Tests/CodexBarTests/BrowserCookieOrderLabelTests.swift @@ -64,5 +64,10 @@ struct BrowserCookieOrderStatusStringTests { #expect(!order.contains(.arc)) } + @Test + func `copilot cookie imports default to chrome only`() { + #expect(ProviderDefaults.metadata[.copilot]?.browserCookieOrder == [.chrome]) + #expect(ProviderBrowserCookieDefaults.copilotCookieImportOrder == [.chrome]) + } #endif } diff --git a/Tests/CodexBarTests/CopilotBudgetWebFetcherTests.swift b/Tests/CodexBarTests/CopilotBudgetWebFetcherTests.swift index 749792bcf3..22b06524b2 100644 --- a/Tests/CodexBarTests/CopilotBudgetWebFetcherTests.swift +++ b/Tests/CodexBarTests/CopilotBudgetWebFetcherTests.swift @@ -100,4 +100,33 @@ struct CopilotBudgetWebFetcherTests { let html = #""# #expect(CopilotBudgetWebFetcher.extractFetchNonce(from: html) == "v2:abc-123") } + + @Test + func `invalid github budget JSON maps to invalid response`() async throws { + let transport = ProviderHTTPTransportStub { request in + guard let url = request.url, + let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: nil) + else { + throw URLError(.badServerResponse) + } + if request.url?.query?.contains("page=") == true { + return (Data("{".utf8), response) + } + return (Data(#""#.utf8), response) + } + let fetcher = CopilotBudgetWebFetcher( + transport: transport, + now: { Date(timeIntervalSince1970: 1_780_358_400) }) + + do { + _ = try await fetcher.fetchBudgetWindows(cookieHeader: "user_session=abc") + Issue.record("Expected invalidResponse") + } catch let error as CopilotBudgetWebFetcher.Error { + #expect(error == .invalidResponse) + } + } } From db56d2f542430afd8b7bfe5ec435d34d6bd85515 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Mon, 1 Jun 2026 17:15:24 +0100 Subject: [PATCH 03/14] Fix Copilot budget cookie routing - Ignore stale manual cookies when budget cookies are auto - Cover Copilot budget cookie source routing --- .../Copilot/CopilotProviderDescriptor.swift | 19 ++++++----- .../CopilotBudgetCookieRoutingTests.swift | 34 +++++++++++++++++++ 2 files changed, 45 insertions(+), 8 deletions(-) create mode 100644 Tests/CodexBarTests/CopilotBudgetCookieRoutingTests.swift diff --git a/Sources/CodexBarCore/Providers/Copilot/CopilotProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Copilot/CopilotProviderDescriptor.swift index 2693a2cf25..636247c7c6 100644 --- a/Sources/CodexBarCore/Providers/Copilot/CopilotProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Copilot/CopilotProviderDescriptor.swift @@ -82,14 +82,9 @@ struct CopilotAPIFetchStrategy: ProviderFetchStrategy { settings.budgetCookieSource != .off else { return usage } - let manualCookieHeader: String? - if settings.budgetCookieSource == .manual { - let cookieHeader = settings.manualBudgetCookieHeader?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - guard !cookieHeader.isEmpty else { return usage } - manualCookieHeader = cookieHeader - } else { - let cookieHeader = settings.manualBudgetCookieHeader?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - manualCookieHeader = cookieHeader.isEmpty ? nil : cookieHeader + let manualCookieHeader = Self.budgetCookieHeaderOverride(from: settings) + if settings.budgetCookieSource == .manual, manualCookieHeader == nil { + return usage } do { let extraRateWindows = try await CopilotBudgetWebFetcher( @@ -106,6 +101,14 @@ struct CopilotAPIFetchStrategy: ProviderFetchStrategy { } } + static func budgetCookieHeaderOverride( + from settings: ProviderSettingsSnapshot.CopilotProviderSettings) -> String? + { + guard settings.budgetCookieSource == .manual else { return nil } + let cookieHeader = settings.manualBudgetCookieHeader?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return cookieHeader.isEmpty ? nil : cookieHeader + } + private static func snapshot( _ snapshot: UsageSnapshot, withExtraRateWindows extraRateWindows: [NamedRateWindow]) -> UsageSnapshot diff --git a/Tests/CodexBarTests/CopilotBudgetCookieRoutingTests.swift b/Tests/CodexBarTests/CopilotBudgetCookieRoutingTests.swift new file mode 100644 index 0000000000..c2df05d2ac --- /dev/null +++ b/Tests/CodexBarTests/CopilotBudgetCookieRoutingTests.swift @@ -0,0 +1,34 @@ +import Testing +@testable import CodexBarCore + +struct CopilotBudgetCookieRoutingTests { + @Test + func `auto budget cookies ignore stale manual header`() { + let settings = ProviderSettingsSnapshot.CopilotProviderSettings( + budgetExtrasEnabled: true, + budgetCookieSource: .auto, + manualBudgetCookieHeader: "user_session=stale") + + #expect(CopilotAPIFetchStrategy.budgetCookieHeaderOverride(from: settings) == nil) + } + + @Test + func `manual budget cookies use trimmed manual header`() { + let settings = ProviderSettingsSnapshot.CopilotProviderSettings( + budgetExtrasEnabled: true, + budgetCookieSource: .manual, + manualBudgetCookieHeader: " user_session=manual ") + + #expect(CopilotAPIFetchStrategy.budgetCookieHeaderOverride(from: settings) == "user_session=manual") + } + + @Test + func `manual budget cookies require non-empty header`() { + let settings = ProviderSettingsSnapshot.CopilotProviderSettings( + budgetExtrasEnabled: true, + budgetCookieSource: .manual, + manualBudgetCookieHeader: " ") + + #expect(CopilotAPIFetchStrategy.budgetCookieHeaderOverride(from: settings) == nil) + } +} From 4d19fc1bbe2749633df22c3100ed98ca2a853376 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Mon, 1 Jun 2026 17:29:13 +0100 Subject: [PATCH 04/14] Fix Copilot budget review issues - Default budget extras off unless explicitly enabled - Harden budget web fetch and manual cookie refresh - Document budget cookie and reset heuristics --- Sources/CodexBar/MenuCardView.swift | 2 +- .../CopilotProviderImplementation.swift | 23 +++-- .../Copilot/CopilotBudgetWebFetcher.swift | 66 ++++++++++---- .../CopilotBudgetWebFetcherTests.swift | 87 +++++++++++++++++++ Tests/CodexBarTests/MenuCardModelTests.swift | 1 - .../ProviderSettingsDescriptorTests.swift | 15 ++++ docs/copilot.md | 6 +- 7 files changed, 172 insertions(+), 28 deletions(-) diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 785e99dd39..e6817a4254 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -743,7 +743,7 @@ extension UsageMenuCardView.Model { resetTimeDisplayStyle: ResetTimeDisplayStyle, tokenCostUsageEnabled: Bool, showOptionalCreditsAndExtraUsage: Bool, - copilotBudgetExtrasEnabled: Bool = true, + copilotBudgetExtrasEnabled: Bool = false, sourceLabel: String? = nil, kiloAutoMode: Bool = false, hidePersonalInfo: Bool, diff --git a/Sources/CodexBar/Providers/Copilot/CopilotProviderImplementation.swift b/Sources/CodexBar/Providers/Copilot/CopilotProviderImplementation.swift index 668a271f9c..19d246f51e 100644 --- a/Sources/CodexBar/Providers/Copilot/CopilotProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Copilot/CopilotProviderImplementation.swift @@ -98,7 +98,7 @@ struct CopilotProviderImplementation: ProviderImplementation { ProviderCookieSourceUI.subtitle( source: context.settings.copilotBudgetCookieSource, keychainDisabled: context.settings.debugDisableKeychainAccess, - auto: "Automatic imports browser cookies for github.com budget extras.", + auto: "Automatically imports browser cookies for github.com budget extras.", manual: "Paste a Cookie header from github.com.", off: "GitHub cookies are disabled.") } @@ -139,12 +139,14 @@ struct CopilotProviderImplementation: ProviderImplementation { ProviderSettingsPickerDescriptor( id: "copilot-budget-cookie-source", title: "GitHub cookies", - subtitle: "Automatic imports browser cookies for budget extras.", + subtitle: "Automatically imports browser cookies for budget extras.", dynamicSubtitle: cookieSubtitle, binding: cookieBinding, options: cookieOptions, isVisible: { context.settings.copilotBudgetExtrasEnabled }, - onChange: nil, + onChange: { _ in + await context.store.refreshProvider(.copilot, allowDisabled: true) + }, trailingText: { guard context.settings.copilotBudgetCookieSource != .manual else { return nil } guard let entry = CookieHeaderCache.load(provider: .copilot) else { return nil } @@ -159,12 +161,21 @@ struct CopilotProviderImplementation: ProviderImplementation { [ ProviderSettingsFieldDescriptor( id: "copilot-budget-cookie-header", - title: "", - subtitle: "", + title: "Manual GitHub Cookie header", + subtitle: "Paste a github.com Cookie header. Treat this value like a password.", kind: .secure, placeholder: "Cookie: ...", binding: context.stringBinding(\.copilotBudgetCookieHeader), - actions: [], + actions: [ + ProviderSettingsActionDescriptor( + id: "refresh-copilot-budget-cookie", + title: "Refresh budgets", + style: .bordered, + isVisible: nil, + perform: { + await context.store.refreshProvider(.copilot, allowDisabled: true) + }), + ], isVisible: { context.settings.copilotBudgetExtrasEnabled && context.settings.copilotBudgetCookieSource == .manual diff --git a/Sources/CodexBarCore/Providers/Copilot/CopilotBudgetWebFetcher.swift b/Sources/CodexBarCore/Providers/Copilot/CopilotBudgetWebFetcher.swift index 81585cd731..410d583e96 100644 --- a/Sources/CodexBarCore/Providers/Copilot/CopilotBudgetWebFetcher.swift +++ b/Sources/CodexBarCore/Providers/Copilot/CopilotBudgetWebFetcher.swift @@ -220,9 +220,12 @@ public struct CopilotBudgetWebFetcher: Sendable { } fileprivate static func parseAmount(_ value: String) -> Double? { - let filtered = value.filter { $0.isNumber || $0 == "." || $0 == "-" } - guard !filtered.isEmpty else { return nil } - return Double(filtered) + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + let isNegative = trimmed.first == "-" + guard !trimmed.dropFirst(isNegative ? 1 : 0).contains("-") else { return nil } + let unsigned = trimmed.filter { $0.isNumber || $0 == "." } + guard !unsigned.isEmpty else { return nil } + return Double(isNegative ? "-\(unsigned)" : unsigned) } } @@ -333,6 +336,8 @@ public struct CopilotBudgetWebFetcher: Sendable { } catch { if case Error.notLoggedIn = error { CookieHeaderCache.clear(provider: .copilot) + } else { + throw error } } } @@ -359,7 +364,7 @@ public struct CopilotBudgetWebFetcher: Sendable { } func fetchBudgetWindows(cookieHeader: String) async throws -> [NamedRateWindow] { - let nonce = try? await self.fetchNonce(cookieHeader: cookieHeader) + let nonce = await self.bestEffortFetchNonce(cookieHeader: cookieHeader) var allBudgets: [Budget] = [] var page = 1 var shouldContinue = true @@ -375,6 +380,16 @@ public struct CopilotBudgetWebFetcher: Sendable { return Self.extraRateWindows(from: allBudgets, now: self.now()) } + private func bestEffortFetchNonce(cookieHeader: String) async -> String? { + do { + return try await self.fetchNonce(cookieHeader: cookieHeader) + } catch { + // GitHub accepts some budget requests without a nonce. Keep auth failures and + // page-shape changes non-fatal so a valid Cookie header can still be tried. + return nil + } + } + private func fetchNonce(cookieHeader: String) async throws -> String? { guard let url = URL(string: "https://github.com/settings/billing/budgets") else { throw URLError(.badURL) @@ -411,7 +426,6 @@ public struct CopilotBudgetWebFetcher: Sendable { request.timeoutInterval = 15 request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") request.setValue("application/json", forHTTPHeaderField: "Accept") - request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("https://github.com/settings/billing/budgets", forHTTPHeaderField: "Referer") request.setValue("XMLHttpRequest", forHTTPHeaderField: "X-Requested-With") request.setValue("true", forHTTPHeaderField: "GitHub-Verified-Fetch") @@ -457,27 +471,36 @@ public struct CopilotBudgetWebFetcher: Sendable { static func extraRateWindows(from budgets: [Budget], now: Date) -> [NamedRateWindow] { var usedIDs = Set() + let resetDate = self.approximateNextMonthResetDate(now: now) return budgets - .filter(Self.isCopilotBudget) - .map { budget in - let id = self.uniqueWindowID(for: budget, usedIDs: &usedIDs) + .compactMap { budget in + let selectors = budget.normalizedSelectors + guard self.isCopilotBudget(budget, selectors: selectors) else { return nil } + let id = self.uniqueWindowID(for: budget, selectors: selectors, usedIDs: &usedIDs) let usedPercent = budget.budgetAmount > 0 ? min(999, max(0, budget.currentAmount / budget.budgetAmount * 100)) : 0 let window = RateWindow( usedPercent: usedPercent, windowMinutes: nil, - resetsAt: self.nextMonthResetDate(now: now), - resetDescription: self.nextMonthResetDate(now: now).map { + resetsAt: resetDate, + resetDescription: resetDate.map { UsageFormatter.resetDescription(from: $0, now: now) }) - return NamedRateWindow(id: id, title: self.windowTitle(for: budget), window: window) + return NamedRateWindow( + id: id, + title: self.windowTitle(for: budget, selectors: selectors), + window: window) } } static func isCopilotBudget(_ budget: Budget) -> Bool { + self.isCopilotBudget(budget, selectors: budget.normalizedSelectors) + } + + private static func isCopilotBudget(_ budget: Budget, selectors: Set) -> Bool { guard budget.budgetAmount > 0 else { return false } - return !budget.normalizedSelectors.isDisjoint(with: self.copilotBudgetSelectors) + return !selectors.isDisjoint(with: self.copilotBudgetSelectors) } static func normalizedBillingIdentifier(_ value: String?) -> String? { @@ -521,7 +544,10 @@ public struct CopilotBudgetWebFetcher: Sendable { } private static func windowTitle(for budget: Budget) -> String { - let selectors = budget.normalizedSelectors + self.windowTitle(for: budget, selectors: budget.normalizedSelectors) + } + + private static func windowTitle(for budget: Budget, selectors: Set) -> String { let budgetType = if selectors == [self.copilotProductID] { "Copilot" } else if selectors.contains(self.copilotAgentPremiumRequestSKU) { @@ -540,9 +566,13 @@ public struct CopilotBudgetWebFetcher: Sendable { return "Budget - \(budgetType)" } - private static func uniqueWindowID(for budget: Budget, usedIDs: inout Set) -> String { + private static func uniqueWindowID( + for budget: Budget, + selectors: Set, + usedIDs: inout Set) -> String + { let source = budget.id ?? budget.budgetProductSkus.joined(separator: "-") - let slug = self.slug(source.isEmpty ? self.windowTitle(for: budget) : source) + let slug = self.slug(source.isEmpty ? self.windowTitle(for: budget, selectors: selectors) : source) let base = slug.isEmpty ? "copilot-budget" : "copilot-budget-\(slug)" var candidate = base var suffix = 2 @@ -553,9 +583,11 @@ public struct CopilotBudgetWebFetcher: Sendable { return candidate } - private static func nextMonthResetDate(now: Date) -> Date? { + private static func approximateNextMonthResetDate(now: Date) -> Date? { + // GitHub's budget response does not expose a reset instant. Use the local + // start of next month as a display-only approximation. var calendar = Calendar(identifier: .gregorian) - calendar.timeZone = TimeZone(secondsFromGMT: 0) ?? .current + calendar.timeZone = .current let components = calendar.dateComponents([.year, .month], from: now) guard let monthStart = calendar.date(from: DateComponents( year: components.year, diff --git a/Tests/CodexBarTests/CopilotBudgetWebFetcherTests.swift b/Tests/CodexBarTests/CopilotBudgetWebFetcherTests.swift index 22b06524b2..9ae3fbda10 100644 --- a/Tests/CodexBarTests/CopilotBudgetWebFetcherTests.swift +++ b/Tests/CodexBarTests/CopilotBudgetWebFetcherTests.swift @@ -2,6 +2,7 @@ import Foundation import Testing @testable import CodexBarCore +@Suite(.serialized) struct CopilotBudgetWebFetcherTests { @Test func `maps positive copilot budgets to extra rate windows`() { @@ -69,6 +70,35 @@ struct CopilotBudgetWebFetcherTests { #expect(windows.first?.window.usedPercent == 0) } + @Test + func `ignores malformed embedded minus amounts`() throws { + let data = Data(""" + { + "budgets": [ + { + "uuid": "budget-1", + "pricingTargetId": "premium_requests", + "targetAmount": "1-5", + "currentAmount": "$5.00" + }, + { + "uuid": "budget-2", + "pricingTargetId": "premium_requests", + "targetAmount": "-$15.00", + "currentAmount": "$5.00" + } + ] + } + """.utf8) + + let response = try JSONDecoder().decode(CopilotBudgetWebFetcher.BudgetResponse.self, from: data) + + #expect(response.budgets.map(\.budgetAmount) == [0, -15]) + #expect(CopilotBudgetWebFetcher.extraRateWindows( + from: response.budgets, + now: Date(timeIntervalSince1970: 1_780_358_400)).isEmpty) + } + @Test func `normalizes documented copilot billing names`() { #expect(CopilotBudgetWebFetcher.normalizedBillingIdentifier("Copilot") == "copilot") @@ -129,4 +159,61 @@ struct CopilotBudgetWebFetcherTests { #expect(error == .invalidResponse) } } + + @Test + func `cached cookie non auth errors do not fall back to browser import`() async throws { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + CookieHeaderCache.store(provider: .copilot, cookieHeader: "user_session=cached", sourceLabel: "Chrome") + defer { CookieHeaderCache.clear(provider: .copilot) } + + let transport = ProviderHTTPTransportStub { request in + guard let url = request.url, + let response = HTTPURLResponse( + url: url, + statusCode: 500, + httpVersion: "HTTP/1.1", + headerFields: nil) + else { + throw URLError(.badServerResponse) + } + return (Data("{}".utf8), response) + } + let fetcher = CopilotBudgetWebFetcher(transport: transport) + + do { + _ = try await fetcher.fetchBudgetWindows() + Issue.record("Expected badStatus") + } catch let error as CopilotBudgetWebFetcher.Error { + #expect(error == .badStatus(500)) + } + + #expect(await transport.requests().count == 2) + #expect(CookieHeaderCache.load(provider: .copilot)?.cookieHeader == "user_session=cached") + } + + @Test + func `budget page request omits content type on get`() async throws { + let transport = ProviderHTTPTransportStub { request in + guard let url = request.url, + let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: nil) + else { + throw URLError(.badServerResponse) + } + if request.url?.query?.contains("page=") == true { + return (Data(#"{"budgets":[],"has_next_page":false}"#.utf8), response) + } + return (Data(#""#.utf8), response) + } + let fetcher = CopilotBudgetWebFetcher(transport: transport) + + _ = try await fetcher.fetchBudgetWindows(cookieHeader: "user_session=abc") + + let pageRequest = try #require(await transport.requests().first { $0.url?.query?.contains("page=") == true }) + #expect(pageRequest.value(forHTTPHeaderField: "Content-Type") == nil) + } } diff --git a/Tests/CodexBarTests/MenuCardModelTests.swift b/Tests/CodexBarTests/MenuCardModelTests.swift index 6a95fc7616..fd50b4c491 100644 --- a/Tests/CodexBarTests/MenuCardModelTests.swift +++ b/Tests/CodexBarTests/MenuCardModelTests.swift @@ -992,7 +992,6 @@ struct MenuCardModelTests { resetTimeDisplayStyle: .countdown, tokenCostUsageEnabled: false, showOptionalCreditsAndExtraUsage: true, - copilotBudgetExtrasEnabled: false, hidePersonalInfo: false, now: now)) diff --git a/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift b/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift index 70c08954a7..09aa338170 100644 --- a/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift +++ b/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift @@ -165,6 +165,21 @@ struct ProviderSettingsDescriptorTests { #expect(pickers.first?.title == "Menu bar secondary metric") } + @Test + func `copilot manual cookie field is labelled and refreshable`() throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-copilot-budget-field") + fixture.settings.copilotBudgetExtrasEnabled = true + fixture.settings.copilotBudgetCookieSource = .manual + let context = fixture.settingsContext(provider: .copilot) + + let fields = CopilotProviderImplementation().settingsFields(context: context) + let field = try #require(fields.first { $0.id == "copilot-budget-cookie-header" }) + + #expect(field.title == "Manual GitHub Cookie header") + #expect(field.subtitle.contains("Treat this value like a password")) + #expect(field.actions.map(\.id) == ["refresh-copilot-budget-cookie"]) + } + @Test func `deepgram exposes api key and project id fields`() throws { let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-deepgram") diff --git a/docs/copilot.md b/docs/copilot.md index 17e3091887..9643cc234f 100644 --- a/docs/copilot.md +++ b/docs/copilot.md @@ -7,7 +7,7 @@ read_when: # Copilot provider -Copilot uses GitHub OAuth device flow and the Copilot internal usage API. No browser cookies. +Copilot uses GitHub OAuth device flow and the Copilot internal usage API for primary usage. Optional budget extras use GitHub web cookies only when enabled. ## Data sources + fallback order @@ -23,7 +23,7 @@ Copilot uses GitHub OAuth device flow and the Copilot internal usage API. No bro - Scope: `read:user`. - Token stored in config: - `~/.codexbar/config.json` → `providers[].apiKey` for `copilot` - - token accounts use `providers[].tokenAccounts` + - token accounts use `providers[].tokenAccounts` 2) **Usage fetch** - `GET https://api.github.com/copilot_internal/user` @@ -34,7 +34,7 @@ Copilot uses GitHub OAuth device flow and the Copilot internal usage API. No bro - `Editor-Version: vscode/1.96.2` - `Editor-Plugin-Version: copilot-chat/0.26.7` - `User-Agent: GitHubCopilotChat/0.26.7` - - `X-Github-Api-Version: 2025-04-01` + - `X-Github-Api-Version: 2025-04-01` 3) **Budget fetch** (optional GitHub web endpoint, best-effort) - Disabled by default. The Copilot provider's "Budget extras" setting must be enabled before CodexBar imports From 54ef386929616898406d9cd9a29e3e8c7a11081d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Mon, 1 Jun 2026 17:39:44 +0100 Subject: [PATCH 05/14] Fix Copilot budget settings preview - Forward the budget extras setting into provider previews - Cover Copilot preview budget visibility --- .../CodexBar/PreferencesProvidersPane.swift | 1 + .../ProvidersPaneCoverageTests.swift | 29 +++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/Sources/CodexBar/PreferencesProvidersPane.swift b/Sources/CodexBar/PreferencesProvidersPane.swift index 55d1144143..c2099a97b3 100644 --- a/Sources/CodexBar/PreferencesProvidersPane.swift +++ b/Sources/CodexBar/PreferencesProvidersPane.swift @@ -667,6 +667,7 @@ struct ProvidersPane: View { resetTimeDisplayStyle: self.settings.resetTimeDisplayStyle, tokenCostUsageEnabled: self.settings.isCostUsageEffectivelyEnabled(for: provider), showOptionalCreditsAndExtraUsage: self.settings.showOptionalCreditsAndExtraUsage, + copilotBudgetExtrasEnabled: self.settings.copilotBudgetExtrasEnabled, hidePersonalInfo: self.settings.hidePersonalInfo, weeklyPace: weeklyPace, quotaWarningThresholds: [ diff --git a/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift b/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift index 7c01c3e275..51a0cca053 100644 --- a/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift +++ b/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift @@ -51,6 +51,35 @@ struct ProvidersPaneCoverageTests { == [.deepseek]) } + @Test + func `copilot menu card preview follows budget extras setting`() { + let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-copilot-budget-preview") + let store = Self.makeUsageStore(settings: settings) + let budgetTitle = "Budget - Copilot Agent Premium Requests" + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow(usedPercent: 20, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 30, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + extraRateWindows: [ + NamedRateWindow( + id: "copilot-budget-agent", + title: budgetTitle, + window: RateWindow( + usedPercent: 65, + windowMinutes: nil, + resetsAt: nil, + resetDescription: nil)), + ], + updatedAt: Date()), + provider: .copilot) + let pane = ProvidersPane(settings: settings, store: store) + + #expect(!pane._test_menuCardModel(for: .copilot).metrics.map(\.title).contains(budgetTitle)) + + settings.copilotBudgetExtrasEnabled = true + #expect(pane._test_menuCardModel(for: .copilot).metrics.map(\.title).contains(budgetTitle)) + } + @Test func `open router menu bar metric picker shows only automatic and primary`() { Self.withEnglishLocalization { From 7b4408a03d977008f2d441c4e55082fdd500fa05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Mon, 1 Jun 2026 19:36:03 +0100 Subject: [PATCH 06/14] Add helper for Copilot budget snapshot copies - Centralize UsageSnapshot extra-window copying - Clear Copilot reset baselines consistently - Remove dead Copilot budget overloads - Log when budget pagination hits the page cap --- .../Copilot/UsageStore+CopilotBudgets.swift | 33 ++++-------- .../Copilot/CopilotBudgetWebFetcher.swift | 16 +++--- .../Copilot/CopilotProviderDescriptor.swift | 26 +-------- Sources/CodexBarCore/UsageFetcher.swift | 21 ++++++++ .../UsageStoreCoverageTests.swift | 53 +++++++++++++++++++ 5 files changed, 91 insertions(+), 58 deletions(-) diff --git a/Sources/CodexBar/Providers/Copilot/UsageStore+CopilotBudgets.swift b/Sources/CodexBar/Providers/Copilot/UsageStore+CopilotBudgets.swift index 9b2d35a316..0d6dc4075c 100644 --- a/Sources/CodexBar/Providers/Copilot/UsageStore+CopilotBudgets.swift +++ b/Sources/CodexBar/Providers/Copilot/UsageStore+CopilotBudgets.swift @@ -4,31 +4,16 @@ import Foundation @MainActor extension UsageStore { func clearCopilotBudgetExtras() { - guard let snapshot = self.snapshots[.copilot], - snapshot.extraRateWindows?.isEmpty == false - else { return } - - let updated = UsageSnapshot( - primary: snapshot.primary, - secondary: snapshot.secondary, - tertiary: snapshot.tertiary, - extraRateWindows: nil, - kiroUsage: snapshot.kiroUsage, - providerCost: snapshot.providerCost, - zaiUsage: snapshot.zaiUsage, - minimaxUsage: snapshot.minimaxUsage, - deepseekUsage: snapshot.deepseekUsage, - openRouterUsage: snapshot.openRouterUsage, - openAIAPIUsage: snapshot.openAIAPIUsage, - claudeAdminAPIUsage: snapshot.claudeAdminAPIUsage, - mistralUsage: snapshot.mistralUsage, - deepgramUsage: snapshot.deepgramUsage, - cursorRequests: snapshot.cursorRequests, - updatedAt: snapshot.updatedAt, - identity: snapshot.identity) - self.snapshots[.copilot] = updated - if self.lastKnownResetSnapshots[.copilot]?.extraRateWindows?.isEmpty == false { + if let snapshot = self.snapshots[.copilot], + snapshot.extraRateWindows?.isEmpty == false + { + let updated = snapshot.with(extraRateWindows: nil) + self.snapshots[.copilot] = updated self.lastKnownResetSnapshots[.copilot] = updated + } else if let resetSnapshot = self.lastKnownResetSnapshots[.copilot], + resetSnapshot.extraRateWindows?.isEmpty == false + { + self.lastKnownResetSnapshots[.copilot] = resetSnapshot.with(extraRateWindows: nil) } } } diff --git a/Sources/CodexBarCore/Providers/Copilot/CopilotBudgetWebFetcher.swift b/Sources/CodexBarCore/Providers/Copilot/CopilotBudgetWebFetcher.swift index 410d583e96..ee4a860949 100644 --- a/Sources/CodexBarCore/Providers/Copilot/CopilotBudgetWebFetcher.swift +++ b/Sources/CodexBarCore/Providers/Copilot/CopilotBudgetWebFetcher.swift @@ -367,8 +367,9 @@ public struct CopilotBudgetWebFetcher: Sendable { let nonce = await self.bestEffortFetchNonce(cookieHeader: cookieHeader) var allBudgets: [Budget] = [] var page = 1 + let maxPages = 20 var shouldContinue = true - while shouldContinue, page <= 20 { + while shouldContinue, page <= maxPages { let response = try await self.fetchBudgetPage( cookieHeader: cookieHeader, nonce: nonce, @@ -377,6 +378,11 @@ public struct CopilotBudgetWebFetcher: Sendable { shouldContinue = response.hasNextPage == true page += 1 } + if shouldContinue { + CodexBarLog.logger(LogCategories.providers).warning( + "Copilot budget pagination reached page cap", + metadata: ["pageCap": "\(maxPages)"]) + } return Self.extraRateWindows(from: allBudgets, now: self.now()) } @@ -494,10 +500,6 @@ public struct CopilotBudgetWebFetcher: Sendable { } } - static func isCopilotBudget(_ budget: Budget) -> Bool { - self.isCopilotBudget(budget, selectors: budget.normalizedSelectors) - } - private static func isCopilotBudget(_ budget: Budget, selectors: Set) -> Bool { guard budget.budgetAmount > 0 else { return false } return !selectors.isDisjoint(with: self.copilotBudgetSelectors) @@ -543,10 +545,6 @@ public struct CopilotBudgetWebFetcher: Sendable { return underscored } - private static func windowTitle(for budget: Budget) -> String { - self.windowTitle(for: budget, selectors: budget.normalizedSelectors) - } - private static func windowTitle(for budget: Budget, selectors: Set) -> String { let budgetType = if selectors == [self.copilotProductID] { "Copilot" diff --git a/Sources/CodexBarCore/Providers/Copilot/CopilotProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Copilot/CopilotProviderDescriptor.swift index 636247c7c6..dd0c160b36 100644 --- a/Sources/CodexBarCore/Providers/Copilot/CopilotProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Copilot/CopilotProviderDescriptor.swift @@ -92,7 +92,7 @@ struct CopilotAPIFetchStrategy: ProviderFetchStrategy { browserDetection: context.browserDetection) .fetchBudgetWindows() guard !extraRateWindows.isEmpty else { return usage } - return Self.snapshot(usage, withExtraRateWindows: extraRateWindows) + return usage.with(extraRateWindows: extraRateWindows) } catch { CodexBarLog.logger(LogCategories.providers).warning( "Copilot budget extras unavailable", @@ -108,28 +108,4 @@ struct CopilotAPIFetchStrategy: ProviderFetchStrategy { let cookieHeader = settings.manualBudgetCookieHeader?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" return cookieHeader.isEmpty ? nil : cookieHeader } - - private static func snapshot( - _ snapshot: UsageSnapshot, - withExtraRateWindows extraRateWindows: [NamedRateWindow]) -> UsageSnapshot - { - UsageSnapshot( - primary: snapshot.primary, - secondary: snapshot.secondary, - tertiary: snapshot.tertiary, - extraRateWindows: extraRateWindows, - kiroUsage: snapshot.kiroUsage, - providerCost: snapshot.providerCost, - zaiUsage: snapshot.zaiUsage, - minimaxUsage: snapshot.minimaxUsage, - deepseekUsage: snapshot.deepseekUsage, - openRouterUsage: snapshot.openRouterUsage, - openAIAPIUsage: snapshot.openAIAPIUsage, - claudeAdminAPIUsage: snapshot.claudeAdminAPIUsage, - mistralUsage: snapshot.mistralUsage, - deepgramUsage: snapshot.deepgramUsage, - cursorRequests: snapshot.cursorRequests, - updatedAt: snapshot.updatedAt, - identity: snapshot.identity) - } } diff --git a/Sources/CodexBarCore/UsageFetcher.swift b/Sources/CodexBarCore/UsageFetcher.swift index 5ec231fc28..6dcd7ed741 100644 --- a/Sources/CodexBarCore/UsageFetcher.swift +++ b/Sources/CodexBarCore/UsageFetcher.swift @@ -168,6 +168,27 @@ public struct UsageSnapshot: Codable, Sendable { self.identity = identity } + public func with(extraRateWindows: [NamedRateWindow]?) -> UsageSnapshot { + UsageSnapshot( + primary: self.primary, + secondary: self.secondary, + tertiary: self.tertiary, + extraRateWindows: extraRateWindows, + kiroUsage: self.kiroUsage, + providerCost: self.providerCost, + zaiUsage: self.zaiUsage, + minimaxUsage: self.minimaxUsage, + deepseekUsage: self.deepseekUsage, + openRouterUsage: self.openRouterUsage, + openAIAPIUsage: self.openAIAPIUsage, + claudeAdminAPIUsage: self.claudeAdminAPIUsage, + mistralUsage: self.mistralUsage, + deepgramUsage: self.deepgramUsage, + cursorRequests: self.cursorRequests, + updatedAt: self.updatedAt, + identity: self.identity) + } + public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.primary = try container.decodeIfPresent(RateWindow.self, forKey: .primary) diff --git a/Tests/CodexBarTests/UsageStoreCoverageTests.swift b/Tests/CodexBarTests/UsageStoreCoverageTests.swift index 3428b2d0d3..e8ea3d8076 100644 --- a/Tests/CodexBarTests/UsageStoreCoverageTests.swift +++ b/Tests/CodexBarTests/UsageStoreCoverageTests.swift @@ -127,6 +127,41 @@ struct UsageStoreCoverageTests { #expect(store.sourceLabel(for: .kilo) == "api") } + @Test + func `clearing copilot budget extras syncs reset baseline`() { + let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-copilot-budget-clear") + let store = Self.makeUsageStore(settings: settings) + let live = Self.makeCopilotSnapshot(usedPercent: 20, extraRateWindows: [Self.makeCopilotBudgetWindow()]) + let resetBaseline = Self.makeCopilotSnapshot(usedPercent: 10, extraRateWindows: nil) + store._setSnapshotForTesting(live, provider: .copilot) + store.lastKnownResetSnapshots[.copilot] = resetBaseline + + store.clearCopilotBudgetExtras() + + #expect(store.snapshot(for: .copilot)?.extraRateWindows == nil) + #expect(store.lastKnownResetSnapshots[.copilot]?.extraRateWindows == nil) + #expect(store.lastKnownResetSnapshots[.copilot]?.primary?.usedPercent == 20) + } + + @Test + func `clearing copilot budget extras also clears stale reset baseline`() { + let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-copilot-budget-reset-clear") + let store = Self.makeUsageStore(settings: settings) + let live = Self.makeCopilotSnapshot(usedPercent: 20, extraRateWindows: nil) + let resetBaseline = Self.makeCopilotSnapshot( + usedPercent: 10, + extraRateWindows: [Self.makeCopilotBudgetWindow()]) + store._setSnapshotForTesting(live, provider: .copilot) + store.lastKnownResetSnapshots[.copilot] = resetBaseline + + store.clearCopilotBudgetExtras() + + #expect(store.snapshot(for: .copilot)?.extraRateWindows == nil) + #expect(store.snapshot(for: .copilot)?.primary?.usedPercent == 20) + #expect(store.lastKnownResetSnapshots[.copilot]?.extraRateWindows == nil) + #expect(store.lastKnownResetSnapshots[.copilot]?.primary?.usedPercent == 10) + } + @Test func `permission prompt errors are detected for notifications`() { let errors: [LocalizedTestError] = [ @@ -707,6 +742,24 @@ struct UsageStoreCoverageTests { .replacingOccurrences(of: "/", with: "_") } + private static func makeCopilotSnapshot( + usedPercent: Double, + extraRateWindows: [NamedRateWindow]?) -> UsageSnapshot + { + UsageSnapshot( + primary: RateWindow(usedPercent: usedPercent, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + extraRateWindows: extraRateWindows, + updatedAt: Date(timeIntervalSince1970: 1_780_358_400)) + } + + private static func makeCopilotBudgetWindow() -> NamedRateWindow { + NamedRateWindow( + id: "copilot-budget-test", + title: "Budget - Copilot", + window: RateWindow(usedPercent: 50, windowMinutes: nil, resetsAt: nil, resetDescription: nil)) + } + private static func enableOnly(_ enabledProvider: UsageProvider, settings: SettingsStore) throws { let metadata = ProviderRegistry.shared.metadata for provider in UsageProvider.allCases { From 14984e4965e872274622079d87f9faace79828c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Mon, 1 Jun 2026 20:42:18 +0100 Subject: [PATCH 07/14] Prevent Copilot budget cookies from crossing accounts - Thread selected Copilot account identity into settings - Reject budget cookie sessions for a different GitHub account - Cover the matching and mismatched account flows in tests --- .../Copilot/CopilotSettingsStore.swift | 1 + .../Copilot/CopilotBudgetWebFetcher.swift | 133 +++++++++- .../Copilot/CopilotProviderDescriptor.swift | 20 +- .../Providers/ProviderSettingsSnapshot.swift | 3 + .../CopilotBudgetWebFetcherTests.swift | 249 ++++++++++++++++++ .../SettingsStoreCoverageTests.swift | 15 ++ 6 files changed, 415 insertions(+), 6 deletions(-) diff --git a/Sources/CodexBar/Providers/Copilot/CopilotSettingsStore.swift b/Sources/CodexBar/Providers/Copilot/CopilotSettingsStore.swift index 0425626d6f..4fdf677eef 100644 --- a/Sources/CodexBar/Providers/Copilot/CopilotSettingsStore.swift +++ b/Sources/CodexBar/Providers/Copilot/CopilotSettingsStore.swift @@ -82,6 +82,7 @@ extension SettingsStore { return ProviderSettingsSnapshot.CopilotProviderSettings( apiToken: self.normalizedConfigValue(token), enterpriseHost: host == CopilotDeviceFlow.defaultHost ? nil : host, + selectedAccountExternalIdentifier: account?.externalIdentifier.flatMap(self.normalizedConfigValue), budgetExtrasEnabled: self.copilotBudgetExtrasEnabled, budgetCookieSource: self.copilotBudgetCookieSource, manualBudgetCookieHeader: self.normalizedConfigValue(self.copilotBudgetCookieHeader)) diff --git a/Sources/CodexBarCore/Providers/Copilot/CopilotBudgetWebFetcher.swift b/Sources/CodexBarCore/Providers/Copilot/CopilotBudgetWebFetcher.swift index ee4a860949..00134e4470 100644 --- a/Sources/CodexBarCore/Providers/Copilot/CopilotBudgetWebFetcher.swift +++ b/Sources/CodexBarCore/Providers/Copilot/CopilotBudgetWebFetcher.swift @@ -10,6 +10,7 @@ public struct CopilotBudgetWebFetcher: Sendable { public enum Error: Swift.Error, LocalizedError, Equatable { case noSessionCookie case notLoggedIn + case accountMismatch(expected: String, actual: String?) case badStatus(Int) case invalidResponse @@ -19,6 +20,8 @@ public struct CopilotBudgetWebFetcher: Sendable { "No GitHub browser session cookie found." case .notLoggedIn: "GitHub browser session is not logged in." + case let .accountMismatch(expected, actual): + "GitHub browser session belongs to \(actual ?? "an unknown account"), expected \(expected)." case let .badStatus(status): "GitHub budgets request failed with HTTP \(status)." case .invalidResponse: @@ -229,6 +232,22 @@ public struct CopilotBudgetWebFetcher: Sendable { } } + public struct GitHubWebIdentity: Equatable, Sendable { + let id: String? + let login: String? + + init(id: String?, login: String?) { + let trimmedID = id?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let trimmedLogin = login?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + self.id = trimmedID.isEmpty ? nil : trimmedID + self.login = trimmedLogin.isEmpty ? nil : trimmedLogin + } + + var displayName: String? { + self.login ?? self.id.map { "github:user:\($0)" } + } + } + private struct ProductSKU: Decodable, Sendable, Equatable { let selectors: [String] @@ -307,17 +326,21 @@ public struct CopilotBudgetWebFetcher: Sendable { ] private let cookieHeaderOverride: String? + private let expectedGitHubAccountIdentifier: String? private let browserDetection: BrowserDetection private let transport: any ProviderHTTPTransport private let now: @Sendable () -> Date public init( cookieHeaderOverride: String? = nil, + expectedGitHubAccountIdentifier: String? = nil, browserDetection: BrowserDetection = BrowserDetection(), transport: any ProviderHTTPTransport = ProviderHTTPClient.shared, now: @escaping @Sendable () -> Date = { Date() }) { self.cookieHeaderOverride = CookieHeaderNormalizer.normalize(cookieHeaderOverride ?? "") + self.expectedGitHubAccountIdentifier = Self.normalizedExpectedAccountIdentifier( + expectedGitHubAccountIdentifier) self.browserDetection = browserDetection self.transport = transport self.now = now @@ -336,6 +359,8 @@ public struct CopilotBudgetWebFetcher: Sendable { } catch { if case Error.notLoggedIn = error { CookieHeaderCache.clear(provider: .copilot) + } else if case Error.accountMismatch = error { + CookieHeaderCache.clear(provider: .copilot) } else { throw error } @@ -355,6 +380,9 @@ public struct CopilotBudgetWebFetcher: Sendable { if case Error.notLoggedIn = error { continue } + if case Error.accountMismatch = error { + continue + } throw error } } @@ -364,7 +392,7 @@ public struct CopilotBudgetWebFetcher: Sendable { } func fetchBudgetWindows(cookieHeader: String) async throws -> [NamedRateWindow] { - let nonce = await self.bestEffortFetchNonce(cookieHeader: cookieHeader) + let nonce = try await self.boundFetchNonce(cookieHeader: cookieHeader) var allBudgets: [Budget] = [] var page = 1 let maxPages = 20 @@ -386,9 +414,18 @@ public struct CopilotBudgetWebFetcher: Sendable { return Self.extraRateWindows(from: allBudgets, now: self.now()) } + private func boundFetchNonce(cookieHeader: String) async throws -> String? { + guard self.expectedGitHubAccountIdentifier != nil else { + return await self.bestEffortFetchNonce(cookieHeader: cookieHeader) + } + let metadata = try await self.fetchBudgetPageMetadata(cookieHeader: cookieHeader) + try self.verifyExpectedGitHubAccount(metadata.identity) + return metadata.nonce + } + private func bestEffortFetchNonce(cookieHeader: String) async -> String? { do { - return try await self.fetchNonce(cookieHeader: cookieHeader) + return try await self.fetchBudgetPageMetadata(cookieHeader: cookieHeader).nonce } catch { // GitHub accepts some budget requests without a nonce. Keep auth failures and // page-shape changes non-fatal so a valid Cookie header can still be tried. @@ -396,7 +433,12 @@ public struct CopilotBudgetWebFetcher: Sendable { } } - private func fetchNonce(cookieHeader: String) async throws -> String? { + private struct BudgetPageMetadata: Sendable { + let nonce: String? + let identity: GitHubWebIdentity? + } + + private func fetchBudgetPageMetadata(cookieHeader: String) async throws -> BudgetPageMetadata { guard let url = URL(string: "https://github.com/settings/billing/budgets") else { throw URLError(.badURL) } @@ -409,8 +451,12 @@ public struct CopilotBudgetWebFetcher: Sendable { let response = try await self.transport.response(for: request) switch response.statusCode { case 200: - guard let html = String(data: response.data, encoding: .utf8) else { return nil } - return Self.extractFetchNonce(from: html) + guard let html = String(data: response.data, encoding: .utf8) else { + return BudgetPageMetadata(nonce: nil, identity: nil) + } + return BudgetPageMetadata( + nonce: Self.extractFetchNonce(from: html), + identity: Self.extractGitHubWebIdentity(from: html)) case 401, 403: throw Error.notLoggedIn default: @@ -418,6 +464,13 @@ public struct CopilotBudgetWebFetcher: Sendable { } } + private func verifyExpectedGitHubAccount(_ actual: GitHubWebIdentity?) throws { + guard let expected = self.expectedGitHubAccountIdentifier else { return } + guard Self.webIdentity(actual, matches: expected) else { + throw Error.accountMismatch(expected: expected, actual: actual?.displayName) + } + } + private func fetchBudgetPage(cookieHeader: String, nonce: String?, page: Int) async throws -> BudgetResponse { guard var components = URLComponents(string: "https://github.com/settings/billing/budgets") else { throw URLError(.badURL) @@ -475,6 +528,76 @@ public struct CopilotBudgetWebFetcher: Sendable { return nil } + static func extractGitHubWebIdentity(from html: String) -> GitHubWebIdentity? { + let id = self.extractMetaContent( + named: [ + "octolytics-actor-id", + "analytics-user-id", + "user-id", + ], + from: html) + let login = self.extractMetaContent( + named: [ + "user-login", + "octolytics-actor-login", + "analytics-user-login", + ], + from: html) + let identity = GitHubWebIdentity(id: id, login: login) + return identity.id == nil && identity.login == nil ? nil : identity + } + + private static func extractMetaContent(named names: [String], from html: String) -> String? { + for name in names { + let escapedName = NSRegularExpression.escapedPattern(for: name) + let patterns = [ + #"]*\bname=["']\#(escapedName)["'])(?=[^>]*\bcontent=["']([^"']+)["'])[^>]*>"#, + #"]*\bcontent=["']([^"']+)["'])(?=[^>]*\bname=["']\#(escapedName)["'])[^>]*>"#, + ] + for pattern in patterns { + guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else { + continue + } + let range = NSRange(html.startIndex.. Bool { + guard let expected = self.normalizedExpectedAccountIdentifier(expectedIdentifier), + let identity + else { return false } + if let expectedID = self.githubUserID(from: expected) { + return identity.id?.trimmingCharacters(in: .whitespacesAndNewlines) == expectedID + } + return identity.login?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == expected + } + + static func normalizedGitHubAccountIdentifier(for identity: CopilotUsageFetcher.GitHubUserIdentity) -> String { + "github:user:\(identity.id)" + } + + private static func normalizedExpectedAccountIdentifier(_ identifier: String?) -> String? { + let trimmed = identifier?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? nil : trimmed.lowercased() + } + + private static func githubUserID(from identifier: String) -> String? { + let prefix = "github:user:" + guard identifier.hasPrefix(prefix) else { return nil } + let suffix = String(identifier.dropFirst(prefix.count)) + .trimmingCharacters(in: .whitespacesAndNewlines) + return suffix.isEmpty ? nil : suffix + } + static func extraRateWindows(from budgets: [Budget], now: Date) -> [NamedRateWindow] { var usedIDs = Set() let resetDate = self.approximateNextMonthResetDate(now: now) diff --git a/Sources/CodexBarCore/Providers/Copilot/CopilotProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Copilot/CopilotProviderDescriptor.swift index dd0c160b36..07804250b5 100644 --- a/Sources/CodexBarCore/Providers/Copilot/CopilotProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Copilot/CopilotProviderDescriptor.swift @@ -56,7 +56,7 @@ struct CopilotAPIFetchStrategy: ProviderFetchStrategy { token: token, enterpriseHost: context.settings?.copilot?.enterpriseHost) let usage = try await fetcher.fetch() - let snap = await self.addBudgetWindowsIfNeeded(to: usage, context: context) + let snap = await self.addBudgetWindowsIfNeeded(to: usage, token: token, context: context) return self.makeResult( usage: snap, sourceLabel: "api") @@ -75,6 +75,7 @@ struct CopilotAPIFetchStrategy: ProviderFetchStrategy { private func addBudgetWindowsIfNeeded( to usage: UsageSnapshot, + token: String, context: ProviderFetchContext) async -> UsageSnapshot { guard let settings = context.settings?.copilot, @@ -87,8 +88,12 @@ struct CopilotAPIFetchStrategy: ProviderFetchStrategy { return usage } do { + let expectedAccountIdentifier = try await self.expectedBudgetAccountIdentifier( + token: token, + settings: settings) let extraRateWindows = try await CopilotBudgetWebFetcher( cookieHeaderOverride: manualCookieHeader, + expectedGitHubAccountIdentifier: expectedAccountIdentifier, browserDetection: context.browserDetection) .fetchBudgetWindows() guard !extraRateWindows.isEmpty else { return usage } @@ -108,4 +113,17 @@ struct CopilotAPIFetchStrategy: ProviderFetchStrategy { let cookieHeader = settings.manualBudgetCookieHeader?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" return cookieHeader.isEmpty ? nil : cookieHeader } + + private func expectedBudgetAccountIdentifier( + token: String, + settings: ProviderSettingsSnapshot.CopilotProviderSettings) async throws -> String + { + if let identifier = settings.selectedAccountExternalIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines), + !identifier.isEmpty + { + return identifier + } + let identity = try await CopilotUsageFetcher.fetchGitHubIdentity(token: token) + return CopilotBudgetWebFetcher.normalizedGitHubAccountIdentifier(for: identity) + } } diff --git a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift index 126ab18a2c..2ed15330b5 100644 --- a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift +++ b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift @@ -206,6 +206,7 @@ public struct ProviderSettingsSnapshot: Sendable { public struct CopilotProviderSettings: Sendable { public let apiToken: String? public let enterpriseHost: String? + public let selectedAccountExternalIdentifier: String? public let budgetExtrasEnabled: Bool public let budgetCookieSource: ProviderCookieSource public let manualBudgetCookieHeader: String? @@ -213,12 +214,14 @@ public struct ProviderSettingsSnapshot: Sendable { public init( apiToken: String? = nil, enterpriseHost: String? = nil, + selectedAccountExternalIdentifier: String? = nil, budgetExtrasEnabled: Bool = false, budgetCookieSource: ProviderCookieSource = .auto, manualBudgetCookieHeader: String? = nil) { self.apiToken = apiToken self.enterpriseHost = enterpriseHost + self.selectedAccountExternalIdentifier = selectedAccountExternalIdentifier self.budgetExtrasEnabled = budgetExtrasEnabled self.budgetCookieSource = budgetCookieSource self.manualBudgetCookieHeader = manualBudgetCookieHeader diff --git a/Tests/CodexBarTests/CopilotBudgetWebFetcherTests.swift b/Tests/CodexBarTests/CopilotBudgetWebFetcherTests.swift index 9ae3fbda10..7c534ee358 100644 --- a/Tests/CodexBarTests/CopilotBudgetWebFetcherTests.swift +++ b/Tests/CodexBarTests/CopilotBudgetWebFetcherTests.swift @@ -131,6 +131,176 @@ struct CopilotBudgetWebFetcherTests { #expect(CopilotBudgetWebFetcher.extractFetchNonce(from: html) == "v2:abc-123") } + @Test + func `extracts github web identity from html`() throws { + let html = """ + + + """ + + let identity = try #require(CopilotBudgetWebFetcher.extractGitHubWebIdentity(from: html)) + + #expect(identity.id == "123") + #expect(identity.login == "octocat") + #expect(CopilotBudgetWebFetcher.webIdentity(identity, matches: "github:user:123")) + #expect(CopilotBudgetWebFetcher.webIdentity(identity, matches: "OctoCat")) + #expect(!CopilotBudgetWebFetcher.webIdentity(identity, matches: "github:user:456")) + } + + @Test + func `manual budget cookie for different github account is ignored before budget request`() async throws { + let transport = ProviderHTTPTransportStub { request in + guard let url = request.url, + let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: nil) + else { + throw URLError(.badServerResponse) + } + if request.url?.query?.contains("page=") == true { + Issue.record("Mismatched cookie should not reach budget JSON endpoint") + return (Data(#"{"budgets":[],"has_next_page":false}"#.utf8), response) + } + return ( + Data(""" + + + + """.utf8), + response) + } + let fetcher = CopilotBudgetWebFetcher( + cookieHeaderOverride: "user_session=other", + expectedGitHubAccountIdentifier: "github:user:123", + transport: transport) + + do { + _ = try await fetcher.fetchBudgetWindows() + Issue.record("Expected account mismatch") + } catch let error as CopilotBudgetWebFetcher.Error { + #expect(error == .accountMismatch(expected: "github:user:123", actual: "otheruser")) + } + + #expect(await transport.requests().count == 1) + } + + @Test + func `manual budget cookie with matching github account appends budget windows`() async throws { + let transport = ProviderHTTPTransportStub { request in + guard let url = request.url, + let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: nil) + else { + throw URLError(.badServerResponse) + } + if request.url?.query?.contains("page=") == true { + return ( + Data(""" + { + "budgets": [ + { + "uuid": "budget-1", + "pricingTargetId": "premium_requests", + "targetAmount": 100.0, + "currentAmount": 40.0 + } + ], + "has_next_page": false + } + """.utf8), + response) + } + return ( + Data(""" + + + + """.utf8), + response) + } + let fetcher = CopilotBudgetWebFetcher( + cookieHeaderOverride: "user_session=matching", + expectedGitHubAccountIdentifier: "github:user:123", + transport: transport, + now: { Date(timeIntervalSince1970: 1_780_358_400) }) + + let windows = try await fetcher.fetchBudgetWindows() + + #expect(windows.map(\.id) == ["copilot-budget-budget-1"]) + #expect(windows.first?.window.usedPercent == 40) + #expect(await transport.requests().count == 2) + } + + @Test + func `mismatched manual budget cookie leaves normal copilot usage unchanged`() async { + let registered = URLProtocol.registerClass(CopilotBudgetBindingStubURLProtocol.self) + defer { + if registered { + URLProtocol.unregisterClass(CopilotBudgetBindingStubURLProtocol.self) + } + CopilotBudgetBindingStubURLProtocol.reset() + } + CopilotBudgetBindingStubURLProtocol.reset() + CopilotBudgetBindingStubURLProtocol.handler = { request in + guard let url = request.url else { + throw URLError(.badURL) + } + if url.host == "api.github.com", url.path == "/copilot_internal/user" { + return Self.stubResponse( + url: url, + data: Data(""" + { + "quota_snapshots": { + "premium_interactions": { + "entitlement": 300, + "remaining": 240, + "percent_remaining": 80, + "quota_id": "premium" + } + }, + "copilot_plan": "pro" + } + """.utf8)) + } + if url.host == "github.com", url.path == "/settings/billing/budgets", url.query == nil { + return Self.stubResponse( + url: url, + data: Data(""" + + + + """.utf8)) + } + Issue.record("Unexpected request: \(url.absoluteString)") + return Self.stubResponse(url: url, data: Data("{}".utf8), statusCode: 404) + } + let descriptor = ProviderDescriptorRegistry.descriptor(for: .copilot) + let settings = ProviderSettingsSnapshot.make(copilot: .init( + apiToken: "selected-token", + selectedAccountExternalIdentifier: "github:user:123", + budgetExtrasEnabled: true, + budgetCookieSource: .manual, + manualBudgetCookieHeader: "user_session=other")) + let context = Self.makeFetchContext(settings: settings) + + let outcome = await descriptor.fetchPlan.fetchOutcome(context: context, provider: .copilot) + + guard case let .success(result) = outcome.result else { + Issue.record("Expected Copilot usage fetch to succeed") + return + } + #expect(result.usage.primary?.usedPercent == 20) + #expect(result.usage.extraRateWindows == nil) + #expect(CopilotBudgetBindingStubURLProtocol.requests().contains { + $0.url?.query?.contains("page=") == true + } == false) + } + @Test func `invalid github budget JSON maps to invalid response`() async throws { let transport = ProviderHTTPTransportStub { request in @@ -216,4 +386,83 @@ struct CopilotBudgetWebFetcherTests { let pageRequest = try #require(await transport.requests().first { $0.url?.query?.contains("page=") == true }) #expect(pageRequest.value(forHTTPHeaderField: "Content-Type") == nil) } + + private static func makeFetchContext(settings: ProviderSettingsSnapshot) -> ProviderFetchContext { + let browserDetection = BrowserDetection(cacheTTL: 0) + return ProviderFetchContext( + runtime: .app, + sourceMode: .api, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: [:], + settings: settings, + fetcher: UsageFetcher(environment: [:]), + claudeFetcher: ClaudeUsageFetcher(browserDetection: browserDetection), + browserDetection: browserDetection) + } + + private static func stubResponse( + url: URL, + data: Data, + statusCode: Int = 200) -> (Data, URLResponse) + { + let response = HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"])! + return (data, response) + } +} + +final class CopilotBudgetBindingStubURLProtocol: URLProtocol { + private static let lock = NSLock() + nonisolated(unsafe) static var handler: (@Sendable (URLRequest) throws -> (Data, URLResponse))? + private nonisolated(unsafe) static var recordedRequests: [URLRequest] = [] + + static func reset() { + self.lock.lock() + defer { self.lock.unlock() } + self.handler = nil + self.recordedRequests = [] + } + + static func requests() -> [URLRequest] { + self.lock.lock() + defer { self.lock.unlock() } + return self.recordedRequests + } + + override static func canInit(with request: URLRequest) -> Bool { + request.url?.host == "api.github.com" || request.url?.host == "github.com" + } + + override static func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + Self.lock.lock() + Self.recordedRequests.append(self.request) + let handler = Self.handler + Self.lock.unlock() + + guard let handler else { + self.client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse)) + return + } + + do { + let (data, response) = try handler(self.request) + self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + self.client?.urlProtocol(self, didLoad: data) + self.client?.urlProtocolDidFinishLoading(self) + } catch { + self.client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} } diff --git a/Tests/CodexBarTests/SettingsStoreCoverageTests.swift b/Tests/CodexBarTests/SettingsStoreCoverageTests.swift index 2bc96c7352..2e860c534f 100644 --- a/Tests/CodexBarTests/SettingsStoreCoverageTests.swift +++ b/Tests/CodexBarTests/SettingsStoreCoverageTests.swift @@ -213,6 +213,21 @@ struct SettingsStoreCoverageTests { #expect(settings.copilotSettingsSnapshot(tokenOverride: nil).apiToken == nil) } + @Test + func `copilot settings snapshot carries selected account identifier`() { + let settings = Self.makeSettingsStore() + settings.addTokenAccount( + provider: .copilot, + label: "octocat (Pro)", + token: "token-1", + externalIdentifier: "github:user:123") + + let snapshot = settings.copilotSettingsSnapshot(tokenOverride: nil) + + #expect(snapshot.apiToken == "token-1") + #expect(snapshot.selectedAccountExternalIdentifier == "github:user:123") + } + @Test func `copilot enterprise host persists in provider config`() throws { let suite = "SettingsStoreCoverageTests-copilot-enterprise-host" From 9cd71fa139374634d9d43ad020db97f0c74eeed5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Mon, 1 Jun 2026 21:00:34 +0100 Subject: [PATCH 08/14] Bind Copilot budgets to token identity - Resolve GitHub identity from the active token before cookie checks - Cover stale selected account identifiers in budget tests --- .../Copilot/CopilotProviderDescriptor.swift | 18 +++-- .../CopilotBudgetWebFetcherTests.swift | 78 +++++++++++++++++++ 2 files changed, 91 insertions(+), 5 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Copilot/CopilotProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Copilot/CopilotProviderDescriptor.swift index 07804250b5..51dab4a2ab 100644 --- a/Sources/CodexBarCore/Providers/Copilot/CopilotProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Copilot/CopilotProviderDescriptor.swift @@ -118,12 +118,20 @@ struct CopilotAPIFetchStrategy: ProviderFetchStrategy { token: String, settings: ProviderSettingsSnapshot.CopilotProviderSettings) async throws -> String { - if let identifier = settings.selectedAccountExternalIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines), - !identifier.isEmpty + let identity = try await CopilotUsageFetcher.fetchGitHubIdentity(token: token) + let tokenIdentifier = CopilotBudgetWebFetcher.normalizedGitHubAccountIdentifier(for: identity) + if let selectedIdentifier = Self.normalizedBudgetAccountIdentifier(settings.selectedAccountExternalIdentifier), + selectedIdentifier != tokenIdentifier.lowercased(), + selectedIdentifier != identity.login.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { - return identifier + CodexBarLog.logger(LogCategories.providers).warning( + "Ignoring stale Copilot account identifier") } - let identity = try await CopilotUsageFetcher.fetchGitHubIdentity(token: token) - return CopilotBudgetWebFetcher.normalizedGitHubAccountIdentifier(for: identity) + return tokenIdentifier + } + + private static func normalizedBudgetAccountIdentifier(_ identifier: String?) -> String? { + let trimmed = identifier?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? nil : trimmed.lowercased() } } diff --git a/Tests/CodexBarTests/CopilotBudgetWebFetcherTests.swift b/Tests/CodexBarTests/CopilotBudgetWebFetcherTests.swift index 7c534ee358..f1247d578e 100644 --- a/Tests/CodexBarTests/CopilotBudgetWebFetcherTests.swift +++ b/Tests/CodexBarTests/CopilotBudgetWebFetcherTests.swift @@ -267,6 +267,11 @@ struct CopilotBudgetWebFetcherTests { } """.utf8)) } + if url.host == "api.github.com", url.path == "/user" { + return Self.stubResponse( + url: url, + data: Data(#"{"id":123,"login":"expecteduser"}"#.utf8)) + } if url.host == "github.com", url.path == "/settings/billing/budgets", url.query == nil { return Self.stubResponse( url: url, @@ -301,6 +306,79 @@ struct CopilotBudgetWebFetcherTests { } == false) } + @Test + func `stale selected account identifier is ignored for budget cookie binding`() async { + let registered = URLProtocol.registerClass(CopilotBudgetBindingStubURLProtocol.self) + defer { + if registered { + URLProtocol.unregisterClass(CopilotBudgetBindingStubURLProtocol.self) + } + CopilotBudgetBindingStubURLProtocol.reset() + } + CopilotBudgetBindingStubURLProtocol.reset() + CopilotBudgetBindingStubURLProtocol.handler = { request in + guard let url = request.url else { + throw URLError(.badURL) + } + if url.host == "api.github.com", url.path == "/copilot_internal/user" { + return Self.stubResponse( + url: url, + data: Data(""" + { + "quota_snapshots": { + "premium_interactions": { + "entitlement": 300, + "remaining": 240, + "percent_remaining": 80, + "quota_id": "premium" + } + }, + "copilot_plan": "pro" + } + """.utf8)) + } + if url.host == "api.github.com", url.path == "/user" { + return Self.stubResponse( + url: url, + data: Data(#"{"id":999,"login":"newuser"}"#.utf8)) + } + if url.host == "github.com", url.path == "/settings/billing/budgets", url.query == nil { + return Self.stubResponse( + url: url, + data: Data(""" + + + + """.utf8)) + } + Issue.record("Unexpected request: \(url.absoluteString)") + return Self.stubResponse(url: url, data: Data("{}".utf8), statusCode: 404) + } + let descriptor = ProviderDescriptorRegistry.descriptor(for: .copilot) + let settings = ProviderSettingsSnapshot.make(copilot: .init( + apiToken: "new-selected-token", + selectedAccountExternalIdentifier: "github:user:123", + budgetExtrasEnabled: true, + budgetCookieSource: .manual, + manualBudgetCookieHeader: "user_session=old-browser-account")) + let context = Self.makeFetchContext(settings: settings) + + let outcome = await descriptor.fetchPlan.fetchOutcome(context: context, provider: .copilot) + + guard case let .success(result) = outcome.result else { + Issue.record("Expected Copilot usage fetch to succeed") + return + } + #expect(result.usage.primary?.usedPercent == 20) + #expect(result.usage.extraRateWindows == nil) + #expect(CopilotBudgetBindingStubURLProtocol.requests().contains { + $0.url?.path == "/user" + }) + #expect(CopilotBudgetBindingStubURLProtocol.requests().contains { + $0.url?.query?.contains("page=") == true + } == false) + } + @Test func `invalid github budget JSON maps to invalid response`() async throws { let transport = ProviderHTTPTransportStub { request in From d66f00bda18a60668d1feae6f11425ed619241d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Mon, 1 Jun 2026 21:09:55 +0100 Subject: [PATCH 09/14] Harden Copilot budget identity parsing - Treat missing budget identity metadata as invalid response - Reuse compiled meta parsing regexes and tighten test stubs --- .../Copilot/CopilotBudgetWebFetcher.swift | 65 ++++++++------ .../CopilotBudgetWebFetcherTests.swift | 87 ++++++++++++++++++- 2 files changed, 124 insertions(+), 28 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Copilot/CopilotBudgetWebFetcher.swift b/Sources/CodexBarCore/Providers/Copilot/CopilotBudgetWebFetcher.swift index 00134e4470..ceb9febdd8 100644 --- a/Sources/CodexBarCore/Providers/Copilot/CopilotBudgetWebFetcher.swift +++ b/Sources/CodexBarCore/Providers/Copilot/CopilotBudgetWebFetcher.swift @@ -232,7 +232,7 @@ public struct CopilotBudgetWebFetcher: Sendable { } } - public struct GitHubWebIdentity: Equatable, Sendable { + struct GitHubWebIdentity: Equatable, Sendable { let id: String? let login: String? @@ -359,8 +359,6 @@ public struct CopilotBudgetWebFetcher: Sendable { } catch { if case Error.notLoggedIn = error { CookieHeaderCache.clear(provider: .copilot) - } else if case Error.accountMismatch = error { - CookieHeaderCache.clear(provider: .copilot) } else { throw error } @@ -451,9 +449,7 @@ public struct CopilotBudgetWebFetcher: Sendable { let response = try await self.transport.response(for: request) switch response.statusCode { case 200: - guard let html = String(data: response.data, encoding: .utf8) else { - return BudgetPageMetadata(nonce: nil, identity: nil) - } + guard let html = String(data: response.data, encoding: .utf8) else { throw Error.invalidResponse } return BudgetPageMetadata( nonce: Self.extractFetchNonce(from: html), identity: Self.extractGitHubWebIdentity(from: html)) @@ -466,8 +462,9 @@ public struct CopilotBudgetWebFetcher: Sendable { private func verifyExpectedGitHubAccount(_ actual: GitHubWebIdentity?) throws { guard let expected = self.expectedGitHubAccountIdentifier else { return } + guard let actual else { throw Error.invalidResponse } guard Self.webIdentity(actual, matches: expected) else { - throw Error.accountMismatch(expected: expected, actual: actual?.displayName) + throw Error.accountMismatch(expected: expected, actual: actual.displayName) } } @@ -547,25 +544,41 @@ public struct CopilotBudgetWebFetcher: Sendable { return identity.id == nil && identity.login == nil ? nil : identity } + private static let metaTagRegex = try? NSRegularExpression( + pattern: #"]*>"#, + options: [.caseInsensitive]) + + private static let metaAttributeRegex = try? NSRegularExpression( + pattern: #"([A-Za-z_:][-A-Za-z0-9_:.]*)\s*=\s*(['"])(.*?)\2"#, + options: [.caseInsensitive]) + private static func extractMetaContent(named names: [String], from html: String) -> String? { - for name in names { - let escapedName = NSRegularExpression.escapedPattern(for: name) - let patterns = [ - #"]*\bname=["']\#(escapedName)["'])(?=[^>]*\bcontent=["']([^"']+)["'])[^>]*>"#, - #"]*\bcontent=["']([^"']+)["'])(?=[^>]*\bname=["']\#(escapedName)["'])[^>]*>"#, - ] - for pattern in patterns { - guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else { - continue - } - let range = NSRange(html.startIndex.. String { diff --git a/Tests/CodexBarTests/CopilotBudgetWebFetcherTests.swift b/Tests/CodexBarTests/CopilotBudgetWebFetcherTests.swift index f1247d578e..20d48bab05 100644 --- a/Tests/CodexBarTests/CopilotBudgetWebFetcherTests.swift +++ b/Tests/CodexBarTests/CopilotBudgetWebFetcherTests.swift @@ -135,7 +135,7 @@ struct CopilotBudgetWebFetcherTests { func `extracts github web identity from html`() throws { let html = """ - + """ let identity = try #require(CopilotBudgetWebFetcher.extractGitHubWebIdentity(from: html)) @@ -147,6 +147,72 @@ struct CopilotBudgetWebFetcherTests { #expect(!CopilotBudgetWebFetcher.webIdentity(identity, matches: "github:user:456")) } + @Test + func `missing github web identity with expected account maps to invalid response`() async throws { + let transport = ProviderHTTPTransportStub { request in + guard let url = request.url, + let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: nil) + else { + throw URLError(.badServerResponse) + } + if request.url?.query?.contains("page=") == true { + Issue.record("Missing identity should not reach budget JSON endpoint") + return (Data(#"{"budgets":[],"has_next_page":false}"#.utf8), response) + } + return (Data(#""#.utf8), response) + } + let fetcher = CopilotBudgetWebFetcher( + cookieHeaderOverride: "user_session=missing-identity", + expectedGitHubAccountIdentifier: "github:user:123", + transport: transport) + + do { + _ = try await fetcher.fetchBudgetWindows() + Issue.record("Expected invalid response") + } catch let error as CopilotBudgetWebFetcher.Error { + #expect(error == .invalidResponse) + } + + #expect(await transport.requests().count == 1) + } + + @Test + func `invalid github budget page html encoding maps to invalid response`() async throws { + let transport = ProviderHTTPTransportStub { request in + guard let url = request.url, + let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: nil) + else { + throw URLError(.badServerResponse) + } + if request.url?.query?.contains("page=") == true { + Issue.record("Invalid HTML encoding should not reach budget JSON endpoint") + return (Data(#"{"budgets":[],"has_next_page":false}"#.utf8), response) + } + return (Data([0xC3, 0x28]), response) + } + let fetcher = CopilotBudgetWebFetcher( + cookieHeaderOverride: "user_session=invalid-html", + expectedGitHubAccountIdentifier: "github:user:123", + transport: transport) + + do { + _ = try await fetcher.fetchBudgetWindows() + Issue.record("Expected invalid response") + } catch let error as CopilotBudgetWebFetcher.Error { + #expect(error == .invalidResponse) + } + + #expect(await transport.requests().count == 1) + } + @Test func `manual budget cookie for different github account is ignored before budget request`() async throws { let transport = ProviderHTTPTransportStub { request in @@ -514,7 +580,16 @@ final class CopilotBudgetBindingStubURLProtocol: URLProtocol { } override static func canInit(with request: URLRequest) -> Bool { - request.url?.host == "api.github.com" || request.url?.host == "github.com" + guard self.hasHandler else { return false } + guard request.url?.scheme == "https" else { return false } + switch (request.url?.host, request.url?.path) { + case ("api.github.com", "/copilot_internal/user"), + ("api.github.com", "/user"), + ("github.com", "/settings/billing/budgets"): + return true + default: + return false + } } override static func canonicalRequest(for request: URLRequest) -> URLRequest { @@ -544,3 +619,11 @@ final class CopilotBudgetBindingStubURLProtocol: URLProtocol { override func stopLoading() {} } + +extension CopilotBudgetBindingStubURLProtocol { + fileprivate static var hasHandler: Bool { + self.lock.lock() + defer { self.lock.unlock() } + return self.handler != nil + } +} From 0f8e1e06f384b004098973f21e9288d89bd1a237 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Mon, 1 Jun 2026 21:29:20 +0100 Subject: [PATCH 10/14] Fix Copilot budget cookie fallback - Clear mismatched cached budget cookies before browser fallback - Treat missing GitHub identity as an unknown account mismatch - Cover cached mismatch and missing-identity fallback behavior --- .../Copilot/CopilotBudgetWebFetcher.swift | 4 +- .../CopilotBudgetWebFetcherTests.swift | 98 ++++++++++++++++++- 2 files changed, 98 insertions(+), 4 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Copilot/CopilotBudgetWebFetcher.swift b/Sources/CodexBarCore/Providers/Copilot/CopilotBudgetWebFetcher.swift index ceb9febdd8..1041659275 100644 --- a/Sources/CodexBarCore/Providers/Copilot/CopilotBudgetWebFetcher.swift +++ b/Sources/CodexBarCore/Providers/Copilot/CopilotBudgetWebFetcher.swift @@ -359,6 +359,8 @@ public struct CopilotBudgetWebFetcher: Sendable { } catch { if case Error.notLoggedIn = error { CookieHeaderCache.clear(provider: .copilot) + } else if case Error.accountMismatch = error { + CookieHeaderCache.clear(provider: .copilot) } else { throw error } @@ -462,7 +464,7 @@ public struct CopilotBudgetWebFetcher: Sendable { private func verifyExpectedGitHubAccount(_ actual: GitHubWebIdentity?) throws { guard let expected = self.expectedGitHubAccountIdentifier else { return } - guard let actual else { throw Error.invalidResponse } + guard let actual else { throw Error.accountMismatch(expected: expected, actual: nil) } guard Self.webIdentity(actual, matches: expected) else { throw Error.accountMismatch(expected: expected, actual: actual.displayName) } diff --git a/Tests/CodexBarTests/CopilotBudgetWebFetcherTests.swift b/Tests/CodexBarTests/CopilotBudgetWebFetcherTests.swift index 20d48bab05..2bdba637a1 100644 --- a/Tests/CodexBarTests/CopilotBudgetWebFetcherTests.swift +++ b/Tests/CodexBarTests/CopilotBudgetWebFetcherTests.swift @@ -148,7 +148,7 @@ struct CopilotBudgetWebFetcherTests { } @Test - func `missing github web identity with expected account maps to invalid response`() async throws { + func `missing github web identity with expected account maps to unknown account mismatch`() async throws { let transport = ProviderHTTPTransportStub { request in guard let url = request.url, let response = HTTPURLResponse( @@ -172,9 +172,9 @@ struct CopilotBudgetWebFetcherTests { do { _ = try await fetcher.fetchBudgetWindows() - Issue.record("Expected invalid response") + Issue.record("Expected account mismatch") } catch let error as CopilotBudgetWebFetcher.Error { - #expect(error == .invalidResponse) + #expect(error == .accountMismatch(expected: "github:user:123", actual: nil)) } #expect(await transport.requests().count == 1) @@ -506,6 +506,98 @@ struct CopilotBudgetWebFetcherTests { #expect(CookieHeaderCache.load(provider: .copilot)?.cookieHeader == "user_session=cached") } + @Test + func `cached cookie account mismatch clears cache before browser fallback`() async throws { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + CookieHeaderCache.store(provider: .copilot, cookieHeader: "user_session=cached", sourceLabel: "Chrome") + defer { CookieHeaderCache.clear(provider: .copilot) } + + let temp = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: temp, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: temp) } + + let transport = ProviderHTTPTransportStub { request in + guard let url = request.url, + let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: nil) + else { + throw URLError(.badServerResponse) + } + if request.url?.query?.contains("page=") == true { + Issue.record("Mismatched cached cookie should not reach budget JSON endpoint") + return (Data(#"{"budgets":[],"has_next_page":false}"#.utf8), response) + } + return ( + Data(""" + + + + """.utf8), + response) + } + let fetcher = CopilotBudgetWebFetcher( + expectedGitHubAccountIdentifier: "github:user:123", + browserDetection: BrowserDetection(homeDirectory: temp.path, cacheTTL: 0), + transport: transport) + + do { + _ = try await fetcher.fetchBudgetWindows() + Issue.record("Expected browser fallback to exhaust without a session") + } catch let error as CopilotBudgetWebFetcher.Error { + #expect(error == .noSessionCookie) + } + + #expect(await transport.requests().count == 1) + #expect(CookieHeaderCache.load(provider: .copilot) == nil) + } + + @Test + func `cached cookie missing identity clears cache before browser fallback`() async throws { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + CookieHeaderCache.store(provider: .copilot, cookieHeader: "user_session=cached", sourceLabel: "Chrome") + defer { CookieHeaderCache.clear(provider: .copilot) } + + let temp = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: temp, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: temp) } + + let transport = ProviderHTTPTransportStub { request in + guard let url = request.url, + let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: nil) + else { + throw URLError(.badServerResponse) + } + if request.url?.query?.contains("page=") == true { + Issue.record("Unverifiable cached cookie should not reach budget JSON endpoint") + return (Data(#"{"budgets":[],"has_next_page":false}"#.utf8), response) + } + return (Data(#""#.utf8), response) + } + let fetcher = CopilotBudgetWebFetcher( + expectedGitHubAccountIdentifier: "github:user:123", + browserDetection: BrowserDetection(homeDirectory: temp.path, cacheTTL: 0), + transport: transport) + + do { + _ = try await fetcher.fetchBudgetWindows() + Issue.record("Expected browser fallback to exhaust without a session") + } catch let error as CopilotBudgetWebFetcher.Error { + #expect(error == .noSessionCookie) + } + + #expect(await transport.requests().count == 1) + #expect(CookieHeaderCache.load(provider: .copilot) == nil) + } + @Test func `budget page request omits content type on get`() async throws { let transport = ProviderHTTPTransportStub { request in From 2d68ecc514160dbd481fe3b6a822954e088740df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Mon, 8 Jun 2026 11:26:26 +0100 Subject: [PATCH 11/14] Move Copilot menu card test --- .../CopilotMenuCardModelTests.swift | 44 +++++++++++++++++++ Tests/CodexBarTests/MenuCardModelTests.swift | 37 ---------------- 2 files changed, 44 insertions(+), 37 deletions(-) create mode 100644 Tests/CodexBarTests/CopilotMenuCardModelTests.swift diff --git a/Tests/CodexBarTests/CopilotMenuCardModelTests.swift b/Tests/CodexBarTests/CopilotMenuCardModelTests.swift new file mode 100644 index 0000000000..5c1a238928 --- /dev/null +++ b/Tests/CodexBarTests/CopilotMenuCardModelTests.swift @@ -0,0 +1,44 @@ +import CodexBarCore +import Foundation +import SwiftUI +import Testing +@testable import CodexBar + +struct CopilotMenuCardModelTests { + @Test + func `hides copilot budget bars when budget extras are disabled`() throws { + let now = Date() + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 20, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 30, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + extraRateWindows: [ + NamedRateWindow( + id: "copilot-budget-agent", + title: "Budget - Copilot Agent Premium Requests", + window: RateWindow(usedPercent: 65, windowMinutes: nil, resetsAt: nil, resetDescription: nil)), + ], + updatedAt: now) + let metadata = try #require(ProviderDefaults.metadata[.copilot]) + let model = UsageMenuCardView.Model.make(.init( + provider: .copilot, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.metrics.map(\.title) == ["Premium", "Chat"]) + } +} diff --git a/Tests/CodexBarTests/MenuCardModelTests.swift b/Tests/CodexBarTests/MenuCardModelTests.swift index fd50b4c491..5842663519 100644 --- a/Tests/CodexBarTests/MenuCardModelTests.swift +++ b/Tests/CodexBarTests/MenuCardModelTests.swift @@ -961,43 +961,6 @@ struct MenuCardModelTests { #expect(model.metrics.map(\.title) == ["Session", "Weekly", "Sonnet", "Daily Routines"]) } - @Test - func `hides copilot budget bars when budget extras are disabled`() throws { - let now = Date() - let snapshot = UsageSnapshot( - primary: RateWindow(usedPercent: 20, windowMinutes: nil, resetsAt: nil, resetDescription: nil), - secondary: RateWindow(usedPercent: 30, windowMinutes: nil, resetsAt: nil, resetDescription: nil), - extraRateWindows: [ - NamedRateWindow( - id: "copilot-budget-agent", - title: "Budget - Copilot Agent Premium Requests", - window: RateWindow(usedPercent: 65, windowMinutes: nil, resetsAt: nil, resetDescription: nil)), - ], - updatedAt: now) - let metadata = try #require(ProviderDefaults.metadata[.copilot]) - let model = UsageMenuCardView.Model.make(.init( - provider: .copilot, - metadata: metadata, - snapshot: snapshot, - credits: nil, - creditsError: nil, - dashboard: nil, - dashboardError: nil, - tokenSnapshot: nil, - tokenError: nil, - account: AccountInfo(email: nil, plan: nil), - isRefreshing: false, - lastError: nil, - usageBarsShowUsed: false, - resetTimeDisplayStyle: .countdown, - tokenCostUsageEnabled: false, - showOptionalCreditsAndExtraUsage: true, - hidePersonalInfo: false, - now: now)) - - #expect(model.metrics.map(\.title) == ["Premium", "Chat"]) - } - @Test func `shows error subtitle when present`() throws { let metadata = try #require(ProviderDefaults.metadata[.codex]) From d4c67d7955aba0d186fe67157cf9b9ffdbb333f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Thu, 11 Jun 2026 10:42:20 +0100 Subject: [PATCH 12/14] Fix settings defaults lint after rebase --- Sources/CodexBar/SettingsStore.swift | 30 +++++++++++++++------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index d4d85eaa14..20a004bb50 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -327,20 +327,7 @@ extension SettingsStore { } let launchAtLogin = userDefaults.object(forKey: "launchAtLogin") as? Bool ?? false let debugMenuEnabled = userDefaults.object(forKey: "debugMenuEnabled") as? Bool ?? false - let debugDisableKeychainAccess: Bool = { - if let stored = userDefaults.object(forKey: "debugDisableKeychainAccess") as? Bool { - return stored - } - if Self.shouldBridgeSharedDefaults(for: userDefaults), - let shared = Self.sharedDefaults?.object(forKey: "debugDisableKeychainAccess") as? Bool - { - if Self.isRunningTests { - userDefaults.set(shared, forKey: "debugDisableKeychainAccess") - } - return shared - } - return false - }() + let debugDisableKeychainAccess = Self.loadDebugDisableKeychainAccess(userDefaults: userDefaults) let debugFileLoggingEnabled = userDefaults.object(forKey: "debugFileLoggingEnabled") as? Bool ?? false let debugLogLevelRaw = userDefaults.string(forKey: "debugLogLevel") ?? CodexBarLog.Level.verbose.rawValue if Self.isRunningTests, userDefaults.string(forKey: "debugLogLevel") == nil { @@ -495,6 +482,21 @@ extension SettingsStore { userDefaults.string(forKey: "copilotIconSecondaryWindowID") ?? CopilotIconSecondaryWindowSelection.chat } + private static func loadDebugDisableKeychainAccess(userDefaults: UserDefaults) -> Bool { + if let stored = userDefaults.object(forKey: "debugDisableKeychainAccess") as? Bool { + return stored + } + if Self.shouldBridgeSharedDefaults(for: userDefaults), + let shared = Self.sharedDefaults?.object(forKey: "debugDisableKeychainAccess") as? Bool + { + if Self.isRunningTests { + userDefaults.set(shared, forKey: "debugDisableKeychainAccess") + } + return shared + } + return false + } + private struct LoadedQuotaWarningDefaults { var notificationsEnabled: Bool var thresholdsRaw: [Int] From 29e678ddb497315a3da776a117ed9a54df04f97a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 11 Jun 2026 12:47:36 +0100 Subject: [PATCH 13/14] fix: preserve subscription metadata in snapshot copies --- Sources/CodexBarCore/UsageFetcher.swift | 2 ++ Tests/CodexBarTests/CodexbarTests.swift | 17 +++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/Sources/CodexBarCore/UsageFetcher.swift b/Sources/CodexBarCore/UsageFetcher.swift index 6dcd7ed741..2ff3f53993 100644 --- a/Sources/CodexBarCore/UsageFetcher.swift +++ b/Sources/CodexBarCore/UsageFetcher.swift @@ -185,6 +185,8 @@ public struct UsageSnapshot: Codable, Sendable { mistralUsage: self.mistralUsage, deepgramUsage: self.deepgramUsage, cursorRequests: self.cursorRequests, + subscriptionExpiresAt: self.subscriptionExpiresAt, + subscriptionRenewsAt: self.subscriptionRenewsAt, updatedAt: self.updatedAt, identity: self.identity) } diff --git a/Tests/CodexBarTests/CodexbarTests.swift b/Tests/CodexBarTests/CodexbarTests.swift index 7573731ac2..7ea5cf4963 100644 --- a/Tests/CodexBarTests/CodexbarTests.swift +++ b/Tests/CodexBarTests/CodexbarTests.swift @@ -185,6 +185,23 @@ struct CodexBarTests { #expect(remaining.secondary == 35) } + @Test + func `copying extra rate windows preserves subscription dates`() { + let expiresAt = Date(timeIntervalSince1970: 1_810_656_000) + let renewsAt = Date(timeIntervalSince1970: 1_810_569_600) + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + subscriptionExpiresAt: expiresAt, + subscriptionRenewsAt: renewsAt, + updatedAt: Date(timeIntervalSince1970: 1_800_000_000)) + + let copied = snapshot.with(extraRateWindows: []) + + #expect(copied.subscriptionExpiresAt == expiresAt) + #expect(copied.subscriptionRenewsAt == renewsAt) + } + @Test func `copilot icon falls back to chat lane when selected budget is unavailable`() { let snapshot = UsageSnapshot( From 9064aa6145797ed4718b04a5a403f9139007747c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 11 Jun 2026 12:49:48 +0100 Subject: [PATCH 14/14] fix: keep manual Copilot budget cookies fail closed --- .../Providers/Copilot/CopilotProviderDescriptor.swift | 3 +-- .../CopilotBudgetCookieRoutingTests.swift | 10 ++++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Copilot/CopilotProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Copilot/CopilotProviderDescriptor.swift index 51dab4a2ab..139e190d33 100644 --- a/Sources/CodexBarCore/Providers/Copilot/CopilotProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Copilot/CopilotProviderDescriptor.swift @@ -110,8 +110,7 @@ struct CopilotAPIFetchStrategy: ProviderFetchStrategy { from settings: ProviderSettingsSnapshot.CopilotProviderSettings) -> String? { guard settings.budgetCookieSource == .manual else { return nil } - let cookieHeader = settings.manualBudgetCookieHeader?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - return cookieHeader.isEmpty ? nil : cookieHeader + return CookieHeaderNormalizer.normalize(settings.manualBudgetCookieHeader) } private func expectedBudgetAccountIdentifier( diff --git a/Tests/CodexBarTests/CopilotBudgetCookieRoutingTests.swift b/Tests/CodexBarTests/CopilotBudgetCookieRoutingTests.swift index c2df05d2ac..1518c40bb1 100644 --- a/Tests/CodexBarTests/CopilotBudgetCookieRoutingTests.swift +++ b/Tests/CodexBarTests/CopilotBudgetCookieRoutingTests.swift @@ -31,4 +31,14 @@ struct CopilotBudgetCookieRoutingTests { #expect(CopilotAPIFetchStrategy.budgetCookieHeaderOverride(from: settings) == nil) } + + @Test + func `invalid manual budget cookies do not fall back to browser import`() { + let settings = ProviderSettingsSnapshot.CopilotProviderSettings( + budgetExtrasEnabled: true, + budgetCookieSource: .manual, + manualBudgetCookieHeader: "Cookie:") + + #expect(CopilotAPIFetchStrategy.budgetCookieHeaderOverride(from: settings) == nil) + } }