diff --git a/README.md b/README.md index 5e86f43181..3806cad11a 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ See [CLI configuration](docs/cli-configuration.md) for the full flow. - [OpenAI](docs/openai.md) — Admin API key usage/cost graphs with legacy credit-balance fallback. - [Azure OpenAI](docs/azure-openai.md) — API key, endpoint, and deployment validation probe. - [Claude](docs/claude.md) — OAuth API, browser cookies, or CLI PTY fallback; session and weekly usage where available. -- [Cursor](docs/cursor.md) — Browser session cookies for plan + usage + billing resets. +- [Cursor](docs/cursor.md) — Cursor app token or browser session cookies for plan + usage + billing resets. - [OpenCode](docs/opencode.md) — Browser cookies for workspace subscription usage. - [OpenCode Go](docs/opencode.md) — Browser or local SQLite data for Go usage windows. - [Alibaba Coding Plan](docs/alibaba-coding-plan.md) — Web cookies or API key for coding-plan quotas. diff --git a/Sources/CodexBar/Providers/Cursor/CursorProviderImplementation.swift b/Sources/CodexBar/Providers/Cursor/CursorProviderImplementation.swift index 53edc1779b..16c4c74a0d 100644 --- a/Sources/CodexBar/Providers/Cursor/CursorProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Cursor/CursorProviderImplementation.swift @@ -8,15 +8,31 @@ struct CursorProviderImplementation: ProviderImplementation { @MainActor func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { - ProviderPresentation { _ in "web" } + ProviderPresentation { context in + switch context.settings.cursorUsageDataSource { + case .app: "app" + case .web: "web" + case .auto: context.store.sourceLabel(for: .cursor) + } + } } @MainActor func observeSettings(_ settings: SettingsStore) { + _ = settings.cursorUsageDataSource _ = settings.cursorCookieSource _ = settings.cursorCookieHeader } + @MainActor + func sourceMode(context: ProviderSourceModeContext) -> ProviderSourceMode { + switch context.settings.cursorUsageDataSource { + case .auto: .auto + case .app: .oauth + case .web: .web + } + } + @MainActor func settingsSnapshot(context: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution? { .cursor(context.settings.cursorSettingsSnapshot(tokenOverride: context.tokenOverride)) @@ -34,10 +50,24 @@ struct CursorProviderImplementation: ProviderImplementation { if settings.cursorCookieSource != .manual { settings.cursorCookieSource = .manual } + // Selecting a saved account is an explicit choice; leave app-token + // mode so the reactivated manual cookie actually runs. + if settings.cursorUsageDataSource == .app { + settings.cursorUsageDataSource = .auto + } } @MainActor func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] { + let usageBinding = Binding( + get: { context.settings.cursorUsageDataSource.rawValue }, + set: { raw in + context.settings.cursorUsageDataSource = CursorUsageDataSource(rawValue: raw) ?? .auto + }) + let usageOptions = CursorUsageDataSource.allCases.map { + ProviderSettingsPickerOption(id: $0.rawValue, title: $0.displayName) + } + let cookieBinding = Binding( get: { context.settings.cursorCookieSource.rawValue }, set: { raw in @@ -57,6 +87,19 @@ struct CursorProviderImplementation: ProviderImplementation { } return [ + ProviderSettingsPickerDescriptor( + id: "cursor-usage-source", + title: "Usage source", + subtitle: "Auto prefers the Cursor app's local sign-in and falls back to browser cookies.", + binding: usageBinding, + options: usageOptions, + isVisible: nil, + onChange: nil, + trailingText: { + guard context.settings.cursorUsageDataSource == .auto else { return nil } + let label = context.store.sourceLabel(for: .cursor) + return label == "auto" ? nil : label + }), ProviderSettingsPickerDescriptor( id: "cursor-cookie-source", title: "Cookie source", @@ -64,7 +107,7 @@ struct CursorProviderImplementation: ProviderImplementation { dynamicSubtitle: cookieSubtitle, binding: cookieBinding, options: cookieOptions, - isVisible: nil, + isVisible: { context.settings.cursorUsageDataSource != .app }, onChange: nil, trailingText: { ProviderCookieSourceUI.cachedTrailingText(provider: .cursor) diff --git a/Sources/CodexBar/Providers/Cursor/CursorSettingsStore.swift b/Sources/CodexBar/Providers/Cursor/CursorSettingsStore.swift index 0de2438feb..4c5e07e16e 100644 --- a/Sources/CodexBar/Providers/Cursor/CursorSettingsStore.swift +++ b/Sources/CodexBar/Providers/Cursor/CursorSettingsStore.swift @@ -1,6 +1,24 @@ import CodexBarCore import Foundation +enum CursorUsageDataSource: String, CaseIterable, Identifiable, Sendable { + case auto + case app + case web + + var id: String { + self.rawValue + } + + var displayName: String { + switch self { + case .auto: "Auto" + case .app: "Cursor App Token" + case .web: "Browser Cookies" + } + } +} + extension SettingsStore { var cursorCookieHeader: String { get { self.configSnapshot.providerConfig(for: .cursor)?.sanitizedCookieHeader ?? "" } @@ -22,6 +40,27 @@ extension SettingsStore { } } + var cursorUsageDataSource: CursorUsageDataSource { + get { + switch self.configSnapshot.providerConfig(for: .cursor)?.source { + case .oauth: .app + case .web, .cli: .web + default: .auto + } + } + set { + let source: ProviderSourceMode? = switch newValue { + case .auto: .auto + case .app: .oauth + case .web: .web + } + self.updateProviderConfig(provider: .cursor) { entry in + entry.source = source + } + self.logProviderModeChange(provider: .cursor, field: "usageSource", value: newValue.rawValue) + } + } + func ensureCursorCookieLoaded() {} } diff --git a/Sources/CodexBar/SettingsStore+TokenAccounts.swift b/Sources/CodexBar/SettingsStore+TokenAccounts.swift index 722fe55c1a..7c47e6b261 100644 --- a/Sources/CodexBar/SettingsStore+TokenAccounts.swift +++ b/Sources/CodexBar/SettingsStore+TokenAccounts.swift @@ -28,6 +28,11 @@ extension SettingsStore { { return nil } + // App-token usage bypasses cookie and token accounts entirely; its + // snapshots must never be attributed to a saved account. + if provider == .cursor, self.cursorUsageDataSource == .app { + return nil + } return self.selectedTokenAccount(for: provider) } diff --git a/Sources/CodexBar/UsageStore+TokenCost.swift b/Sources/CodexBar/UsageStore+TokenCost.swift index 24cf19d654..051450640b 100644 --- a/Sources/CodexBar/UsageStore+TokenCost.swift +++ b/Sources/CodexBar/UsageStore+TokenCost.swift @@ -21,25 +21,81 @@ struct TokenSnapshotPublication: Sendable, Equatable { extension UsageStore { enum CursorCostCookiePreparation { case proceed(String?) + case skip case reject } + /// `prepareCursorCostCookie` plus the refresh-loop side effect: a skipped + /// fetch (cookie ladder Off, no app-token session) clears any stale state. + func prepareCursorCostCookieForRefresh(_ provider: UsageProvider) -> CursorCostCookiePreparation { + let preparation = self.prepareCursorCostCookie(for: provider) + if case .skip = preparation { + self.resetTokenUsageState(for: provider) + } + return preparation + } + func prepareCursorCostCookie(for provider: UsageProvider) -> CursorCostCookiePreparation { - // Provider-specific by design: Cursor's dashboard cost fetch consumes its manually selected browser cookie. - guard provider == .cursor, self.settings.cursorCookieSource == .manual else { + guard provider == .cursor else { return .proceed(nil) } - guard let header = CookieHeaderNormalizer.normalize(self.settings.cursorCookieHeader) else { - self.lastTokenFetchAt.removeValue(forKey: provider.instanceID) - self.lastTokenFetchScope.removeValue(forKey: provider.instanceID) - self.clearTokenSnapshot(for: provider) - self.tokenErrors[provider.instanceID] = "Cursor cost requires a non-empty Manual cookie header." - self.tokenFailureGates[provider.instanceID]?.reset() - return .reject + // App-token mode: cost must use the same app-token session as usage — + // never manual, cached, or browser cookies for a possibly different account. + if self.settings.cursorUsageDataSource == .app { + guard let header = CursorStatusProbe.appAuthCookieHeader() else { + return self.rejectCursorCost( + provider, + message: "Cursor cost requires the Cursor app to be signed in.") + } + return .proceed(header) + } + // Resolve the same account-aware settings the usage fetch context uses, + // so a selected token account defers the app token here exactly like it + // does for the usage refresh. + let resolved = self.cursorCostCookieSettings() + // Auto mode: when the usage pipeline would win with the app token, pin + // cost to that same session instead of resolving cached/browser cookies + // that may belong to a different account. + if self.settings.cursorUsageDataSource == .auto, + let header = CursorStatusProbe.autoModeAppAuthCookieHeader(cursorSettings: resolved) + { + return .proceed(header) + } + // Off disables only the cookie ladder (the app-token branches above + // bypass it, matching the CLI); without an app-token session there is + // no account left to fetch cost with, so stay off silently. + guard resolved.cookieSource != .off else { + return .skip + } + guard resolved.cookieSource == .manual else { + return .proceed(nil) + } + guard let header = CookieHeaderNormalizer.normalize(resolved.manualCookieHeader) else { + return self.rejectCursorCost( + provider, + message: "Cursor cost requires a non-empty Manual cookie header.") } return .proceed(header) } + private func rejectCursorCost( + _ provider: UsageProvider, + message: String) -> CursorCostCookiePreparation + { + self.lastTokenFetchAt.removeValue(forKey: provider.instanceID) + self.lastTokenFetchScope.removeValue(forKey: provider.instanceID) + self.clearTokenSnapshot(for: provider) + self.tokenErrors[provider.instanceID] = message + self.tokenFailureGates[provider.instanceID]?.reset() + return .reject + } + + /// Account-aware Cursor cookie settings, resolved exactly like the usage + /// fetch context (a selected token account becomes the manual header). + private func cursorCostCookieSettings() -> ProviderSettingsSnapshot.CursorProviderSettings { + self.settings.cursorSettingsSnapshot(tokenOverride: nil) + } + func loadTokenUsageSnapshot( provider: UsageProvider, force: Bool, @@ -325,9 +381,28 @@ extension UsageStore { return base } - let source = self.settings.cursorCookieSource + if self.settings.cursorUsageDataSource == .app { + let headerFingerprint = CursorStatusProbe.appAuthCookieHeader() + .map(CookieHeaderCache.credentialFingerprint) ?? "missing" + return "\(base)|cursorCookie=app:\(headerFingerprint)" + } + + // Mirror prepareCursorCostCookie: resolve the same account-aware + // settings, and cache auto-mode cost pinned to the app token under + // that credential, not the cookie ladder's. + let resolved = self.cursorCostCookieSettings() + if self.settings.cursorUsageDataSource == .auto, + let appHeader = CursorStatusProbe.autoModeAppAuthCookieHeader(cursorSettings: resolved) + { + return self.cursorCostScopeSignature( + historyDays: historyDays, + source: .auto, + credentialFingerprint: CookieHeaderCache.credentialFingerprint(appHeader)) + } + + let source = resolved.cookieSource if source == .manual { - let headerFingerprint = CookieHeaderNormalizer.normalize(self.settings.cursorCookieHeader) + let headerFingerprint = CookieHeaderNormalizer.normalize(resolved.manualCookieHeader) .map(CookieHeaderCache.credentialFingerprint) ?? "missing" return "\(base)|cursorCookie=manual:\(headerFingerprint)" } @@ -404,6 +479,7 @@ extension UsageStore { snapshot: CostUsageTokenSnapshot) -> String { guard provider == .cursor, + self.settings.cursorUsageDataSource != .app, self.settings.cursorCookieSource == .auto, let fingerprint = snapshot.credentialScopeFingerprint else { return initialSignature } diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 69ea0c6d22..6d2545a78c 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -1429,11 +1429,13 @@ extension UsageStore { return } - // Provider-specific by design: Cursor cost shares the dashboard-cookie source policy with status fetching. - // Cursor cost honors the same cookie policy as status: when the user set the cookie source - // to Off, skip the network fetch entirely (mirrors CursorProviderDescriptor.checkStatus). - if provider == .cursor, self.settings.cursorCookieSource == .off { - self.resetTokenUsageState(for: provider) + // Cursor cost reuses the status cookie policy: a Manual source forwards the manual header so + // cost and status share the same session; other sources fall back to auto resolution. Decided + // before the in-flight guard so switching to a skipped or rejected configuration clears stale + // cost state even while an older fetch is still running. + guard case let .proceed(cursorCookieHeaderOverride) = + self.prepareCursorCostCookieForRefresh(provider) + else { return } @@ -1441,11 +1443,6 @@ extension UsageStore { let now = Date() let historyDays = self.settings.costUsageHistoryDays - // Cursor cost reuses the status cookie policy: a Manual source forwards the manual header so - // cost and status share the same session; other sources fall back to auto resolution. - guard case let .proceed(cursorCookieHeaderOverride) = self.prepareCursorCostCookie(for: provider) else { - return - } let costScope = self.tokenCostScope(for: provider) let costScopeSignature = self.tokenSnapshotScopeSignature(for: provider) let publicationRevision = self.providerPublicationRevision(for: provider) @@ -1569,7 +1566,7 @@ extension UsageStore { } } - private func resetTokenUsageState(for provider: UsageProvider) { + func resetTokenUsageState(for provider: UsageProvider) { // Provider-specific by design: resetting Codex token state also cancels its two ledger catch-up workflows. if provider == .codex { self.cancelCodexCostCatchUp() diff --git a/Sources/CodexBarCLI/CLICostCommand.swift b/Sources/CodexBarCLI/CLICostCommand.swift index 9faa15b057..5fb2849895 100644 --- a/Sources/CodexBarCLI/CLICostCommand.swift +++ b/Sources/CodexBarCLI/CLICostCommand.swift @@ -69,6 +69,7 @@ extension CodexBarCLI { if let error = Self.cursorCostAvailabilityError( provider, settings: cursorCookieSettings, + source: config.providerConfig(for: .cursor)?.source, resolutionError: cursorCookieSettingsError) { exitCode = Self.mapError(error) @@ -86,7 +87,10 @@ extension CodexBarCLI { provider: provider, forceRefresh: forceRefresh, historyDays: historyDays, - cursorCookieHeaderOverride: Self.cursorCostHeaderOverride(provider, settings: cursorCookieSettings), + cursorCookieHeaderOverride: Self.cursorCostHeaderOverride( + provider, + settings: cursorCookieSettings, + source: config.providerConfig(for: .cursor)?.source), refreshPricingInBackground: false, includePiSessions: includePiSessions) switch format { @@ -391,16 +395,34 @@ extension CodexBarCLI { return context.settingsSnapshot(for: .cursor, account: account)?.cursor } - /// Return the actionable error for a Cursor cost fetch disabled by cookie-source policy. + /// Return the actionable error for a Cursor cost fetch disabled by source policy. static func cursorCostAvailabilityError( _ provider: UsageProvider, settings: ProviderSettingsSnapshot.CursorProviderSettings?, - resolutionError: Error? = nil) -> Error? + source: ProviderSourceMode? = nil, + resolutionError: Error? = nil, + appAuthCookieHeader: () -> String? = { CursorStatusProbe.appAuthCookieHeader() }) -> Error? { guard provider == .cursor else { return nil } if let resolutionError { return resolutionError } + // App-token usage pins cost to the app-token account; missing tokens + // fail closed instead of falling back to another account's cookies. + if source == .oauth { + return appAuthCookieHeader() == nil + ? CursorCostAvailabilityError.appTokenUnavailable + : nil + } + // Auto with a winning app token fetches with that session, so cookie + // policy errors (like an empty manual header) do not apply. + if Self.cursorCostAutoModeAppHeader( + source: source, + settings: settings, + appAuthCookieHeader: appAuthCookieHeader) != nil + { + return nil + } guard let settings else { return nil } switch settings.cookieSource { case .off: @@ -412,19 +434,50 @@ extension CodexBarCLI { } } - /// Manual cookie header to forward for a Cursor cost fetch, or nil for auto/non-cursor sources. + /// Cookie header to forward for a Cursor cost fetch, or nil for auto/non-cursor sources. + /// App-token usage forwards the app-token-derived session so cost and usage share an account, + /// and auto mode does the same whenever the usage pipeline would win with the app token. static func cursorCostHeaderOverride( _ provider: UsageProvider, - settings: ProviderSettingsSnapshot.CursorProviderSettings?) -> String? + settings: ProviderSettingsSnapshot.CursorProviderSettings?, + source: ProviderSourceMode? = nil, + appAuthCookieHeader: () -> String? = { CursorStatusProbe.appAuthCookieHeader() }) -> String? { - guard provider == .cursor, settings?.cookieSource == .manual else { return nil } + guard provider == .cursor else { return nil } + if source == .oauth { + return appAuthCookieHeader() + } + if let appHeader = cursorCostAutoModeAppHeader( + source: source, + settings: settings, + appAuthCookieHeader: appAuthCookieHeader) + { + return appHeader + } + guard settings?.cookieSource == .manual else { return nil } return CookieHeaderNormalizer.normalize(settings?.manualCookieHeader) } + + /// App-token session for an auto-mode cost fetch, or nil when explicit + /// selections (manual header, committed browser login) or an unusable + /// token defer to the cookie ladder. + private static func cursorCostAutoModeAppHeader( + source: ProviderSourceMode?, + settings: ProviderSettingsSnapshot.CursorProviderSettings?, + appAuthCookieHeader: () -> String?) -> String? + { + guard source == nil || source == .auto else { return nil } + return CursorStatusProbe.autoModeAppAuthCookieHeader( + cursorSettings: settings, + cachedEntry: CookieHeaderCache.load(provider: .cursor), + appAuthCookieHeader: appAuthCookieHeader) + } } enum CursorCostAvailabilityError: LocalizedError { case cookieSourceOff case manualCookieMissing + case appTokenUnavailable var errorDescription: String? { switch self { @@ -432,6 +485,8 @@ enum CursorCostAvailabilityError: LocalizedError { "Cursor cost is unavailable because the Cursor cookie source is set to Off." case .manualCookieMissing: "Cursor cost requires a non-empty Manual cookie header." + case .appTokenUnavailable: + "Cursor cost requires the Cursor app to be signed in when the usage source is App Token." } } } diff --git a/Sources/CodexBarCLI/CLIServeCommand.swift b/Sources/CodexBarCLI/CLIServeCommand.swift index 60bf2c4827..1d6f961d90 100644 --- a/Sources/CodexBarCLI/CLIServeCommand.swift +++ b/Sources/CodexBarCLI/CLIServeCommand.swift @@ -1478,13 +1478,17 @@ extension CodexBarCLI { if let error = Self.cursorCostAvailabilityError( provider, settings: cursorCookieSettings, + source: config.providerConfig(for: .cursor)?.source, resolutionError: cursorCookieSettingsError) { return Self.makeCostPayload(provider: provider, snapshot: nil, error: error) } return await fetch( provider, - Self.cursorCostHeaderOverride(provider, settings: cursorCookieSettings)) + Self.cursorCostHeaderOverride( + provider, + settings: cursorCookieSettings, + source: config.providerConfig(for: .cursor)?.source)) } } diff --git a/Sources/CodexBarCLI/CLIUsageCommand.swift b/Sources/CodexBarCLI/CLIUsageCommand.swift index b7ea849f32..2712280f40 100644 --- a/Sources/CodexBarCLI/CLIUsageCommand.swift +++ b/Sources/CodexBarCLI/CLIUsageCommand.swift @@ -439,6 +439,13 @@ extension CodexBarCLI { command: UsageCommandContext) async -> UsageCommandOutput { var output = UsageCommandOutput() + let configSource = tokenContext.preferredSourceMode(for: provider) + let baseSource = command.sourceModeOverride ?? configSource + let account = Self.effectiveUsageAccount( + provider: provider, + baseSource: baseSource, + selectionUsesOverride: tokenContext.selection.usesOverride, + account: account) let env = tokenContext.environment( base: ProcessInfo.processInfo.environment, provider: provider, @@ -448,8 +455,6 @@ extension CodexBarCLI { for: provider, account: account, codexActiveSourceOverride: codexVisibleAccount?.selectionSource) - let configSource = tokenContext.preferredSourceMode(for: provider) - let baseSource = command.sourceModeOverride ?? configSource let effectiveSourceMode = tokenContext.effectiveSourceMode( base: baseSource, provider: provider, @@ -733,6 +738,23 @@ extension CodexBarCLI { return nil } + /// The token account a usage fetch may attribute its result to. + /// Cursor's App Token mode always fetches the Cursor app's own account, so + /// an implicitly active saved account must not own or label the result + /// (mirrors `SettingsStore.effectiveSelectedTokenAccount`); explicit + /// `--account` selections instead route through the web strategy. + static func effectiveUsageAccount( + provider: UsageProvider, + baseSource: ProviderSourceMode, + selectionUsesOverride: Bool, + account: ProviderTokenAccount?) -> ProviderTokenAccount? + { + if provider == .cursor, baseSource == .oauth, !selectionUsesOverride { + return nil + } + return account + } + static func sourceModeRequiresWebSupport( _ sourceMode: ProviderSourceMode, provider: UsageProvider, diff --git a/Sources/CodexBarCore/Providers/Cursor/CursorProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Cursor/CursorProviderDescriptor.swift index 340fc2a035..9951f356e2 100644 --- a/Sources/CodexBarCore/Providers/Cursor/CursorProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Cursor/CursorProviderDescriptor.swift @@ -3,14 +3,22 @@ import SweetCookieKit public enum CursorProviderDescriptor { public static let descriptor: ProviderDescriptor = Self.makeDescriptor() - private static let credentials = ProviderCredentialAdapter(tokenAccountSupport: TokenAccountSupport( - title: "Session tokens", - subtitle: "Store multiple Cursor Cookie headers.", - placeholder: "Cookie: …", - injection: .cookieHeader, - requiresManualCookieSource: true, - cookieName: nil, - selectedAccountRequiresManualCookieSource: true)) + private static let credentials = ProviderCredentialAdapter( + tokenAccountSupport: TokenAccountSupport( + title: "Session tokens", + subtitle: "Store multiple Cursor Cookie headers.", + placeholder: "Cookie: …", + injection: .cookieHeader, + requiresManualCookieSource: true, + cookieName: nil, + selectedAccountRequiresManualCookieSource: true), + selectedAccountSourceModeResolver: { base, account, _ in + // An explicitly selected token account is a cookie credential: + // fetch it through the web strategy instead of letting App Token + // mode label the Cursor app's own data with the account (mirrors + // the app leaving App Token mode on explicit account selection). + base == .oauth && account != nil ? .web : base + }) /// Active Cursor sessions often live only in Safari; Chromium profiles may carry stale tokens. private static var browserCookieOrder: BrowserCookieImportOrder? { @@ -86,16 +94,18 @@ public enum CursorProviderDescriptor { supportsInlineTokenCostDashboard: true, primaryDetailKind: .requestQuota)), fetchPlan: ProviderFetchPlan( - sourceModes: [.auto, .cli, .web], - pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [CursorStatusFetchStrategy()] })), + sourceModes: [.auto, .cli, .web, .oauth], + pipeline: ProviderFetchPipeline(resolveStrategies: self.resolveStrategies)), cli: ProviderCLIConfig( name: "cursor", versionDetector: nil, supportsCostCommand: self.supportsCostCommand, - browserSupportExemption: { _, _, settings in + browserSupportExemption: { sourceMode, _, settings in #if os(Linux) // Linux uses Cursor app auth and manual cookies; browser import remains macOS-only. - settings?.cursor?.cookieSource != .off + // Auto is always exempt: it can still fetch with the Cursor app token + // even when the cookie source is Off. + sourceMode == .auto || settings?.cursor?.cookieSource != .off #else false #endif @@ -134,11 +144,40 @@ public enum CursorProviderDescriptor { false #endif } + + @Sendable + private static func resolveStrategies(context: ProviderFetchContext) async -> [any ProviderFetchStrategy] { + #if os(macOS) || os(Linux) + switch context.sourceMode { + case .oauth: + return [CursorAppTokenFetchStrategy()] + case .web, .cli: + // Cli is only ever set by the shell for "no cookie yet"; treat it as + // web so empty-manual users get a browser cookie attempt (#212). + return [CursorStatusFetchStrategy()] + case .auto: + let appToken = CursorAppTokenFetchStrategy() + // When the app token will run first, the web ladder must not retry + // the same token as its own last-resort fallback. + let appTokenAvailable = await appToken.isAvailable(context) + return [appToken, CursorStatusFetchStrategy(allowAppAuthFallback: !appTokenAvailable)] + case .api: + return [] + } + #else + return [CursorStatusFetchStrategy()] + #endif + } } struct CursorStatusFetchStrategy: ProviderFetchStrategy { let id: String = "cursor.web" let kind: ProviderFetchKind = .web + let allowAppAuthFallback: Bool + + init(allowAppAuthFallback: Bool = true) { + self.allowAppAuthFallback = allowAppAuthFallback + } func isAvailable(_ context: ProviderFetchContext) async -> Bool { guard context.settings?.cursor?.cookieSource != .off else { return false } @@ -148,7 +187,9 @@ struct CursorStatusFetchStrategy: ProviderFetchStrategy { func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { let probe = CursorStatusProbe(browserDetection: context.browserDetection) let manual = Self.manualCookieHeader(from: context) - let snap = try await probe.fetch(cookieHeaderOverride: manual) + let snap = try await probe.fetch( + cookieHeaderOverride: manual, + allowAppAuthFallback: self.allowAppAuthFallback) return self.makeResult( usage: snap.toUsageSnapshot(), sourceLabel: "web") @@ -163,3 +204,56 @@ struct CursorStatusFetchStrategy: ProviderFetchStrategy { return CookieHeaderNormalizer.normalize(context.settings?.cursor?.manualCookieHeader) } } + +#if os(macOS) || os(Linux) +/// Fetches usage with the Cursor desktop app's locally stored access token, +/// mirroring the Codex/Claude pattern of preferring a local credential over +/// browser cookies. +struct CursorAppTokenFetchStrategy: ProviderFetchStrategy { + let id: String = "cursor.oauth" + let kind: ProviderFetchKind = .oauth + + private let appAuthStore: any CursorAppAuthSessionProviding + private let loadCachedEntry: @Sendable () -> CookieHeaderCache.Entry? + + init( + appAuthStore: any CursorAppAuthSessionProviding = CursorAppAuthStore(), + loadCachedEntry: @escaping @Sendable () -> CookieHeaderCache.Entry? = { + CookieHeaderCache.load(provider: .cursor) + }) + { + self.appAuthStore = appAuthStore + self.loadCachedEntry = loadCachedEntry + } + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + // Explicit account choices must keep winning automatic mode: a manual + // cookie header (or selected token account) and an explicitly selected + // browser login both outrank the app token. + if context.sourceMode == .auto, + CursorStatusProbe.autoModeDefersToExplicitSelection( + cursorSettings: context.settings?.cursor, + cachedEntry: self.loadCachedEntry()) + { + return false + } + guard let session = try? self.appAuthStore.loadSession() else { return false } + return session.isUsable + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + guard let session = try self.appAuthStore.loadSession(), session.isUsable else { + throw CursorStatusProbeError.notLoggedIn + } + let probe = CursorStatusProbe(browserDetection: context.browserDetection) + let snap = try await probe.fetchWithAppAuthSession(session) + return self.makeResult( + usage: snap.toUsageSnapshot(), + sourceLabel: "app") + } + + func shouldFallback(on _: Error, context: ProviderFetchContext) -> Bool { + context.sourceMode == .auto + } +} +#endif diff --git a/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift b/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift index 7f794fb712..bb605380d7 100644 --- a/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift @@ -533,18 +533,34 @@ struct CursorAppAuthStore: CursorAppAuthSessionProviding { } private static func decodeSQLiteValue(stmt: OpaquePointer?, index: Int32) -> String? { + let raw: String? switch sqlite3_column_type(stmt, index) { case SQLITE_TEXT: guard let c = sqlite3_column_text(stmt, index) else { return nil } - return String(cString: c) + raw = String(cString: c) case SQLITE_BLOB: guard let bytes = sqlite3_column_blob(stmt, index) else { return nil } - let data = Data(bytes: bytes, count: Int(sqlite3_column_bytes(stmt, index))) - return String(data: data, encoding: .utf8) - ?? String(data: data, encoding: .utf16LittleEndian) + raw = Self.decodeBlobString(Data(bytes: bytes, count: Int(sqlite3_column_bytes(stmt, index)))) default: return nil } + guard let raw else { return nil } + + // VS Code-style state values are occasionally JSON-quoted; tolerate that form. + let trimmed = raw.trimmingCharacters(in: CharacterSet(charactersIn: "\0 \t\r\n")) + if trimmed.count >= 2, trimmed.hasPrefix("\""), trimmed.hasSuffix("\"") { + return String(trimmed.dropFirst().dropLast()) + } + return trimmed + } + + /// UTF-16-LE blobs of ASCII text decode as "valid" UTF-8 full of interior + /// NULs; only accept a NUL-free UTF-8 decode before trying UTF-16. + private static func decodeBlobString(_ data: Data) -> String? { + if let text = String(data: data, encoding: .utf8), !text.contains("\0") { + return text + } + return String(data: data, encoding: .utf16LittleEndian) } } @@ -1042,6 +1058,58 @@ public struct CursorStatusProbe: Sendable { requestUsageUserIDFallback: session.userID()) } + /// First-party web-session Cookie header derived from Cursor.app's local + /// access token, or nil when the app has no usable token. Lets callers pin + /// a fetch to the app-token account without the cookie fallback ladder. + public static func appAuthCookieHeader() -> String? { + self.appAuthCookieHeader(store: CursorAppAuthStore()) + } + + static func appAuthCookieHeader(store: any CursorAppAuthSessionProviding) -> String? { + guard let session = try? store.loadSession(), session.isUsable else { return nil } + return try? session.cookieHeader() + } + + /// True when automatic mode must keep an explicitly selected account — + /// a manual cookie header (or selected token account) or a committed + /// browser login — ahead of the Cursor app token. + static func autoModeDefersToExplicitSelection( + cursorSettings: ProviderSettingsSnapshot.CursorProviderSettings?, + cachedEntry: @autoclosure () -> CookieHeaderCache.Entry?) -> Bool + { + if cursorSettings?.cookieSource == .manual, + CookieHeaderNormalizer.normalize(cursorSettings?.manualCookieHeader) != nil + { + return true + } + return cachedEntry()?.authenticationFailurePolicy == .stopFallback + } + + /// Cookie header the automatic usage pipeline fetches with when the app + /// token wins auto mode, or nil when an explicit selection or an unusable + /// token defers to the cookie ladder. Cost fetches use this to stay on the + /// same account as the usage card. + public static func autoModeAppAuthCookieHeader( + cursorSettings: ProviderSettingsSnapshot.CursorProviderSettings?) -> String? + { + self.autoModeAppAuthCookieHeader( + cursorSettings: cursorSettings, + cachedEntry: CookieHeaderCache.load(provider: .cursor), + appAuthCookieHeader: { self.appAuthCookieHeader() }) + } + + public static func autoModeAppAuthCookieHeader( + cursorSettings: ProviderSettingsSnapshot.CursorProviderSettings?, + cachedEntry: @autoclosure () -> CookieHeaderCache.Entry?, + appAuthCookieHeader: () -> String?) -> String? + { + guard !self.autoModeDefersToExplicitSelection( + cursorSettings: cursorSettings, + cachedEntry: cachedEntry()) + else { return nil } + return appAuthCookieHeader() + } + /// Fetch Cursor usage with manual cookie header (for debugging). public func fetchWithManualCookies(_ cookieHeader: String) async throws -> CursorStatusSnapshot { try await self.fetchWithCookieHeader(cookieHeader) @@ -1780,6 +1848,16 @@ public struct CursorStatusProbe: Sendable { { throw CursorStatusProbeError.notSupported } + + public static func appAuthCookieHeader() -> String? { + nil + } + + public static func autoModeAppAuthCookieHeader( + cursorSettings _: ProviderSettingsSnapshot.CursorProviderSettings?) -> String? + { + nil + } } #endif diff --git a/Tests/CodexBarTests/CursorAppTokenStrategyTests.swift b/Tests/CodexBarTests/CursorAppTokenStrategyTests.swift new file mode 100644 index 0000000000..10acfc9f33 --- /dev/null +++ b/Tests/CodexBarTests/CursorAppTokenStrategyTests.swift @@ -0,0 +1,185 @@ +import Foundation +import Testing +@testable import CodexBarCore + +private enum CursorAppTokenStrategyTestError: Error { + case unused +} + +private struct CursorAppTokenStrategyStubClaudeFetcher: ClaudeUsageFetching { + func loadLatestUsage(model _: String) async throws -> ClaudeUsageSnapshot { + throw CursorAppTokenStrategyTestError.unused + } + + func debugRawProbe(model _: String) async -> String { + "stub" + } + + func detectVersion() -> String? { + nil + } +} + +private struct CursorAppTokenStoreStub: CursorAppAuthSessionProviding { + let session: CursorAppAuthSession? + + func loadSession() throws -> CursorAppAuthSession? { + self.session + } +} + +private func makeCursorAppTokenJWT(expiration: Date = Date(timeIntervalSinceNow: 3600)) throws -> String { + let payload = try JSONSerialization.data( + withJSONObject: [ + "exp": Int(expiration.timeIntervalSince1970), + "sub": "auth0|user_test", + ], + options: [.sortedKeys]) + let encodedPayload = payload.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + return "header.\(encodedPayload).signature" +} + +struct CursorAppTokenStrategyTests { + @Test + func `descriptor exposes oauth source mode`() { + #expect(CursorProviderDescriptor.descriptor.fetchPlan.sourceModes.contains(.oauth)) + } + + @Test + func `oauth mode resolves only the app token strategy`() async { + let strategies = await Self.resolveStrategies(sourceMode: .oauth) + #expect(strategies.map(\.id) == ["cursor.oauth"]) + #expect(strategies.map(\.kind) == [.oauth]) + } + + @Test + func `auto mode prefers the app token strategy before web`() async { + let strategies = await Self.resolveStrategies(sourceMode: .auto) + #expect(strategies.map(\.id) == ["cursor.oauth", "cursor.web"]) + } + + @Test + func `web and cli modes resolve only the web strategy`() async { + let webStrategies = await Self.resolveStrategies(sourceMode: .web) + #expect(webStrategies.map(\.id) == ["cursor.web"]) + + let cliStrategies = await Self.resolveStrategies(sourceMode: .cli) + #expect(cliStrategies.map(\.id) == ["cursor.web"]) + } + + @Test + func `usable app session is available in oauth mode`() async throws { + let token = try makeCursorAppTokenJWT() + let strategy = CursorAppTokenFetchStrategy( + appAuthStore: CursorAppTokenStoreStub(session: CursorAppAuthSession(accessToken: token)), + loadCachedEntry: { nil }) + #expect(await strategy.isAvailable(Self.makeContext(sourceMode: .oauth))) + } + + @Test + func `missing app session is unavailable and fetch surfaces not logged in`() async { + let strategy = CursorAppTokenFetchStrategy( + appAuthStore: CursorAppTokenStoreStub(session: nil), + loadCachedEntry: { nil }) + let context = Self.makeContext(sourceMode: .oauth) + + #expect(await strategy.isAvailable(context) == false) + await #expect(throws: CursorStatusProbeError.self) { + _ = try await strategy.fetch(context) + } + } + + @Test + func `expired app session is unavailable`() async throws { + let token = try makeCursorAppTokenJWT(expiration: Date(timeIntervalSinceNow: -60)) + let strategy = CursorAppTokenFetchStrategy( + appAuthStore: CursorAppTokenStoreStub(session: CursorAppAuthSession(accessToken: token)), + loadCachedEntry: { nil }) + #expect(await strategy.isAvailable(Self.makeContext(sourceMode: .auto)) == false) + } + + @Test + func `explicitly selected browser login keeps winning auto mode`() async throws { + let token = try makeCursorAppTokenJWT() + let selectedEntry = CookieHeaderCache.Entry( + cookieHeader: "WorkosCursorSessionToken=selected", + storedAt: Date(), + sourceLabel: "Chrome", + authenticationFailurePolicy: .stopFallback) + let strategy = CursorAppTokenFetchStrategy( + appAuthStore: CursorAppTokenStoreStub(session: CursorAppAuthSession(accessToken: token)), + loadCachedEntry: { selectedEntry }) + + #expect(await strategy.isAvailable(Self.makeContext(sourceMode: .auto)) == false) + // An explicit oauth selection still uses the app token. + #expect(await strategy.isAvailable(Self.makeContext(sourceMode: .oauth))) + } + + @Test + func `manual cookie source keeps winning auto mode`() async throws { + let token = try makeCursorAppTokenJWT() + let strategy = CursorAppTokenFetchStrategy( + appAuthStore: CursorAppTokenStoreStub(session: CursorAppAuthSession(accessToken: token)), + loadCachedEntry: { nil }) + let manualSettings = ProviderSettingsSnapshot.make( + cursor: .init(cookieSource: .manual, manualCookieHeader: "WorkosCursorSessionToken=manual")) + + #expect(await strategy.isAvailable(Self.makeContext(sourceMode: .auto, settings: manualSettings)) == false) + // An explicit oauth selection still uses the app token. + #expect(await strategy.isAvailable(Self.makeContext(sourceMode: .oauth, settings: manualSettings))) + + // A manual source without a usable header cannot pin an account. + let emptyManualSettings = ProviderSettingsSnapshot.make( + cursor: .init(cookieSource: .manual, manualCookieHeader: " ")) + #expect(await strategy.isAvailable(Self.makeContext(sourceMode: .auto, settings: emptyManualSettings))) + } + + @Test + func `unselected cached session does not block auto mode`() async throws { + let token = try makeCursorAppTokenJWT() + let importedEntry = CookieHeaderCache.Entry( + cookieHeader: "WorkosCursorSessionToken=imported", + storedAt: Date(), + sourceLabel: "Chrome") + let strategy = CursorAppTokenFetchStrategy( + appAuthStore: CursorAppTokenStoreStub(session: CursorAppAuthSession(accessToken: token)), + loadCachedEntry: { importedEntry }) + #expect(await strategy.isAvailable(Self.makeContext(sourceMode: .auto))) + } + + @Test + func `app token strategy only falls back in auto mode`() { + let strategy = CursorAppTokenFetchStrategy( + appAuthStore: CursorAppTokenStoreStub(session: nil), + loadCachedEntry: { nil }) + let error = CursorStatusProbeError.notLoggedIn + #expect(strategy.shouldFallback(on: error, context: Self.makeContext(sourceMode: .auto))) + #expect(!strategy.shouldFallback(on: error, context: Self.makeContext(sourceMode: .oauth))) + } + + private static func resolveStrategies(sourceMode: ProviderSourceMode) async -> [any ProviderFetchStrategy] { + await CursorProviderDescriptor.descriptor.fetchPlan.pipeline + .resolveStrategies(self.makeContext(sourceMode: sourceMode)) + } + + private static func makeContext( + sourceMode: ProviderSourceMode, + settings: ProviderSettingsSnapshot? = nil) -> ProviderFetchContext + { + ProviderFetchContext( + runtime: .app, + sourceMode: sourceMode, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: [:], + settings: settings, + fetcher: UsageFetcher(environment: [:]), + claudeFetcher: CursorAppTokenStrategyStubClaudeFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0)) + } +} diff --git a/Tests/CodexBarTests/CursorTokenAccountSourceTests.swift b/Tests/CodexBarTests/CursorTokenAccountSourceTests.swift new file mode 100644 index 0000000000..4084c7608d --- /dev/null +++ b/Tests/CodexBarTests/CursorTokenAccountSourceTests.swift @@ -0,0 +1,122 @@ +import Foundation +import Testing +@testable import CodexBar +@testable import CodexBarCore + +@MainActor +struct CursorTokenAccountSourceTests { + @Test + func `app token usage source deactivates saved cursor token accounts`() { + let settings = testSettingsStore(suiteName: "CursorTokenAccountSourceTests") + settings.addTokenAccount( + provider: .cursor, + label: "Saved", + token: "WorkosCursorSessionToken=saved") + settings.setActiveTokenAccountIndex(0, for: .cursor) + + #expect(settings.cursorCookieSource == .manual) + #expect(settings.effectiveSelectedTokenAccount(for: .cursor) != nil) + + // App-token usage bypasses cookie auth; the saved account must not + // own snapshots fetched with the Cursor app's credential. + settings.cursorUsageDataSource = .app + #expect(settings.effectiveSelectedTokenAccount(for: .cursor) == nil) + + settings.cursorUsageDataSource = .auto + #expect(settings.effectiveSelectedTokenAccount(for: .cursor) != nil) + } + + @Test + func `selecting a token account leaves app token mode`() { + let settings = testSettingsStore(suiteName: "CursorTokenAccountSourceTests") + settings.cursorUsageDataSource = .app + settings.addTokenAccount( + provider: .cursor, + label: "Saved", + token: "WorkosCursorSessionToken=saved") + settings.setActiveTokenAccountIndex(0, for: .cursor) + + #expect(settings.cursorUsageDataSource == .auto) + #expect(settings.cursorCookieSource == .manual) + #expect(settings.effectiveSelectedTokenAccount(for: .cursor) != nil) + } + + @Test + func `auto mode cost defers to a selected token account`() { + let settings = testSettingsStore(suiteName: "CursorTokenAccountSourceTests") + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + settings.addTokenAccount( + provider: .cursor, + label: "Saved", + token: "WorkosCursorSessionToken=saved") + settings.setActiveTokenAccountIndex(0, for: .cursor) + // The selected account carries the credential; the global manual + // header stays empty, so only account-aware resolution can defer. + #expect(settings.cursorCookieHeader.isEmpty) + + guard case let .proceed(header) = store.prepareCursorCostCookie(for: .cursor) else { + Issue.record("expected cost to proceed with the selected account header") + return + } + #expect(header == "WorkosCursorSessionToken=saved") + } + + @Test + func `cost stays off with the cookie ladder disabled and no app token path`() { + let settings = testSettingsStore(suiteName: "CursorTokenAccountSourceTests") + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + // Browser Cookies mode never consults the app token, so Off leaves no + // session to fetch cost with regardless of the machine's Cursor app. + settings.cursorUsageDataSource = .web + settings.cursorCookieSource = .off + + guard case .skip = store.prepareCursorCostCookie(for: .cursor) else { + Issue.record("expected cost to skip while the cookie source is Off") + return + } + } + + @Test + func `skipped cost clears stale state while a refresh is in flight`() async { + let settings = testSettingsStore(suiteName: "CursorTokenAccountSourceTests") + settings.costUsageEnabled = true + if let metadata = ProviderRegistry.shared.metadata[.cursor] { + settings.setProviderEnabled(provider: .cursor, metadata: metadata, enabled: true) + } + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + settings.cursorUsageDataSource = .web + settings.cursorCookieSource = .off + store.publishTokenSnapshot( + CostUsageTokenSnapshot( + sessionTokens: 10, + sessionCostUSD: 1, + last30DaysTokens: 10, + last30DaysCostUSD: 1, + daily: [], + updatedAt: Date(timeIntervalSince1970: 1_784_203_200)), + for: .cursor) + // An older fetch is still running: the skip must clear the stale cost + // snapshot anyway instead of waiting behind the in-flight guard. + store.tokenRefreshInFlight.insert(.cursor) + defer { store.tokenRefreshInFlight.remove(.cursor) } + + await store.refreshTokenUsage(.cursor, force: true) + + #expect(store.tokenSnapshot(for: .cursor) == nil) + } +} diff --git a/TestsLinux/CursorLinuxTests.swift b/TestsLinux/CursorLinuxTests.swift index f78acb1ba5..2f1994bee6 100644 --- a/TestsLinux/CursorLinuxTests.swift +++ b/TestsLinux/CursorLinuxTests.swift @@ -1,9 +1,93 @@ #if os(Linux) +import CSQLite3 import Foundation import Testing @testable import CodexBarCLI @testable import CodexBarCore +private struct CursorLinuxClaudeFetcherStub: ClaudeUsageFetching { + struct Unavailable: Error {} + + func loadLatestUsage(model _: String) async throws -> ClaudeUsageSnapshot { + throw Unavailable() + } + + func debugRawProbe(model _: String) async -> String { + "stub" + } + + func detectVersion() -> String? { + nil + } +} + +private func makeCursorFetchContext( + sourceMode: ProviderSourceMode, + settings: ProviderSettingsSnapshot? = nil) -> ProviderFetchContext +{ + ProviderFetchContext( + runtime: .cli, + sourceMode: sourceMode, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: [:], + settings: settings, + fetcher: UsageFetcher(environment: [:]), + claudeFetcher: CursorLinuxClaudeFetcherStub(), + browserDetection: BrowserDetection(cacheTTL: 0)) +} + +/// Write a Cursor-style `state.vscdb` with the given ItemTable rows. +private func writeCursorStateDB(at path: String, entries: [(key: String, value: String)]) throws { + var db: OpaquePointer? + try #require(sqlite3_open(path, &db) == SQLITE_OK) + defer { sqlite3_close(db) } + + let createTable = "CREATE TABLE ItemTable (key TEXT PRIMARY KEY, value TEXT)" + try #require(sqlite3_exec(db, createTable, nil, nil, nil) == SQLITE_OK) + for entry in entries { + var stmt: OpaquePointer? + let insert = "INSERT INTO ItemTable (key, value) VALUES (?1, ?2)" + try #require(sqlite3_prepare_v2(db, insert, -1, &stmt, nil) == SQLITE_OK) + defer { sqlite3_finalize(stmt) } + let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + sqlite3_bind_text(stmt, 1, entry.key, -1, transient) + sqlite3_bind_text(stmt, 2, entry.value, -1, transient) + try #require(sqlite3_step(stmt) == SQLITE_DONE) + } +} + +private struct CursorLinuxAppAuthStoreStub: CursorAppAuthSessionProviding { + let session: CursorAppAuthSession? + + func loadSession() throws -> CursorAppAuthSession? { + self.session + } +} + +private func makeCursorLinuxAppTokenJWT() throws -> String { + let payload = try JSONSerialization.data( + withJSONObject: [ + "exp": Int(Date(timeIntervalSinceNow: 3600).timeIntervalSince1970), + "sub": "auth0|user_test", + ], + options: [.sortedKeys]) + let encodedPayload = payload.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + return "header.\(encodedPayload).signature" +} + +private func makeCursorTempDirectory() throws -> URL { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("cursor-linux-tests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return directory +} + struct CursorLinuxTests { @Test func `Cursor database path honors absolute XDG config home`() { @@ -62,5 +146,365 @@ struct CursorLinuxTests { settings: ProviderSettingsSnapshot.make( cursor: .init(cookieSource: .off, manualCookieHeader: nil)))) } + + @Test + func `Cursor descriptor accepts explicit oauth source`() { + #expect(CursorProviderDescriptor.descriptor.fetchPlan.sourceModes.contains(.oauth)) + } + + @Test + func `Cursor oauth source never requires web support`() { + #expect(!CodexBarCLI.sourceModeRequiresWebSupport( + .oauth, + provider: .cursor, + settings: ProviderSettingsSnapshot.make( + cursor: .init(cookieSource: .off, manualCookieHeader: nil)))) + } + + @Test + func `Cursor auto source with cookies off does not require web support`() { + // Auto can still fetch with the Cursor app token when the cookie source is Off. + #expect(!CodexBarCLI.sourceModeRequiresWebSupport( + .auto, + provider: .cursor, + settings: ProviderSettingsSnapshot.make( + cursor: .init(cookieSource: .off, manualCookieHeader: nil)))) + } + + @Test + func `Cursor oauth mode resolves only the app token strategy`() async { + let strategies = await CursorProviderDescriptor.descriptor.fetchPlan.pipeline + .resolveStrategies(makeCursorFetchContext(sourceMode: .oauth)) + #expect(strategies.map(\.id) == ["cursor.oauth"]) + } + + @Test + func `Cursor auto mode prefers the app token strategy before web`() async { + let strategies = await CursorProviderDescriptor.descriptor.fetchPlan.pipeline + .resolveStrategies(makeCursorFetchContext(sourceMode: .auto)) + #expect(strategies.map(\.id) == ["cursor.oauth", "cursor.web"]) + } + + @Test + func `Cursor app auth store reads token from state database`() throws { + let directory = try makeCursorTempDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let dbPath = directory.appendingPathComponent("state.vscdb").path + try writeCursorStateDB(at: dbPath, entries: [ + ("cursorAuth/accessToken", "eyJhbGciOiJIUzI1NiJ9.payload.sig"), + ]) + + let session = try #require(try CursorAppAuthStore(dbPath: dbPath).loadSession()) + #expect(session.accessToken == "eyJhbGciOiJIUzI1NiJ9.payload.sig") + } + + @Test + func `Cursor app auth store strips surrounding JSON quotes`() throws { + let directory = try makeCursorTempDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let dbPath = directory.appendingPathComponent("state.vscdb").path + try writeCursorStateDB(at: dbPath, entries: [ + ("cursorAuth/accessToken", "\"tok-123\""), + ]) + + let session = try #require(try CursorAppAuthStore(dbPath: dbPath).loadSession()) + #expect(session.accessToken == "tok-123") + } + + @Test + func `Cursor app auth store decodes UTF-16 blob values`() throws { + let directory = try makeCursorTempDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let dbPath = directory.appendingPathComponent("state.vscdb").path + try writeCursorStateDB(at: dbPath, entries: []) + + var db: OpaquePointer? + try #require(sqlite3_open(dbPath, &db) == SQLITE_OK) + defer { sqlite3_close(db) } + var stmt: OpaquePointer? + let insert = "INSERT INTO ItemTable (key, value) VALUES (?1, ?2)" + try #require(sqlite3_prepare_v2(db, insert, -1, &stmt, nil) == SQLITE_OK) + defer { sqlite3_finalize(stmt) } + let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + sqlite3_bind_text(stmt, 1, "cursorAuth/accessToken", -1, transient) + let utf16Bytes: [UInt8] = Array("tok-utf16".utf16.flatMap { [UInt8($0 & 0xFF), UInt8($0 >> 8)] }) + try utf16Bytes.withUnsafeBufferPointer { buffer in + try #require(sqlite3_bind_blob( + stmt, 2, buffer.baseAddress, Int32(buffer.count), transient) == SQLITE_OK) + } + try #require(sqlite3_step(stmt) == SQLITE_DONE) + + let session = try #require(try CursorAppAuthStore(dbPath: dbPath).loadSession()) + #expect(session.accessToken == "tok-utf16") + } + + @Test + func `Cursor app auth store returns nil session without a token row`() throws { + let directory = try makeCursorTempDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let dbPath = directory.appendingPathComponent("state.vscdb").path + try writeCursorStateDB(at: dbPath, entries: [("someOther/key", "value")]) + + #expect(try CursorAppAuthStore(dbPath: dbPath).loadSession() == nil) + } + + @Test + func `Cursor app auth store returns nil session without a database`() throws { + let missing = "/nonexistent/cursor-linux-tests/state.vscdb" + #expect(try CursorAppAuthStore(dbPath: missing).loadSession() == nil) + } + + @Test + func `Cursor app token strategy is unavailable without a database`() async { + let strategy = CursorAppTokenFetchStrategy( + appAuthStore: CursorAppAuthStore(dbPath: "/nonexistent/cursor-linux-tests/state.vscdb"), + loadCachedEntry: { nil }) + #expect(await strategy.isAvailable(makeCursorFetchContext(sourceMode: .oauth)) == false) + } + + @Test + func `Cursor app auth cookie header derives from a usable session`() throws { + let token = try makeCursorLinuxAppTokenJWT() + let header = CursorStatusProbe.appAuthCookieHeader( + store: CursorLinuxAppAuthStoreStub(session: CursorAppAuthSession(accessToken: token))) + #expect(header == "WorkosCursorSessionToken=user_test%3A%3A\(token)") + + #expect(CursorStatusProbe.appAuthCookieHeader( + store: CursorLinuxAppAuthStoreStub(session: nil)) == nil) + #expect(CursorStatusProbe.appAuthCookieHeader( + store: CursorLinuxAppAuthStoreStub(session: CursorAppAuthSession(accessToken: "not-a-jwt"))) == nil) + } + + @Test + func `Cursor cost helpers pin oauth source to the app token session`() throws { + let token = try makeCursorLinuxAppTokenJWT() + let appHeader = "WorkosCursorSessionToken=user_test%3A%3A\(token)" + let manualSettings = ProviderSettingsSnapshot.CursorProviderSettings( + cookieSource: .manual, + manualCookieHeader: "WorkosCursorSessionToken=manual") + + // App token present: no availability error, and the override is the + // app session even when a manual cookie is configured. + #expect(CodexBarCLI.cursorCostAvailabilityError( + .cursor, + settings: manualSettings, + source: .oauth, + appAuthCookieHeader: { appHeader }) == nil) + #expect(CodexBarCLI.cursorCostHeaderOverride( + .cursor, + settings: manualSettings, + source: .oauth, + appAuthCookieHeader: { appHeader }) == appHeader) + + // App token missing: fail closed instead of using other cookies. + let error = CodexBarCLI.cursorCostAvailabilityError( + .cursor, + settings: manualSettings, + source: .oauth, + appAuthCookieHeader: { nil }) + #expect(error is CursorCostAvailabilityError) + #expect(CodexBarCLI.cursorCostHeaderOverride( + .cursor, + settings: manualSettings, + source: .oauth, + appAuthCookieHeader: { nil }) == nil) + + // Non-oauth sources keep the existing manual-header behavior. + #expect(CodexBarCLI.cursorCostHeaderOverride( + .cursor, + settings: manualSettings, + source: .auto, + appAuthCookieHeader: { appHeader }) == "WorkosCursorSessionToken=manual") + } + + @Test + func `Cursor auto mode app header defers to explicit selections`() throws { + let token = try makeCursorLinuxAppTokenJWT() + let appHeader = "WorkosCursorSessionToken=user_test%3A%3A\(token)" + let autoSettings = ProviderSettingsSnapshot.CursorProviderSettings( + cookieSource: .auto, + manualCookieHeader: nil) + let manualSettings = ProviderSettingsSnapshot.CursorProviderSettings( + cookieSource: .manual, + manualCookieHeader: "WorkosCursorSessionToken=manual") + let selectedLogin = CookieHeaderCache.Entry( + cookieHeader: "WorkosCursorSessionToken=selected", + storedAt: Date(), + sourceLabel: "Chrome", + authenticationFailurePolicy: .stopFallback) + + // App token wins plain automatic mode. + #expect(CursorStatusProbe.autoModeAppAuthCookieHeader( + cursorSettings: autoSettings, + cachedEntry: nil, + appAuthCookieHeader: { appHeader }) == appHeader) + + // Explicit selections defer: manual header or committed browser login. + #expect(CursorStatusProbe.autoModeAppAuthCookieHeader( + cursorSettings: manualSettings, + cachedEntry: nil, + appAuthCookieHeader: { appHeader }) == nil) + #expect(CursorStatusProbe.autoModeAppAuthCookieHeader( + cursorSettings: autoSettings, + cachedEntry: selectedLogin, + appAuthCookieHeader: { appHeader }) == nil) + + // A manual source without a usable header cannot pin an account. + let emptyManualSettings = ProviderSettingsSnapshot.CursorProviderSettings( + cookieSource: .manual, + manualCookieHeader: " ") + #expect(CursorStatusProbe.autoModeAppAuthCookieHeader( + cursorSettings: emptyManualSettings, + cachedEntry: nil, + appAuthCookieHeader: { appHeader }) == appHeader) + + // No usable app token: the cookie ladder keeps ownership. + #expect(CursorStatusProbe.autoModeAppAuthCookieHeader( + cursorSettings: autoSettings, + cachedEntry: nil, + appAuthCookieHeader: { nil }) == nil) + } + + @Test + func `Cursor cost helpers pin auto source to a winning app token`() throws { + let token = try makeCursorLinuxAppTokenJWT() + let appHeader = "WorkosCursorSessionToken=user_test%3A%3A\(token)" + let autoSettings = ProviderSettingsSnapshot.CursorProviderSettings( + cookieSource: .auto, + manualCookieHeader: nil) + + // Auto (explicit or defaulted source) rides the winning app token. + #expect(CodexBarCLI.cursorCostHeaderOverride( + .cursor, + settings: autoSettings, + source: .auto, + appAuthCookieHeader: { appHeader }) == appHeader) + #expect(CodexBarCLI.cursorCostHeaderOverride( + .cursor, + settings: autoSettings, + source: nil, + appAuthCookieHeader: { appHeader }) == appHeader) + + // An empty manual header is not an error while the app token wins. + let emptyManualSettings = ProviderSettingsSnapshot.CursorProviderSettings( + cookieSource: .manual, + manualCookieHeader: nil) + #expect(CodexBarCLI.cursorCostAvailabilityError( + .cursor, + settings: emptyManualSettings, + source: .auto, + appAuthCookieHeader: { appHeader }) == nil) + #expect(CodexBarCLI.cursorCostHeaderOverride( + .cursor, + settings: emptyManualSettings, + source: .auto, + appAuthCookieHeader: { appHeader }) == appHeader) + + // Without a winning app token the cookie policy still applies. + #expect(CodexBarCLI.cursorCostAvailabilityError( + .cursor, + settings: emptyManualSettings, + source: .auto, + appAuthCookieHeader: { nil }) is CursorCostAvailabilityError) + #expect(CodexBarCLI.cursorCostHeaderOverride( + .cursor, + settings: autoSettings, + source: .auto, + appAuthCookieHeader: { nil }) == nil) + + // Explicit web source keeps cookie-ladder behavior even with a token. + #expect(CodexBarCLI.cursorCostHeaderOverride( + .cursor, + settings: autoSettings, + source: .web, + appAuthCookieHeader: { appHeader }) == nil) + } + + @Test + func `Cursor manual cookie source keeps winning auto mode over app token`() async throws { + let token = try makeCursorLinuxAppTokenJWT() + let strategy = CursorAppTokenFetchStrategy( + appAuthStore: CursorLinuxAppAuthStoreStub(session: CursorAppAuthSession(accessToken: token)), + loadCachedEntry: { nil }) + let manualSettings = ProviderSettingsSnapshot.make( + cursor: .init(cookieSource: .manual, manualCookieHeader: "WorkosCursorSessionToken=manual")) + + #expect(await strategy.isAvailable( + makeCursorFetchContext(sourceMode: .auto, settings: manualSettings)) == false) + // An explicit oauth selection still uses the app token. + #expect(await strategy.isAvailable( + makeCursorFetchContext(sourceMode: .oauth, settings: manualSettings))) + // Automatic cookie source keeps the token-first ordering. + #expect(await strategy.isAvailable(makeCursorFetchContext( + sourceMode: .auto, + settings: ProviderSettingsSnapshot.make( + cursor: .init(cookieSource: .auto, manualCookieHeader: nil))))) + } + + @Test + func `Cursor app token usage never carries an implicit saved account`() { + let saved = ProviderTokenAccount( + id: UUID(), + label: "Saved", + token: "WorkosCursorSessionToken=saved", + addedAt: 0, + lastUsed: nil) + + // App Token mode fetches the Cursor app's own account: the implicitly + // active saved account must not own or label the result. + #expect(CodexBarCLI.effectiveUsageAccount( + provider: .cursor, + baseSource: .oauth, + selectionUsesOverride: false, + account: saved) == nil) + // Explicit selections stay, and are routed through the web strategy. + #expect(CodexBarCLI.effectiveUsageAccount( + provider: .cursor, + baseSource: .oauth, + selectionUsesOverride: true, + account: saved)?.label == "Saved") + // Other source modes keep the account-aware cookie behavior. + #expect(CodexBarCLI.effectiveUsageAccount( + provider: .cursor, + baseSource: .auto, + selectionUsesOverride: false, + account: saved)?.label == "Saved") + } + + @Test + func `Cursor explicit accounts route app token mode to the web strategy`() throws { + let saved = ProviderTokenAccount( + id: UUID(), + label: "Saved", + token: "WorkosCursorSessionToken=saved", + addedAt: 0, + lastUsed: nil) + let context = try TokenAccountCLIContext( + selection: TokenAccountCLISelection(label: "Saved", index: nil, allAccounts: false), + config: CodexBarConfig(providers: []), + verbose: false) + + #expect(context.effectiveSourceMode(base: .oauth, provider: .cursor, account: saved) == .web) + #expect(context.effectiveSourceMode(base: .oauth, provider: .cursor, account: nil) == .oauth) + #expect(context.effectiveSourceMode(base: .auto, provider: .cursor, account: saved) == .auto) + } + + @Test + func `Cursor manual deference skips loading the cached browser login`() { + let manualSettings = ProviderSettingsSnapshot.CursorProviderSettings( + cookieSource: .manual, + manualCookieHeader: "WorkosCursorSessionToken=manual") + var loadedCache = false + func loadCachedEntry() -> CookieHeaderCache.Entry? { + loadedCache = true + return nil + } + + let defers = CursorStatusProbe.autoModeDefersToExplicitSelection( + cursorSettings: manualSettings, + cachedEntry: loadCachedEntry()) + #expect(defers) + #expect(!loadedCache) + } } #endif diff --git a/docs/cursor.md b/docs/cursor.md index e16bdbacc7..8935139d0b 100644 --- a/docs/cursor.md +++ b/docs/cursor.md @@ -8,9 +8,29 @@ read_when: # Cursor provider -Cursor is primarily web-backed. Usage is fetched via browser cookies, with legacy stored-session cookies and Cursor.app local auth as fallbacks. +Cursor supports two credential sources: the Cursor desktop app's locally stored access token and browser web sessions. The **Usage source** picker selects between them; Automatic prefers the app token and falls back to browser cookies. -## Data sources + fallback order +## Usage source + +- Preferences → Providers → Cursor → **Usage source**: Auto (default), Cursor App Token, or Browser Cookies. CLI: `--source auto|oauth|web`. +- **Cursor App Token** (`oauth`): only the app-token strategy runs (`cursor.oauth`, source label `app`). No browser or cookie stack is involved; the Cookie source picker is hidden in this mode. +- **Browser Cookies** (`web`): only the cookie ladder below runs (`cursor.web`). +- **Auto**: app token first, then the cookie ladder. Explicit account choices keep winning Auto — the app token defers to a Manual cookie source with a usable header (including a selected token account) and to an explicitly selected browser login (committed by Add/Switch Account with stop-fallback policy), so account selection stays stable. +- Cookie source **Off** disables only the cookie ladder: app-token fetches (App Token mode, or Auto with a winning app token) still run for usage and cost, and without a usable app token Cursor stays off. +- Saved token accounts never own app-token fetches. In App Token mode the app and CLI ignore the implicitly active saved account, and an explicit CLI `--account` selection fetches that account's cookie credential through the web strategy instead of relabeling app-token data. + +## Cursor app token (`cursor.oauth`) + +- Reads Cursor.app's VS Code-style global state DB (`ItemTable` key `cursorAuth/accessToken`). +- File: + - macOS: `~/Library/Application Support/Cursor/User/globalStorage/state.vscdb` + - Linux: `$XDG_CONFIG_HOME/Cursor/User/globalStorage/state.vscdb` (default `~/.config/Cursor/...`) +- Values are tolerated as plain text, JSON-quoted strings, or UTF-16 blobs. +- The token is only used while its JWT expiry is more than 60s away; CodexBar never refreshes it (the Cursor app keeps it fresh). +- Derives Cursor's first-party web-session cookie (`WorkosCursorSessionToken=::`), then uses the same usage and account endpoints as browser sessions. +- Account identity comes from that authenticated session; cached app profile fields are not mixed across accounts. + +## Browser cookie ladder (`cursor.web`) 1) **Cached cookie header** (preferred) - Stored after successful browser import. @@ -29,14 +49,8 @@ Cursor is primarily web-backed. Usage is fetched via browser cookies, with legac - Stored at: `~/Library/Application Support/CodexBar/cursor-session.json`. 4) **Cursor.app local auth** (last fallback) - - Reads Cursor.app's VS Code-style global state DB for the local app bearer token. - - File: - - macOS: `~/Library/Application Support/Cursor/User/globalStorage/state.vscdb` - - Linux: `$XDG_CONFIG_HOME/Cursor/User/globalStorage/state.vscdb` (default `~/.config/Cursor/...`) - - Used only after cookie/session sources fail so existing account-selection precedence stays stable. - - On Linux, this is the primary automatic source because browser import is macOS-only. - - Derives Cursor's first-party web-session cookie, then uses the same usage and account endpoints as browser sessions. - - Account identity comes from that authenticated session; cached app profile fields are not mixed across accounts. + - Same app-token read as `cursor.oauth`, kept at the bottom of the ladder for Browser Cookies mode. + - In Auto it is skipped when the app-token strategy already ran, so the token is not attempted twice per refresh. Manual option: - Preferences → Providers → Cursor → Cookie source → Manual. @@ -52,7 +66,9 @@ Manual option: - CodexBar checks all available profiles in the selected browser. Add accepts a sole unambiguous account automatically, while Switch always asks for confirmation before replacing the current account, even when only one eligible alternative is found. Multiple eligible accounts always require an explicit choice, and CodexBar caches only the chosen session. - A successful add or switch selects the Automatic cookie source. Saved manual headers and token accounts remain stored but passive: they do not override browser fetching, cached usage, quota warnings, or utilization/reset - ownership. Explicitly selecting a saved token account switches Cursor back to Manual and reactivates it. + ownership. Explicitly selecting a saved token account switches Cursor back to Manual and reactivates it (leaving + Cursor App Token mode if it was selected). In Cursor App Token mode, saved token accounts are never treated as + active, so app-token snapshots are not attributed to a saved account. ## API endpoints - `GET https://cursor.com/api/usage-summary` @@ -69,6 +85,7 @@ Manual option: ## Linux CLI - `codexbar usage --provider cursor` reads the signed-in Cursor app's access token from the Linux global state DB and reuses the same `cursor.com` usage endpoints as macOS. +- `--source oauth` forces the app token on any platform and never requires the macOS web stack. - Automatic browser cookie import and the external-browser Add/Switch flow are macOS app features. - Manual cookie headers from `~/.config/codexbar/config.json` (or legacy `~/.codexbar/config.json`) work on Linux. @@ -89,10 +106,16 @@ The storage detail lists measured paths and their sizes. CodexBar does not delet The cost summary's Cursor section is opt-in: it only fetches when **Show cost summary** is enabled and the Cursor provider is on. Unlike Claude and Codex cost (scanned from local session logs on this machine), Cursor cost is remote, account-wide data from the cursor.com dashboard, so it covers usage from every machine on the account. -Auth reuses the exact status-probe session resolution and cookie-source policy: +Auth follows the Usage source picker so cost and usage always come from the same account. In **Cursor App Token** +mode the cost fetch uses only the app-token-derived session (forwarded like a manual header, so it never falls +back to manual/cached/browser cookies); a missing app token fails the fetch closed. In **Auto** usage mode, cost +is pinned to that same app-token session exactly when the usage pipeline would win with the app token (same +deference rules: a usable manual header — including a selected token account — or a committed browser login +keeps ownership). Otherwise auth reuses the +exact status-probe session resolution and cookie-source policy: - **Auto**: cached cookie header → browser cookie import → stored WebKit session → Cursor.app local auth. - **Manual**: a non-empty pasted cookie header is required and forwarded as-is, so cost and status share the same session; an empty header fails closed instead of falling back to another account. -- **Off**: the fetch is skipped in the app; `codexbar cost --provider cursor` fails explicitly and `/cost` returns a provider error row. +- **Off**: disables only the cookie ladder. An app-token-carried fetch (App Token mode, or Auto with a winning app token) still runs; otherwise the fetch is skipped in the app (clearing any stale cost immediately, even while an older fetch is still running), `codexbar cost --provider cursor` fails explicitly, and `/cost` returns a provider error row. Fetch behavior: - `POST https://cursor.com/api/dashboard/get-filtered-usage-events` (cookie-authenticated; requires a matching `Origin` for CSRF). @@ -114,7 +137,9 @@ Caching: the app holds the snapshot for an in-memory hourly TTL, keyed by the hi - Reset: billing cycle end date. ## Key files -- `Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift` +- `Sources/CodexBarCore/Providers/Cursor/CursorProviderDescriptor.swift` (fetch strategies + source modes) +- `Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift` (session resolution, app-token store) +- `Sources/CodexBar/Providers/Cursor/CursorSettingsStore.swift` (usage source + cookie settings) - `Sources/CodexBar/CursorLoginRunner.swift` (login flow) - `Sources/CodexBar/Providers/Cursor/CursorLoginFlow.swift` (menu integration) - `Sources/CodexBar/CursorLoginBrowserRouter.swift` (browser routing and selection)