From c7bd7b939b60c585b3f766a9f2d31929ae46aaa4 Mon Sep 17 00:00:00 2001 From: Asis Panda Date: Wed, 2 Sep 2026 10:14:18 +0530 Subject: [PATCH 1/6] Add Nous Portal provider Show Nous Portal monthly subscription credits, cycle reset, plan, and purchased credit balance by reusing the Hermes Agent OAuth login stored in ~/.hermes/auth.json. The provider reads GET /api/oauth/account with the existing access token and never calls the refresh endpoint, because Nous refresh tokens are single-use and reuse revokes the Hermes session. Refs #1367 Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 5 + .../Nous/NousProviderImplementation.swift | 42 +++ .../ProviderImplementationManifest.swift | 1 + .../CodexBar/Resources/ProviderIcon-nous.svg | 4 + .../Nous/NousProviderDescriptor.swift | 96 +++++++ .../Providers/Nous/NousSettingsReader.swift | 227 +++++++++++++++ .../Providers/Nous/NousUsageFetcher.swift | 268 ++++++++++++++++++ .../ProviderInstanceIDAliases.generated.swift | 1 + .../Providers/ProviderManifest.swift | 1 + .../CodexBarCore/Providers/Providers.swift | 1 + .../NousProviderDescriptorTests.swift | 53 ++++ .../NousSettingsReaderTests.swift | 164 +++++++++++ .../CodexBarTests/NousUsageFetcherTests.swift | 141 +++++++++ .../ProviderArchitectureGatekeeperTests.swift | 5 +- docs/nous.md | 60 ++++ docs/provider-ids.md | 2 +- docs/providers.md | 14 +- 17 files changed, 1081 insertions(+), 4 deletions(-) create mode 100644 Sources/CodexBar/Providers/Nous/NousProviderImplementation.swift create mode 100644 Sources/CodexBar/Resources/ProviderIcon-nous.svg create mode 100644 Sources/CodexBarCore/Providers/Nous/NousProviderDescriptor.swift create mode 100644 Sources/CodexBarCore/Providers/Nous/NousSettingsReader.swift create mode 100644 Sources/CodexBarCore/Providers/Nous/NousUsageFetcher.swift create mode 100644 Tests/CodexBarTests/NousProviderDescriptorTests.swift create mode 100644 Tests/CodexBarTests/NousSettingsReaderTests.swift create mode 100644 Tests/CodexBarTests/NousUsageFetcherTests.swift create mode 100644 docs/nous.md diff --git a/CHANGELOG.md b/CHANGELOG.md index e8c3e52d10..ac31730338 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## Unreleased + +### Added +- Nous Portal: new provider showing monthly subscription credits, cycle reset, plan, and purchased credit balance by reusing the Hermes Agent OAuth login from `~/.hermes/auth.json` (#1367). + ## 0.56.3 — 2026-09-01 ### Performance diff --git a/Sources/CodexBar/Providers/Nous/NousProviderImplementation.swift b/Sources/CodexBar/Providers/Nous/NousProviderImplementation.swift new file mode 100644 index 0000000000..5628a46a9e --- /dev/null +++ b/Sources/CodexBar/Providers/Nous/NousProviderImplementation.swift @@ -0,0 +1,42 @@ +import AppKit +import CodexBarCore +import Foundation +import SwiftUI + +struct NousProviderImplementation: ProviderImplementation { + let id: UsageProvider = .nous + + @MainActor + func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { + ProviderPresentation { _ in "api" } + } + + @MainActor + func isAvailable(context: ProviderAvailabilityContext) -> Bool { + NousSettingsReader.credential(environment: context.environment) != nil + } + + @MainActor + func settingsActions(context _: ProviderSettingsContext) -> [ProviderSettingsActionsDescriptor] { + let status = NousSettingsReader.unavailableMessage(environment: ProcessInfo.processInfo.environment) + ?? "Reading the Nous Portal login Hermes Agent stored in ~/.hermes/auth.json." + return [ + ProviderSettingsActionsDescriptor( + id: "nous-hermes-login", + title: "Hermes Agent login", + subtitle: status + " CodexBar never refreshes the token; run `hermes` to renew it. " + + "Set NOUS_PORTAL_ACCESS_TOKEN or HERMES_HOME to override.", + actions: [ + ProviderSettingsActionDescriptor( + id: "nous-open-portal", + title: "Open Nous Portal", + style: .link, + isVisible: nil, + perform: { + NSWorkspace.shared.open(NousSettingsReader.defaultPortalBaseURL) + }), + ], + isVisible: nil), + ] + } +} diff --git a/Sources/CodexBar/Providers/Shared/ProviderImplementationManifest.swift b/Sources/CodexBar/Providers/Shared/ProviderImplementationManifest.swift index 49ee8bb14b..ce549591d9 100644 --- a/Sources/CodexBar/Providers/Shared/ProviderImplementationManifest.swift +++ b/Sources/CodexBar/Providers/Shared/ProviderImplementationManifest.swift @@ -75,5 +75,6 @@ enum ProviderImplementationManifest { { XAIProviderImplementation() }, { NotionProviderImplementation() }, { IBMBobProviderImplementation() }, + { NousProviderImplementation() }, ] } diff --git a/Sources/CodexBar/Resources/ProviderIcon-nous.svg b/Sources/CodexBar/Resources/ProviderIcon-nous.svg new file mode 100644 index 0000000000..2ca0148189 --- /dev/null +++ b/Sources/CodexBar/Resources/ProviderIcon-nous.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Sources/CodexBarCore/Providers/Nous/NousProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Nous/NousProviderDescriptor.swift new file mode 100644 index 0000000000..b737b4f710 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Nous/NousProviderDescriptor.swift @@ -0,0 +1,96 @@ +import Foundation + +public enum NousProviderDescriptor { + public static let descriptor: ProviderDescriptor = Self.makeDescriptor() + + private static let credentials = ProviderCredentialAdapter( + supportsAPIKeyOverride: false, + requiresAPIKeyForAPISource: false, + tokenResolver: { kind, environment, _ in + guard kind == .primary, let credential = NousSettingsReader.credential(environment: environment) else { + return nil + } + let source: ProviderTokenSource = credential.source == .environment ? .environment : .authFile + return ProviderTokenResolution(token: credential.token, source: source) + }, + authDetector: { environment, _ in + NousSettingsReader.credential(environment: environment) == nil ? [] : ["api"] + }, + missingCredentialMessage: { environment in + NousSettingsReader.unavailableMessage(environment: environment) + }) + + static func makeDescriptor() -> ProviderDescriptor { + ProviderDescriptor( + id: .nous, + menuBarMetrics: ProviderMenuBarMetricCapabilities(supported: [.automatic, .primary]), + credentials: self.credentials, + metadata: ProviderMetadata( + id: .nous, + displayName: "Nous Portal", + shortDisplayName: "Nous", + sessionLabel: "Monthly credits", + weeklyLabel: "Weekly", + opusLabel: nil, + supportsOpus: false, + supportsCredits: true, + creditsHint: "Purchased credit balance from Nous Portal", + toggleTitle: "Show Nous Portal usage", + cliName: "nous", + defaultEnabled: false, + widgetSelectable: false, + isPrimaryProvider: false, + usesAccountFallback: false, + dashboardURL: "https://portal.nousresearch.com/usage", + subscriptionDashboardURL: "https://portal.nousresearch.com/manage-subscription", + statusPageURL: nil), + branding: ProviderBranding( + iconStyle: .init(provider: .nous), + iconResourceName: "ProviderIcon-nous", + color: ProviderColor(red: 214 / 255, green: 165 / 255, blue: 92 / 255), + confettiPalette: [ + ProviderColor(hex: 0xD6A55C), + ProviderColor(hex: 0x1C1B1A), + ProviderColor(hex: 0xF3EADB), + ]), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: false, + noDataMessage: { "Nous Portal cost summary is not available." }), + presentation: ProviderUsagePresentation( + planRow: ProviderPlanRowPresentation(label: "Plan")), + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .api], + pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [NousAPIFetchStrategy()] })), + cli: ProviderCLIConfig( + name: "nous", + aliases: ["nous-portal", "hermes"], + versionDetector: nil)) + } +} + +struct NousAPIFetchStrategy: ProviderFetchStrategy { + let id = "nous.api" + let kind: ProviderFetchKind = .apiToken + private let transport: any ProviderHTTPTransport + + init(transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) { + self.transport = transport + } + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + NousSettingsReader.credential(environment: context.env) != nil + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + let credential = try NousSettingsReader.resolveCredential(environment: context.env) + let account = try await NousUsageFetcher.fetchAccount(credential: credential, transport: self.transport) + return self.makeResult( + usage: account.toUsageSnapshot(), + credits: context.includeCredits ? account.toCreditsSnapshot() : nil, + sourceLabel: "api") + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } +} diff --git a/Sources/CodexBarCore/Providers/Nous/NousSettingsReader.swift b/Sources/CodexBarCore/Providers/Nous/NousSettingsReader.swift new file mode 100644 index 0000000000..4ed9e08e51 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Nous/NousSettingsReader.swift @@ -0,0 +1,227 @@ +import Foundation + +/// Resolves the Nous Portal access token CodexBar uses for read-only billing lookups. +/// +/// Nous Portal issues short-lived OAuth access tokens through the Hermes Agent device-code login. Refresh tokens +/// are single-use and the portal revokes the whole session when it detects reuse, so CodexBar never refreshes: +/// it only reads the access token Hermes already minted (`~/.hermes/auth.json`) or an explicit environment +/// override, and reports a clear "run `hermes`" message once that token expires. +public enum NousSettingsReader: Sendable { + public static let accessTokenEnvironmentKey = "NOUS_PORTAL_ACCESS_TOKEN" + public static let portalBaseURLEnvironmentKeys = ["NOUS_PORTAL_BASE_URL", "HERMES_PORTAL_BASE_URL"] + public static let hermesHomeEnvironmentKey = "HERMES_HOME" + public static let defaultPortalBaseURL = URL(string: "https://portal.nousresearch.com")! + /// Tokens closer to expiry than this are treated as expired so a fetch never races the portal clock. + public static let expirySkew: TimeInterval = 60 + + public enum CredentialSource: Sendable, Equatable { + case environment + case authFile(String) + + public var label: String { + switch self { + case .environment: "env" + case .authFile: "hermes" + } + } + } + + public struct Credential: Sendable, Equatable { + public let token: String + public let portalBaseURL: URL + public let expiresAt: Date? + public let source: CredentialSource + + public init(token: String, portalBaseURL: URL, expiresAt: Date?, source: CredentialSource) { + self.token = token + self.portalBaseURL = portalBaseURL + self.expiresAt = expiresAt + self.source = source + } + + public func isExpired(now: Date = Date(), skew: TimeInterval = NousSettingsReader.expirySkew) -> Bool { + guard let expiresAt else { return false } + return expiresAt.timeIntervalSince(now) <= skew + } + } + + /// Returns a usable (non-expired) credential, or nil when none is configured. + public static func credential( + environment: [String: String] = ProcessInfo.processInfo.environment, + now: Date = Date()) -> Credential? + { + try? self.resolveCredential(environment: environment, now: now) + } + + /// Resolves the credential, throwing a typed error that explains what is missing or expired. + public static func resolveCredential( + environment: [String: String] = ProcessInfo.processInfo.environment, + now: Date = Date()) throws -> Credential + { + if let token = self.cleaned(environment[self.accessTokenEnvironmentKey]) { + return Credential( + token: token, + portalBaseURL: self.portalBaseURL(environment: environment, stored: nil), + expiresAt: self.jwtExpiry(token), + source: .environment) + } + + var expired: Credential? + var sawFile = false + for url in self.authFileCandidates(environment: environment) { + guard FileManager.default.fileExists(atPath: url.path) else { continue } + sawFile = true + guard let data = try? Data(contentsOf: url), + let stored = self.parseAuthFile(data: data) + else { continue } + let credential = Credential( + token: stored.token, + portalBaseURL: self.portalBaseURL(environment: environment, stored: stored.portalBaseURL), + expiresAt: stored.expiresAt ?? self.jwtExpiry(stored.token), + source: .authFile(url.path)) + if credential.isExpired(now: now) { + expired = expired ?? credential + continue + } + return credential + } + + if let expired, case let .authFile(path) = expired.source { + throw NousUsageError.sessionExpired(path) + } + throw sawFile + ? NousUsageError.authFileInvalid(self.authFileCandidates(environment: environment).first?.path ?? "") + : NousUsageError.missingCredentials + } + + public static func unavailableMessage(environment: [String: String]) -> String? { + do { + _ = try self.resolveCredential(environment: environment) + return nil + } catch { + return (error as? LocalizedError)?.errorDescription ?? error.localizedDescription + } + } + + public static func portalBaseURL(environment: [String: String], stored: String?) -> URL { + for key in self.portalBaseURLEnvironmentKeys { + if let raw = self.cleaned(environment[key]), let url = self.normalizedHTTPSURL(raw) { + return url + } + } + if let stored, let url = self.normalizedHTTPSURL(stored) { + return url + } + return self.defaultPortalBaseURL + } + + // MARK: - Hermes auth store + + struct StoredCredential: Equatable { + let token: String + let portalBaseURL: String? + let expiresAt: Date? + } + + /// Hermes stores per-profile credentials in `auth.json` and a cross-profile copy in `shared/nous_auth.json`. + static func authFileCandidates(environment: [String: String]) -> [URL] { + var roots: [URL] = [] + if let override = self.cleaned(environment[self.hermesHomeEnvironmentKey]) { + roots.append(URL(fileURLWithPath: NSString(string: override).expandingTildeInPath, isDirectory: true)) + } + roots.append(self.defaultHermesHome(environment: environment)) + + var seen = Set() + var candidates: [URL] = [] + for root in roots { + for relative in ["auth.json", "shared/nous_auth.json"] { + let url = root.appendingPathComponent(relative) + if seen.insert(url.path).inserted { + candidates.append(url) + } + } + } + return candidates + } + + static func defaultHermesHome(environment: [String: String]) -> URL { + let home: URL = if let raw = self.cleaned(environment["HOME"]) { + URL(fileURLWithPath: NSString(string: raw).expandingTildeInPath, isDirectory: true) + } else { + FileManager.default.homeDirectoryForCurrentUser + } + return home.appendingPathComponent(".hermes", isDirectory: true) + } + + /// Accepts the three shapes Hermes writes: `providers.nous`, `credential_pool.nous[]`, or a bare state object. + static func parseAuthFile(data: Data) -> StoredCredential? { + guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil } + + if let providers = root["providers"] as? [String: Any], + let nous = providers["nous"] as? [String: Any], + let stored = self.storedCredential(from: nous) + { + return stored + } + if let pool = root["credential_pool"] as? [String: Any], + let entries = pool["nous"] as? [[String: Any]] + { + for entry in entries { + if let stored = self.storedCredential(from: entry) { + return stored + } + } + } + return self.storedCredential(from: root) + } + + private static func storedCredential(from state: [String: Any]) -> StoredCredential? { + guard let token = self.cleaned(state["access_token"] as? String) else { return nil } + return StoredCredential( + token: token, + portalBaseURL: self.cleaned(state["portal_base_url"] as? String), + expiresAt: (state["expires_at"] as? String).flatMap(Self.parseISODate)) + } + + // MARK: - Helpers + + static func parseISODate(_ raw: String) -> Date? { + let fractional = ISO8601DateFormatter() + fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = fractional.date(from: raw) { return date } + let plain = ISO8601DateFormatter() + plain.formatOptions = [.withInternetDateTime] + return plain.date(from: raw) + } + + /// Best-effort `exp` claim from a JWT so environment overrides also get expiry checks. + static func jwtExpiry(_ token: String) -> Date? { + let parts = token.split(separator: ".") + guard parts.count == 3 else { return nil } + var payload = String(parts[1]).replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/") + while payload.count % 4 != 0 { payload.append("=") } + guard let data = Data(base64Encoded: payload), + let claims = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let exp = claims["exp"] as? Double + else { return nil } + return Date(timeIntervalSince1970: exp) + } + + static func normalizedHTTPSURL(_ raw: String) -> URL? { + var value = raw + while value.hasSuffix("/") { value.removeLast() } + guard let url = URL(string: value), let scheme = url.scheme?.lowercased(), url.host != nil else { return nil } + guard scheme == "https" || (scheme == "http" && (url.host == "localhost" || url.host == "127.0.0.1")) else { + return nil + } + return url + } + + static func cleaned(_ raw: String?) -> String? { + guard var value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { return nil } + if (value.hasPrefix("\"") && value.hasSuffix("\"")) || (value.hasPrefix("'") && value.hasSuffix("'")) { + value = String(value.dropFirst().dropLast()).trimmingCharacters(in: .whitespacesAndNewlines) + } + return value.isEmpty ? nil : value + } +} diff --git a/Sources/CodexBarCore/Providers/Nous/NousUsageFetcher.swift b/Sources/CodexBarCore/Providers/Nous/NousUsageFetcher.swift new file mode 100644 index 0000000000..b2d986e1ba --- /dev/null +++ b/Sources/CodexBarCore/Providers/Nous/NousUsageFetcher.swift @@ -0,0 +1,268 @@ +import Foundation + +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +/// Parsed `GET /api/oauth/account` response from Nous Portal. +public struct NousAccountSummary: Sendable, Equatable { + public let email: String? + public let organizationName: String? + public let plan: String? + public let monthlyCredits: Double + public let creditsRemaining: Double + public let rolloverCredits: Double + public let currentPeriodEnd: Date? + public let purchasedCreditsRemaining: Double + public let totalUsableCredits: Double? + public let hasActiveSubscription: Bool + public let updatedAt: Date + + public init( + email: String?, + organizationName: String?, + plan: String?, + monthlyCredits: Double, + creditsRemaining: Double, + rolloverCredits: Double, + currentPeriodEnd: Date?, + purchasedCreditsRemaining: Double, + totalUsableCredits: Double?, + hasActiveSubscription: Bool, + updatedAt: Date) + { + self.email = email + self.organizationName = organizationName + self.plan = plan + self.monthlyCredits = monthlyCredits + self.creditsRemaining = creditsRemaining + self.rolloverCredits = rolloverCredits + self.currentPeriodEnd = currentPeriodEnd + self.purchasedCreditsRemaining = purchasedCreditsRemaining + self.totalUsableCredits = totalUsableCredits + self.hasActiveSubscription = hasActiveSubscription + self.updatedAt = updatedAt + } + + /// Monthly subscription credits consumed this cycle, as a percentage of the monthly grant. + public var monthlyUsedPercent: Double? { + guard self.monthlyCredits > 0 else { return nil } + let used = max(0, self.monthlyCredits - max(0, self.creditsRemaining)) + return min(100, used / self.monthlyCredits * 100) + } + + public func toUsageSnapshot() -> UsageSnapshot { + let primary = self.monthlyUsedPercent.map { percent in + RateWindow( + usedPercent: percent, + windowMinutes: nil, + resetsAt: self.currentPeriodEnd, + resetDescription: nil) + } + let identity = ProviderIdentitySnapshot( + providerID: .nous, + accountEmail: self.email, + accountOrganization: self.organizationName, + loginMethod: self.plan ?? (self.hasActiveSubscription ? nil : "Free")) + return UsageSnapshot( + primary: primary, + secondary: nil, + details: self.detailSections(), + subscriptionRenewsAt: self.currentPeriodEnd, + updatedAt: self.updatedAt, + identity: identity, + dataConfidence: .exact) + } + + public func toCreditsSnapshot() -> CreditsSnapshot { + CreditsSnapshot(remaining: self.purchasedCreditsRemaining, events: [], updatedAt: self.updatedAt) + } + + private func detailSections() -> [ProviderDetailSection] { + var sections: [ProviderDetailSection] = [] + var subscriptionRows: [ProviderDetailSection.Row] = [] + if self.monthlyCredits > 0 { + let remaining = UsageFormatter.usdString(max(0, self.creditsRemaining)) + let monthly = UsageFormatter.usdString(self.monthlyCredits) + if let row = try? ProviderDetailSection.Row(label: "Monthly credits", value: "\(remaining) of \(monthly) left") { + subscriptionRows.append(row) + } + } + if self.rolloverCredits > 0, + let row = try? ProviderDetailSection.Row( + label: "Rollover credits", + value: UsageFormatter.usdString(self.rolloverCredits)) + { + subscriptionRows.append(row) + } + if let currentPeriodEnd, + let row = try? ProviderDetailSection.Row( + label: "Renews", + value: Self.renewalFormatter.string(from: currentPeriodEnd)) + { + subscriptionRows.append(row) + } + if !subscriptionRows.isEmpty, let section = try? ProviderDetailSection(title: "Subscription", rows: subscriptionRows) { + sections.append(section) + } + + var creditRows: [ProviderDetailSection.Row] = [] + if let row = try? ProviderDetailSection.Row( + label: "Purchased balance", + value: UsageFormatter.usdString(self.purchasedCreditsRemaining)) + { + creditRows.append(row) + } + if let totalUsableCredits, + let row = try? ProviderDetailSection.Row(label: "Total usable", value: UsageFormatter.usdString(totalUsableCredits)) + { + creditRows.append(row) + } + if let section = try? ProviderDetailSection(title: "Credits", rows: creditRows) { + sections.append(section) + } + return sections + } + + private static let renewalFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateStyle = .medium + formatter.timeStyle = .short + return formatter + }() +} + +public enum NousUsageError: LocalizedError, Sendable, Equatable { + case missingCredentials + case authFileInvalid(String) + case sessionExpired(String) + case unauthorized + case networkError(String) + case apiError(String) + case parseFailed(String) + + public var errorDescription: String? { + switch self { + case .missingCredentials: + "Nous Portal login not found. Run `hermes` and sign in to Nous Portal, or set NOUS_PORTAL_ACCESS_TOKEN." + case let .authFileInvalid(path): + "Hermes auth file at \(path) has no Nous Portal access token. Run `hermes auth add nous` to sign in." + case let .sessionExpired(path): + "Nous Portal access token in \(path) has expired. Run `hermes` so Hermes Agent refreshes it." + case .unauthorized: + "Nous Portal rejected the access token. Run `hermes` to refresh your Hermes Agent login." + case let .networkError(message): + "Nous Portal network error: \(message)" + case let .apiError(message): + "Nous Portal API error: \(message)" + case let .parseFailed(message): + "Failed to parse Nous Portal response: \(message)" + } + } +} + +public struct NousUsageFetcher: Sendable { + private static let log = CodexBarLog.logger(LogCategories.provider(.nous, scope: "usage")) + private static let timeoutSeconds: TimeInterval = 15 + public static let accountPath = "/api/oauth/account" + + public static func fetchAccount( + credential: NousSettingsReader.Credential, + transport: any ProviderHTTPTransport = ProviderHTTPClient.shared, + now: Date = Date()) async throws -> NousAccountSummary + { + var request = URLRequest(url: self.accountURL(portalBaseURL: credential.portalBaseURL)) + request.httpMethod = "GET" + request.setValue("Bearer \(credential.token)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.timeoutInterval = Self.timeoutSeconds + + let response: ProviderHTTPResponse + do { + response = try await transport.response(for: request) + } catch let error as URLError where error.code == .badServerResponse { + throw NousUsageError.networkError("Invalid response") + } catch let error as URLError { + throw NousUsageError.networkError(error.localizedDescription) + } + + switch response.statusCode { + case 200: + return try self.parseAccount(data: response.data, now: now) + case 401: + throw NousUsageError.unauthorized + default: + Self.log.error("Nous Portal account endpoint returned HTTP \(response.statusCode)") + throw NousUsageError.apiError(self.errorMessage(data: response.data) ?? "HTTP \(response.statusCode)") + } + } + + public static func accountURL(portalBaseURL: URL) -> URL { + URL(string: portalBaseURL.absoluteString + self.accountPath) ?? portalBaseURL + } + + static func _parseAccountForTesting(_ data: Data, now: Date = Date()) throws -> NousAccountSummary { + try self.parseAccount(data: data, now: now) + } + + private static func parseAccount(data: Data, now: Date) throws -> NousAccountSummary { + let root: [String: Any] + do { + guard let object = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw NousUsageError.parseFailed("Expected a JSON object") + } + root = object + } catch let error as NousUsageError { + throw error + } catch { + throw NousUsageError.parseFailed(error.localizedDescription) + } + + if let message = root["error"] as? String, !message.isEmpty { + throw NousUsageError.apiError(message) + } + + let user = root["user"] as? [String: Any] ?? [:] + let organisation = root["organisation"] as? [String: Any] ?? [:] + let subscription = root["subscription"] as? [String: Any] + let access = root["paid_service_access"] as? [String: Any] ?? [:] + + guard subscription != nil || root["purchased_credits_remaining"] != nil || !access.isEmpty else { + throw NousUsageError.parseFailed("Response has no subscription or credit fields") + } + + let purchased = self.number(root["purchased_credits_remaining"]) + ?? self.number(access["purchased_credits_remaining"]) + ?? 0 + return NousAccountSummary( + email: NousSettingsReader.cleaned(user["email"] as? String), + organizationName: NousSettingsReader.cleaned(organisation["name"] as? String), + plan: NousSettingsReader.cleaned(subscription?["plan"] as? String), + monthlyCredits: self.number(subscription?["monthly_credits"]) ?? 0, + creditsRemaining: self.number(subscription?["credits_remaining"]) + ?? self.number(access["subscription_credits_remaining"]) + ?? 0, + rolloverCredits: self.number(subscription?["rollover_credits"]) ?? 0, + currentPeriodEnd: (subscription?["current_period_end"] as? String).flatMap(NousSettingsReader.parseISODate), + purchasedCreditsRemaining: purchased, + totalUsableCredits: self.number(access["total_usable_credits"]), + hasActiveSubscription: (access["has_active_subscription"] as? Bool) ?? (subscription != nil), + updatedAt: now) + } + + private static func errorMessage(data: Data) -> String? { + guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil } + return (root["message"] as? String) ?? (root["error"] as? String) + } + + /// Nous emits money as JSON numbers in the account payload and as decimal strings on billing routes. + static func number(_ value: Any?) -> Double? { + switch value { + case let double as Double: double + case let int as Int: Double(int) + case let string as String: Double(string.trimmingCharacters(in: .whitespacesAndNewlines)) + default: nil + } + } +} diff --git a/Sources/CodexBarCore/Providers/ProviderInstanceIDAliases.generated.swift b/Sources/CodexBarCore/Providers/ProviderInstanceIDAliases.generated.swift index 1a6682c057..5e184234bf 100644 --- a/Sources/CodexBarCore/Providers/ProviderInstanceIDAliases.generated.swift +++ b/Sources/CodexBarCore/Providers/ProviderInstanceIDAliases.generated.swift @@ -72,6 +72,7 @@ extension ProviderInstanceID { public static let xai = UsageProvider.xai.instanceID public static let notion = UsageProvider.notion.instanceID public static let ibmbob = UsageProvider.ibmbob.instanceID + public static let nous = UsageProvider.nous.instanceID } // swiftformat:enable sortDeclarations diff --git a/Sources/CodexBarCore/Providers/ProviderManifest.swift b/Sources/CodexBarCore/Providers/ProviderManifest.swift index 47631b6564..59debf684d 100644 --- a/Sources/CodexBarCore/Providers/ProviderManifest.swift +++ b/Sources/CodexBarCore/Providers/ProviderManifest.swift @@ -74,5 +74,6 @@ public enum ProviderManifest { XAIProviderDescriptor.descriptor, NotionProviderDescriptor.descriptor, IBMBobProviderDescriptor.descriptor, + NousProviderDescriptor.descriptor, ] } diff --git a/Sources/CodexBarCore/Providers/Providers.swift b/Sources/CodexBarCore/Providers/Providers.swift index fbdbdea033..5116622a0b 100644 --- a/Sources/CodexBarCore/Providers/Providers.swift +++ b/Sources/CodexBarCore/Providers/Providers.swift @@ -88,6 +88,7 @@ public enum UsageProvider: String, CaseIterable, Sendable, Codable { case xai case notion case ibmbob + case nous } // swiftformat:enable sortDeclarations diff --git a/Tests/CodexBarTests/NousProviderDescriptorTests.swift b/Tests/CodexBarTests/NousProviderDescriptorTests.swift new file mode 100644 index 0000000000..2d05786c59 --- /dev/null +++ b/Tests/CodexBarTests/NousProviderDescriptorTests.swift @@ -0,0 +1,53 @@ +import Foundation +import Testing +@testable import CodexBarCore + +private struct NousStubClaudeFetcher: ClaudeUsageFetching { + struct Unavailable: Error {} + func loadLatestUsage(model _: String) async throws -> ClaudeUsageSnapshot { throw Unavailable() } + func debugRawProbe(model _: String) async -> String { "stub" } + func detectVersion() -> String? { nil } +} + +struct NousProviderDescriptorTests { + @Test + func `descriptor exposes api source and hermes aliases`() { + let descriptor = NousProviderDescriptor.descriptor + #expect(descriptor.id == .nous) + #expect(descriptor.metadata.supportsCredits) + #expect(descriptor.fetchPlan.sourceModes == [.auto, .api]) + #expect(descriptor.cli.aliases == ["nous-portal", "hermes"]) + #expect(descriptor.metadata.dashboardURL == "https://portal.nousresearch.com/usage") + } + + @Test + func `credential adapter resolves environment token without config`() { + let credentials = NousProviderDescriptor.descriptor.credentials + let resolution = credentials?.resolveToken(environment: [ + "HOME": "/nonexistent", + "NOUS_PORTAL_ACCESS_TOKEN": "env-token", + ]) + #expect(resolution?.token == "env-token") + #expect(resolution?.source == .environment) + #expect(credentials?.resolveToken(environment: ["HOME": "/nonexistent"]) == nil) + #expect(credentials?.unavailableMessage(environment: ["HOME": "/nonexistent"])?.contains("hermes") == true) + } + + @Test + func `strategy is unavailable without a credential`() async { + let strategy = NousAPIFetchStrategy() + let context = ProviderFetchContext( + runtime: .cli, + sourceMode: .api, + includeCredits: true, + webTimeout: 5, + webDebugDumpHTML: false, + verbose: false, + env: ["HOME": "/nonexistent"], + settings: nil, + fetcher: UsageFetcher(environment: ["HOME": "/nonexistent"]), + claudeFetcher: NousStubClaudeFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0)) + #expect(await strategy.isAvailable(context) == false) + } +} diff --git a/Tests/CodexBarTests/NousSettingsReaderTests.swift b/Tests/CodexBarTests/NousSettingsReaderTests.swift new file mode 100644 index 0000000000..a203b71a6b --- /dev/null +++ b/Tests/CodexBarTests/NousSettingsReaderTests.swift @@ -0,0 +1,164 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct NousSettingsReaderTests { + private static func makeHome() throws -> URL { + let home = FileManager.default.temporaryDirectory + .appendingPathComponent("nous-settings-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory( + at: home.appendingPathComponent(".hermes/shared", isDirectory: true), + withIntermediateDirectories: true) + return home + } + + private static func write(_ json: String, to url: URL) throws { + try Data(json.utf8).write(to: url) + } + + private static func authJSON(token: String, expiresAt: String, portal: String = "https://portal.nousresearch.com") -> String { + """ + { + "version": 1, + "providers": { + "nous": { + "access_token": "\(token)", + "refresh_token": "rt", + "portal_base_url": "\(portal)", + "expires_at": "\(expiresAt)" + } + } + } + """ + } + + @Test + func `environment token overrides hermes auth file`() throws { + let home = try Self.makeHome() + try Self.write( + Self.authJSON(token: "file-token", expiresAt: "2999-01-01T00:00:00+00:00"), + to: home.appendingPathComponent(".hermes/auth.json")) + let credential = try NousSettingsReader.resolveCredential(environment: [ + "HOME": home.path, + "NOUS_PORTAL_ACCESS_TOKEN": "env-token", + "NOUS_PORTAL_BASE_URL": "https://preview.portal.example.com/", + ]) + #expect(credential.token == "env-token") + #expect(credential.source == .environment) + #expect(credential.portalBaseURL.absoluteString == "https://preview.portal.example.com") + } + + @Test + func `reads providers section from hermes auth file`() throws { + let home = try Self.makeHome() + let path = home.appendingPathComponent(".hermes/auth.json") + try Self.write(Self.authJSON(token: "file-token", expiresAt: "2999-01-01T00:00:00+00:00"), to: path) + + let credential = try NousSettingsReader.resolveCredential(environment: ["HOME": home.path]) + #expect(credential.token == "file-token") + #expect(credential.source == .authFile(path.path)) + #expect(credential.portalBaseURL == NousSettingsReader.defaultPortalBaseURL) + #expect(credential.expiresAt == NousSettingsReader.parseISODate("2999-01-01T00:00:00+00:00")) + } + + @Test + func `HERMES_HOME override takes precedence over default home`() throws { + let home = try Self.makeHome() + let custom = home.appendingPathComponent("custom-hermes", isDirectory: true) + try FileManager.default.createDirectory(at: custom, withIntermediateDirectories: true) + try Self.write( + Self.authJSON(token: "default-token", expiresAt: "2999-01-01T00:00:00+00:00"), + to: home.appendingPathComponent(".hermes/auth.json")) + try Self.write( + Self.authJSON(token: "custom-token", expiresAt: "2999-01-01T00:00:00+00:00"), + to: custom.appendingPathComponent("auth.json")) + + let credential = try NousSettingsReader.resolveCredential(environment: [ + "HOME": home.path, + "HERMES_HOME": custom.path, + ]) + #expect(credential.token == "custom-token") + } + + @Test + func `falls back to shared store when profile token is expired`() throws { + let home = try Self.makeHome() + try Self.write( + Self.authJSON(token: "stale", expiresAt: "2000-01-01T00:00:00+00:00"), + to: home.appendingPathComponent(".hermes/auth.json")) + try Self.write( + """ + { "_schema": 1, "access_token": "shared-token", "expires_at": "2999-01-01T00:00:00+00:00" } + """, + to: home.appendingPathComponent(".hermes/shared/nous_auth.json")) + + let credential = try NousSettingsReader.resolveCredential(environment: ["HOME": home.path]) + #expect(credential.token == "shared-token") + } + + @Test + func `expired token reports session expired instead of missing`() throws { + let home = try Self.makeHome() + let path = home.appendingPathComponent(".hermes/auth.json") + try Self.write(Self.authJSON(token: "stale", expiresAt: "2000-01-01T00:00:00+00:00"), to: path) + + #expect(NousSettingsReader.credential(environment: ["HOME": home.path]) == nil) + #expect { + _ = try NousSettingsReader.resolveCredential(environment: ["HOME": home.path]) + } throws: { error in + error as? NousUsageError == .sessionExpired(path.path) + } + #expect(NousSettingsReader.unavailableMessage(environment: ["HOME": home.path])?.contains("expired") == true) + } + + @Test + func `missing auth file reports missing credentials`() throws { + let home = try Self.makeHome() + #expect { + _ = try NousSettingsReader.resolveCredential(environment: ["HOME": home.path]) + } throws: { error in + error as? NousUsageError == .missingCredentials + } + } + + @Test + func `credential pool entries are accepted when providers section is absent`() throws { + let data = Data(""" + { + "credential_pool": { + "nous": [ + { "id": "a", "auth_type": "oauth", "access_token": "pool-token", "expires_at": "2999-01-01T00:00:00+00:00" } + ] + } + } + """.utf8) + let stored = try #require(NousSettingsReader.parseAuthFile(data: data)) + #expect(stored.token == "pool-token") + #expect(stored.portalBaseURL == nil) + } + + @Test + func `jwt exp claim is used when no expiry is stored`() { + let header = Data("{\"alg\":\"none\"}".utf8).base64EncodedString() + let payload = Data("{\"exp\": 946684800}".utf8).base64EncodedString() + .replacingOccurrences(of: "=", with: "") + let token = "\(header).\(payload).sig" + #expect(NousSettingsReader.jwtExpiry(token) == Date(timeIntervalSince1970: 946_684_800)) + + let credential = NousSettingsReader.Credential( + token: token, + portalBaseURL: NousSettingsReader.defaultPortalBaseURL, + expiresAt: NousSettingsReader.jwtExpiry(token), + source: .environment) + #expect(credential.isExpired(now: Date(timeIntervalSince1970: 946_684_800 + 10))) + #expect(!credential.isExpired(now: Date(timeIntervalSince1970: 946_684_800 - 600))) + } + + @Test + func `portal base URL rejects non-https overrides`() { + #expect(NousSettingsReader.portalBaseURL(environment: ["NOUS_PORTAL_BASE_URL": "http://evil.example"], stored: nil) + == NousSettingsReader.defaultPortalBaseURL) + #expect(NousSettingsReader.portalBaseURL(environment: [:], stored: "https://stored.example/") + .absoluteString == "https://stored.example") + } +} diff --git a/Tests/CodexBarTests/NousUsageFetcherTests.swift b/Tests/CodexBarTests/NousUsageFetcherTests.swift new file mode 100644 index 0000000000..d7934fb30f --- /dev/null +++ b/Tests/CodexBarTests/NousUsageFetcherTests.swift @@ -0,0 +1,141 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct NousUsageFetcherTests { + static let accountJSON = """ + { + "user": { "email": "dev@example.com", "privy_did": "did:privy:abc" }, + "organisation": { "id": "nas_organisation:1", "slug": "4314949a", "name": "dev's account" }, + "subscription": { + "plan": "Ultra", + "tier": 9, + "monthly_charge": 200, + "monthly_credits": 220, + "current_period_end": "2026-09-12T04:29:00.000Z", + "credits_remaining": 55, + "rollover_credits": 0 + }, + "purchased_credits_remaining": 19.3462440630144, + "tool_access": { "enabled": false, "coverage": { "fal": true } }, + "paid_service_access": { + "allowed": true, + "paid_access": true, + "reason": "usable_credits", + "has_active_subscription": true, + "subscription_credits_remaining": 55, + "purchased_credits_remaining": 19.3462440630144, + "total_usable_credits": 74.3462440630144 + } + } + """ + + @Test + func `parses account payload into usage and credits`() throws { + let now = Date(timeIntervalSince1970: 1_788_300_000) + let account = try NousUsageFetcher._parseAccountForTesting(Data(Self.accountJSON.utf8), now: now) + + #expect(account.email == "dev@example.com") + #expect(account.organizationName == "dev's account") + #expect(account.plan == "Ultra") + #expect(account.monthlyCredits == 220) + #expect(account.creditsRemaining == 55) + #expect(account.purchasedCreditsRemaining == 19.3462440630144) + #expect(account.totalUsableCredits == 74.3462440630144) + #expect(account.hasActiveSubscription) + #expect(account.currentPeriodEnd == NousSettingsReader.parseISODate("2026-09-12T04:29:00.000Z")) + + let usage = account.toUsageSnapshot() + let primary = try #require(usage.primary) + #expect(abs(primary.usedPercent - 75) < 0.0001) + #expect(primary.resetsAt == account.currentPeriodEnd) + #expect(usage.secondary == nil) + #expect(usage.subscriptionRenewsAt == account.currentPeriodEnd) + #expect(usage.loginMethod(for: .nous) == "Ultra") + #expect(usage.identity(for: .nous)?.accountEmail == "dev@example.com") + #expect(usage.dataConfidence == .exact) + #expect(usage.details.map(\.title) == ["Subscription", "Credits"]) + #expect(usage.details[0].rows.map(\.label) == ["Monthly credits", "Renews"]) + #expect(usage.details[0].rows[0].value == "$55.00 of $220.00 left") + + let credits = account.toCreditsSnapshot() + #expect(credits.remaining == 19.3462440630144) + #expect(credits.updatedAt == now) + } + + @Test + func `exhausted monthly grant reports fully used window`() throws { + let json = Self.accountJSON.replacingOccurrences(of: "\"credits_remaining\": 55", with: "\"credits_remaining\": 0") + let account = try NousUsageFetcher._parseAccountForTesting(Data(json.utf8)) + #expect(account.toUsageSnapshot().primary?.usedPercent == 100) + } + + @Test + func `free tier without monthly credits has no rate window`() throws { + let json = """ + { + "user": { "email": "free@example.com" }, + "organisation": { "name": "free's account" }, + "subscription": { + "plan": "Free", + "monthly_credits": 0, + "credits_remaining": 0, + "rollover_credits": 0, + "current_period_end": null + }, + "purchased_credits_remaining": 2.5, + "paid_service_access": { "has_active_subscription": false, "total_usable_credits": 2.5 } + } + """ + let account = try NousUsageFetcher._parseAccountForTesting(Data(json.utf8)) + let usage = account.toUsageSnapshot() + #expect(usage.primary == nil) + #expect(usage.loginMethod(for: .nous) == "Free") + #expect(account.toCreditsSnapshot().remaining == 2.5) + #expect(usage.details.map(\.title) == ["Credits"]) + } + + @Test + func `accepts decimal strings for money fields`() throws { + let json = """ + { + "subscription": { "plan": "Plus", "monthly_credits": "22", "credits_remaining": "11", "rollover_credits": "1.5" }, + "purchased_credits_remaining": "3.25" + } + """ + let account = try NousUsageFetcher._parseAccountForTesting(Data(json.utf8)) + #expect(account.monthlyCredits == 22) + #expect(account.creditsRemaining == 11) + #expect(account.rolloverCredits == 1.5) + #expect(account.purchasedCreditsRemaining == 3.25) + #expect(account.toUsageSnapshot().primary?.usedPercent == 50) + } + + @Test + func `error payload surfaces as api error`() { + let json = """ + { "error": "account_missing" } + """ + #expect { + _ = try NousUsageFetcher._parseAccountForTesting(Data(json.utf8)) + } throws: { error in + error as? NousUsageError == .apiError("account_missing") + } + } + + @Test + func `non-object payload is a parse failure`() { + #expect { + _ = try NousUsageFetcher._parseAccountForTesting(Data("[1, 2]".utf8)) + } throws: { error in + if case NousUsageError.parseFailed = error { return true } + return false + } + } + + @Test + func `account URL appends the oauth account path`() { + let url = NousUsageFetcher.accountURL(portalBaseURL: URL(string: "https://portal.example.com")!) + #expect(url.absoluteString == "https://portal.example.com/api/oauth/account") + } +} diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index 0b0bfdefde..c8c1bea6b6 100644 --- a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift +++ b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift @@ -134,6 +134,7 @@ struct ProviderArchitectureGatekeeperTests { .bedrock: "Bedrock", .jetbrains: "JetBrains", .moonshot: "Moonshot", + .nous: "Nous", ] for descriptor in ProviderDescriptorRegistry.all { let expected = overrides[descriptor.id] ?? descriptor.metadata.displayName @@ -154,8 +155,8 @@ struct ProviderArchitectureGatekeeperTests { Self.hash(descriptor.branding.burnDownWidgetColor, into: &burnDownFingerprint) } - #expect(widgetFingerprint == 16_873_014_858_015_536_126) - #expect(burnDownFingerprint == 8_686_456_525_451_224_704) + #expect(widgetFingerprint == 6_809_063_519_350_150_547) + #expect(burnDownFingerprint == 743_996_565_557_836_219) } @Test diff --git a/docs/nous.md b/docs/nous.md new file mode 100644 index 0000000000..5038ef2562 --- /dev/null +++ b/docs/nous.md @@ -0,0 +1,60 @@ +--- +summary: "Nous Portal provider: Hermes Agent OAuth token reuse, account endpoint parsing, and credit display." +read_when: + - Debugging Nous Portal credit or subscription parsing + - Explaining why CodexBar asks to run `hermes` to refresh the token + - Updating Nous Portal setup or environment variables +--- + +# Nous Portal Provider + +[Nous Portal](https://portal.nousresearch.com) is Nous Research's subscription and credit portal for the Hermes +inference API. Plans grant a monthly credit budget that resets each billing cycle; purchased credits top up the +balance on top of that grant. + +## Authentication + +Nous Portal only exposes its account and billing endpoints to the OAuth access token minted by the Hermes Agent +device-code login. CodexBar does not run its own login and does not store any Nous secret: + +1. Sign in once with Hermes Agent (`hermes` and choose Nous Portal, or `hermes auth add nous`). +2. Hermes writes the token to `~/.hermes/auth.json` (and a cross-profile copy to `~/.hermes/shared/nous_auth.json`). +3. CodexBar reads the access token from those files on every refresh. + +Overrides: + +- `HERMES_HOME`: directory holding `auth.json` when Hermes runs from a custom root or profile. +- `NOUS_PORTAL_ACCESS_TOKEN`: use this token instead of the Hermes files. +- `NOUS_PORTAL_BASE_URL` / `HERMES_PORTAL_BASE_URL`: point at a preview portal deployment (HTTPS only). + +### Why CodexBar never refreshes the token + +Nous access tokens live for about an hour. The refresh token is single-use: the portal rotates it on every refresh and +revokes the entire session when it sees an old one replayed. A second client refreshing behind Hermes's back would +therefore log Hermes out. CodexBar only reads the current access token and, once it has expired, shows +"run `hermes` so Hermes Agent refreshes it". Any Hermes command (or a running Hermes gateway) renews the token. + +## Data Source + +One request per refresh: `GET {portal}/api/oauth/account` with the bearer token. + +| Field | Display | +| --- | --- | +| `subscription.monthly_credits`, `subscription.credits_remaining` | Primary meter "Monthly credits" as percent used | +| `subscription.current_period_end` | Meter reset time and renewal date | +| `subscription.plan` | Plan row | +| `subscription.rollover_credits` | Subscription detail row when non-zero | +| `purchased_credits_remaining` | Credits balance | +| `paid_service_access.total_usable_credits` | Credits detail row | +| `user.email`, `organisation.name` | Identity (siloed to this provider) | + +Money fields are accepted both as JSON numbers and as decimal strings. A Free tier with no monthly grant shows no +meter and only the purchased balance. + +## CLI + +```bash +codexbar usage --provider nous +``` + +Aliases: `nous-portal`, `hermes`. Source modes: `auto`, `api`. diff --git a/docs/provider-ids.md b/docs/provider-ids.md index 214a88e646..0e59e507a4 100644 --- a/docs/provider-ids.md +++ b/docs/provider-ids.md @@ -2,4 +2,4 @@ # Provider IDs -`codex`, `openai`, `azureopenai`, `claude`, `clinepass`, `cursor`, `opencode`, `opencodego`, `alibaba`, `alibabatokenplan`, `qwencloud`, `factory`, `fireworks`, `gemini`, `antigravity`, `copilot`, `devin`, `zai`, `minimax`, `manus`, `kimi`, `kilo`, `kiro`, `vertexai`, `augment`, `jetbrains`, `moonshot`, `amp`, `t3chat`, `ollama`, `synthetic`, `openrouter`, `elevenlabs`, `warp`, `windsurf`, `zed`, `perplexity`, `mimo`, `doubao`, `sakana`, `abacus`, `mistral`, `deepseek`, `deepinfra`, `codebuff`, `crof`, `venice`, `commandcode`, `qoder`, `stepfun`, `bedrock`, `grok`, `groq`, `llmproxy`, `litellm`, `deepgram`, `poe`, `chutes`, `neuralwatt`, `clawrouter`, `longcat`, `sub2api`, `wayfinder`, `zenmux`, `aiand`, `zoommate`, `xai`, `notion`, `ibmbob`. +`codex`, `openai`, `azureopenai`, `claude`, `clinepass`, `cursor`, `opencode`, `opencodego`, `alibaba`, `alibabatokenplan`, `qwencloud`, `factory`, `fireworks`, `gemini`, `antigravity`, `copilot`, `devin`, `zai`, `minimax`, `manus`, `kimi`, `kilo`, `kiro`, `vertexai`, `augment`, `jetbrains`, `moonshot`, `amp`, `t3chat`, `ollama`, `synthetic`, `openrouter`, `elevenlabs`, `warp`, `windsurf`, `zed`, `perplexity`, `mimo`, `doubao`, `sakana`, `abacus`, `mistral`, `deepseek`, `deepinfra`, `codebuff`, `crof`, `venice`, `commandcode`, `qoder`, `stepfun`, `bedrock`, `grok`, `groq`, `llmproxy`, `litellm`, `deepgram`, `poe`, `chutes`, `neuralwatt`, `clawrouter`, `longcat`, `sub2api`, `wayfinder`, `zenmux`, `aiand`, `zoommate`, `xai`, `notion`, `ibmbob`, `nous`. diff --git a/docs/providers.md b/docs/providers.md index 3956c6fb4d..2d356846db 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -8,7 +8,7 @@ read_when: # Providers -CodexBar currently registers 69 provider IDs. Some companies expose multiple surfaces, such as Codex vs OpenAI API or +CodexBar currently registers 70 provider IDs. Some companies expose multiple surfaces, such as Codex vs OpenAI API or OpenCode vs OpenCode Go, because the auth source and quota shape differ. ## Fetch strategies (current) @@ -98,6 +98,7 @@ complete when the available scan window covers fewer days. | Fireworks | API key + account slug → 30-day spend from the billing summary API (`api`). | | DeepInfra | API key from env or token accounts → billing checklist + monthly usage endpoints (`api`). | | Moonshot | API key from config/env → balance endpoint (`api`). | +| Nous Portal | Hermes Agent OAuth access token from `~/.hermes/auth.json` (or env) → account/billing API (`api`). | | Codebuff | API token from config/env or `codebuff login` credentials → usage API (`api`). | | Crof | API key from config/env → credit balance + optional request quota API (`api`). | | Venice | API key from config/env → DIEM/USD balance API (`api`). | @@ -483,6 +484,17 @@ provider-specific cookie validation, endpoints, login detection, and error trans - Status: none yet. - Details: `docs/moonshot.md`. + +## Nous Portal +- Reuses the OAuth access token Hermes Agent stores in `~/.hermes/auth.json` (falls back to `~/.hermes/shared/nous_auth.json`; + `HERMES_HOME` overrides the directory). `NOUS_PORTAL_ACCESS_TOKEN` supplies a token directly. +- CodexBar never calls the refresh endpoint: Nous refresh tokens are single-use and reuse revokes the Hermes session. + When the stored token expires, the card asks you to run `hermes` so Hermes refreshes it. +- Reads `GET /api/oauth/account`: monthly subscription credits used/remaining with the cycle reset date, plan name, + and the purchased credit balance (shown as credits). +- Override the portal host with `NOUS_PORTAL_BASE_URL` (HTTPS only). +- Details: `docs/nous.md`. + ## Venice - API key via `VENICE_API_KEY` / `VENICE_KEY` env var or Venice token accounts. - Shows current DIEM or USD balance; DIEM epoch allocation progress when available. From cfd451d7de73cffdc95574e4c95b80d4d376c52a Mon Sep 17 00:00:00 2001 From: Asis Panda Date: Wed, 2 Sep 2026 10:47:43 +0530 Subject: [PATCH 2/6] Nous Portal: pin token destination, reject expired env tokens, show top-up - Only send the Hermes bearer token to nousresearch.com hosts (or an explicit NOUS_PORTAL_BASE_URL override). An untrusted stored portal_base_url is ignored, logged, and reported as rejectedStoredHost in the verbose trace. - Reject an expired NOUS_PORTAL_ACCESS_TOKEN before any request, and keep the strategy available so the fetch reports the specific expiry reason. - Always attach the purchased (top-up) balance as credits and show it in the plan row; label detail rows to match the portal billing page. Co-Authored-By: Claude Fable 5.1 --- .../Nous/NousProviderDescriptor.swift | 22 ++++++- .../Providers/Nous/NousSettingsReader.swift | 61 ++++++++++++++--- .../Providers/Nous/NousUsageFetcher.swift | 22 +++++-- .../NousProviderDescriptorTests.swift | 24 +++++++ .../NousSettingsReaderTests.swift | 66 +++++++++++++++++-- .../CodexBarTests/NousUsageFetcherTests.swift | 7 +- docs/nous.md | 22 ++++++- docs/providers.md | 3 +- 8 files changed, 201 insertions(+), 26 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Nous/NousProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Nous/NousProviderDescriptor.swift index b737b4f710..6a4523ebb9 100644 --- a/Sources/CodexBarCore/Providers/Nous/NousProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Nous/NousProviderDescriptor.swift @@ -77,17 +77,33 @@ struct NousAPIFetchStrategy: ProviderFetchStrategy { self.transport = transport } + /// Available whenever some Nous credential exists, even an expired one, so the fetch surfaces the specific + /// expiry or trust error instead of a bare "unavailable". func isAvailable(_ context: ProviderFetchContext) async -> Bool { - NousSettingsReader.credential(environment: context.env) != nil + do { + _ = try NousSettingsReader.resolveCredential(environment: context.env) + return true + } catch NousUsageError.missingCredentials { + return false + } catch { + return true + } } func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + // Credits ride along in the same account response, so attach them regardless of `includeCredits` + // (the app only sets that flag for Codex's separate credits request). let credential = try NousSettingsReader.resolveCredential(environment: context.env) let account = try await NousUsageFetcher.fetchAccount(credential: credential, transport: self.transport) + var diagnostic = "portal=\(credential.portalBaseURL.host ?? "?") credential=\(credential.source.label)" + if let rejected = credential.rejectedPortalHost { + diagnostic += " rejectedStoredHost=\(rejected)" + } return self.makeResult( usage: account.toUsageSnapshot(), - credits: context.includeCredits ? account.toCreditsSnapshot() : nil, - sourceLabel: "api") + credits: account.toCreditsSnapshot(), + sourceLabel: "api", + diagnostic: diagnostic) } func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { diff --git a/Sources/CodexBarCore/Providers/Nous/NousSettingsReader.swift b/Sources/CodexBarCore/Providers/Nous/NousSettingsReader.swift index 4ed9e08e51..16e6e03795 100644 --- a/Sources/CodexBarCore/Providers/Nous/NousSettingsReader.swift +++ b/Sources/CodexBarCore/Providers/Nous/NousSettingsReader.swift @@ -11,6 +11,9 @@ public enum NousSettingsReader: Sendable { public static let portalBaseURLEnvironmentKeys = ["NOUS_PORTAL_BASE_URL", "HERMES_PORTAL_BASE_URL"] public static let hermesHomeEnvironmentKey = "HERMES_HOME" public static let defaultPortalBaseURL = URL(string: "https://portal.nousresearch.com")! + /// Hosts a Hermes auth file may point the bearer token at. Anything else falls back to the default portal so a + /// tampered or stale `portal_base_url` can never redirect the credential to a third party. + public static let trustedPortalHostSuffix = "nousresearch.com" /// Tokens closer to expiry than this are treated as expired so a fetch never races the portal clock. public static let expirySkew: TimeInterval = 60 @@ -31,12 +34,21 @@ public enum NousSettingsReader: Sendable { public let portalBaseURL: URL public let expiresAt: Date? public let source: CredentialSource + /// Stored `portal_base_url` that failed the trusted-host policy, kept only for diagnostics. + public let rejectedPortalHost: String? - public init(token: String, portalBaseURL: URL, expiresAt: Date?, source: CredentialSource) { + public init( + token: String, + portalBaseURL: URL, + expiresAt: Date?, + source: CredentialSource, + rejectedPortalHost: String? = nil) + { self.token = token self.portalBaseURL = portalBaseURL self.expiresAt = expiresAt self.source = source + self.rejectedPortalHost = rejectedPortalHost } public func isExpired(now: Date = Date(), skew: TimeInterval = NousSettingsReader.expirySkew) -> Bool { @@ -59,11 +71,15 @@ public enum NousSettingsReader: Sendable { now: Date = Date()) throws -> Credential { if let token = self.cleaned(environment[self.accessTokenEnvironmentKey]) { - return Credential( + let credential = Credential( token: token, - portalBaseURL: self.portalBaseURL(environment: environment, stored: nil), + portalBaseURL: self.portalBaseURL(environment: environment, stored: nil).url, expiresAt: self.jwtExpiry(token), source: .environment) + if credential.isExpired(now: now) { + throw NousUsageError.environmentTokenExpired + } + return credential } var expired: Credential? @@ -74,11 +90,13 @@ public enum NousSettingsReader: Sendable { guard let data = try? Data(contentsOf: url), let stored = self.parseAuthFile(data: data) else { continue } + let resolvedPortal = self.portalBaseURL(environment: environment, stored: stored.portalBaseURL) let credential = Credential( token: stored.token, - portalBaseURL: self.portalBaseURL(environment: environment, stored: stored.portalBaseURL), + portalBaseURL: resolvedPortal.url, expiresAt: stored.expiresAt ?? self.jwtExpiry(stored.token), - source: .authFile(url.path)) + source: .authFile(url.path), + rejectedPortalHost: resolvedPortal.rejectedStoredHost) if credential.isExpired(now: now) { expired = expired ?? credential continue @@ -103,16 +121,41 @@ public enum NousSettingsReader: Sendable { } } - public static func portalBaseURL(environment: [String: String], stored: String?) -> URL { + public struct PortalResolution: Sendable, Equatable { + public let url: URL + public let origin: PortalOrigin + /// Host from a stored `portal_base_url` that was refused by the trusted-host policy. + public let rejectedStoredHost: String? + } + + public enum PortalOrigin: String, Sendable { + case environmentOverride + case storedTrusted + case `default` + } + + /// Resolves the portal origin the bearer token will be sent to. + /// + /// Precedence: explicit environment override (HTTPS, operator-controlled) → stored auth-file value when its host + /// is `nousresearch.com` or a subdomain → the default portal. A stored host outside that policy is never used. + public static func portalBaseURL(environment: [String: String], stored: String?) -> PortalResolution { for key in self.portalBaseURLEnvironmentKeys { if let raw = self.cleaned(environment[key]), let url = self.normalizedHTTPSURL(raw) { - return url + return PortalResolution(url: url, origin: .environmentOverride, rejectedStoredHost: nil) } } if let stored, let url = self.normalizedHTTPSURL(stored) { - return url + if self.isTrustedPortalHost(url.host) { + return PortalResolution(url: url, origin: .storedTrusted, rejectedStoredHost: nil) + } + return PortalResolution(url: self.defaultPortalBaseURL, origin: .default, rejectedStoredHost: url.host) } - return self.defaultPortalBaseURL + return PortalResolution(url: self.defaultPortalBaseURL, origin: .default, rejectedStoredHost: nil) + } + + public static func isTrustedPortalHost(_ host: String?) -> Bool { + guard let host = host?.lowercased(), !host.isEmpty else { return false } + return host == self.trustedPortalHostSuffix || host.hasSuffix("." + self.trustedPortalHostSuffix) } // MARK: - Hermes auth store diff --git a/Sources/CodexBarCore/Providers/Nous/NousUsageFetcher.swift b/Sources/CodexBarCore/Providers/Nous/NousUsageFetcher.swift index b2d986e1ba..dad3347c73 100644 --- a/Sources/CodexBarCore/Providers/Nous/NousUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Nous/NousUsageFetcher.swift @@ -44,6 +44,12 @@ public struct NousAccountSummary: Sendable, Equatable { self.updatedAt = updatedAt } + /// Plan row text: plan name plus the top-up balance, e.g. "Ultra · Top-up $19.35". + public var planRowText: String { + let plan = self.plan ?? (self.hasActiveSubscription ? "Subscription" : "Free") + return "\(plan) · Top-up \(UsageFormatter.usdString(max(0, self.purchasedCreditsRemaining)))" + } + /// Monthly subscription credits consumed this cycle, as a percentage of the monthly grant. public var monthlyUsedPercent: Double? { guard self.monthlyCredits > 0 else { return nil } @@ -63,7 +69,7 @@ public struct NousAccountSummary: Sendable, Equatable { providerID: .nous, accountEmail: self.email, accountOrganization: self.organizationName, - loginMethod: self.plan ?? (self.hasActiveSubscription ? nil : "Free")) + loginMethod: self.planRowText) return UsageSnapshot( primary: primary, secondary: nil, @@ -84,7 +90,7 @@ public struct NousAccountSummary: Sendable, Equatable { if self.monthlyCredits > 0 { let remaining = UsageFormatter.usdString(max(0, self.creditsRemaining)) let monthly = UsageFormatter.usdString(self.monthlyCredits) - if let row = try? ProviderDetailSection.Row(label: "Monthly credits", value: "\(remaining) of \(monthly) left") { + if let row = try? ProviderDetailSection.Row(label: "Subscription credits", value: "\(remaining) of \(monthly) left") { subscriptionRows.append(row) } } @@ -108,7 +114,7 @@ public struct NousAccountSummary: Sendable, Equatable { var creditRows: [ProviderDetailSection.Row] = [] if let row = try? ProviderDetailSection.Row( - label: "Purchased balance", + label: "Top-up credits", value: UsageFormatter.usdString(self.purchasedCreditsRemaining)) { creditRows.append(row) @@ -137,6 +143,7 @@ public enum NousUsageError: LocalizedError, Sendable, Equatable { case missingCredentials case authFileInvalid(String) case sessionExpired(String) + case environmentTokenExpired case unauthorized case networkError(String) case apiError(String) @@ -150,6 +157,8 @@ public enum NousUsageError: LocalizedError, Sendable, Equatable { "Hermes auth file at \(path) has no Nous Portal access token. Run `hermes auth add nous` to sign in." case let .sessionExpired(path): "Nous Portal access token in \(path) has expired. Run `hermes` so Hermes Agent refreshes it." + case .environmentTokenExpired: + "NOUS_PORTAL_ACCESS_TOKEN has expired. Export a fresh token or unset it to use the Hermes Agent login." case .unauthorized: "Nous Portal rejected the access token. Run `hermes` to refresh your Hermes Agent login." case let .networkError(message): @@ -172,7 +181,12 @@ public struct NousUsageFetcher: Sendable { transport: any ProviderHTTPTransport = ProviderHTTPClient.shared, now: Date = Date()) async throws -> NousAccountSummary { - var request = URLRequest(url: self.accountURL(portalBaseURL: credential.portalBaseURL)) + let url = self.accountURL(portalBaseURL: credential.portalBaseURL) + Self.log.info("Nous Portal account request → \(url.host ?? "?") (source: \(credential.source.label))") + if let rejected = credential.rejectedPortalHost { + Self.log.warning("Ignored untrusted stored portal_base_url host \(rejected); using \(url.host ?? "?")") + } + var request = URLRequest(url: url) request.httpMethod = "GET" request.setValue("Bearer \(credential.token)", forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "Accept") diff --git a/Tests/CodexBarTests/NousProviderDescriptorTests.swift b/Tests/CodexBarTests/NousProviderDescriptorTests.swift index 2d05786c59..e2cc9c4e69 100644 --- a/Tests/CodexBarTests/NousProviderDescriptorTests.swift +++ b/Tests/CodexBarTests/NousProviderDescriptorTests.swift @@ -50,4 +50,28 @@ struct NousProviderDescriptorTests { browserDetection: BrowserDetection(cacheTTL: 0)) #expect(await strategy.isAvailable(context) == false) } + + @Test + func `strategy stays available with an expired environment token so the fetch reports why`() async { + let header = Data("{\"alg\":\"none\"}".utf8).base64EncodedString() + let payload = Data("{\"exp\": 946684800}".utf8).base64EncodedString().replacingOccurrences(of: "=", with: "") + let env = ["HOME": "/nonexistent", "NOUS_PORTAL_ACCESS_TOKEN": "\(header).\(payload).sig"] + let strategy = NousAPIFetchStrategy() + let context = ProviderFetchContext( + runtime: .cli, + sourceMode: .api, + includeCredits: false, + webTimeout: 5, + webDebugDumpHTML: false, + verbose: false, + env: env, + settings: nil, + fetcher: UsageFetcher(environment: env), + claudeFetcher: NousStubClaudeFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0)) + #expect(await strategy.isAvailable(context)) + await #expect(throws: NousUsageError.environmentTokenExpired) { + _ = try await strategy.fetch(context) + } + } } diff --git a/Tests/CodexBarTests/NousSettingsReaderTests.swift b/Tests/CodexBarTests/NousSettingsReaderTests.swift index a203b71a6b..f1e7b506ca 100644 --- a/Tests/CodexBarTests/NousSettingsReaderTests.swift +++ b/Tests/CodexBarTests/NousSettingsReaderTests.swift @@ -46,6 +46,7 @@ struct NousSettingsReaderTests { #expect(credential.token == "env-token") #expect(credential.source == .environment) #expect(credential.portalBaseURL.absoluteString == "https://preview.portal.example.com") + #expect(credential.rejectedPortalHost == nil) } @Test @@ -156,9 +157,66 @@ struct NousSettingsReaderTests { @Test func `portal base URL rejects non-https overrides`() { - #expect(NousSettingsReader.portalBaseURL(environment: ["NOUS_PORTAL_BASE_URL": "http://evil.example"], stored: nil) - == NousSettingsReader.defaultPortalBaseURL) - #expect(NousSettingsReader.portalBaseURL(environment: [:], stored: "https://stored.example/") - .absoluteString == "https://stored.example") + let resolution = NousSettingsReader.portalBaseURL( + environment: ["NOUS_PORTAL_BASE_URL": "http://evil.example"], + stored: nil) + #expect(resolution.url == NousSettingsReader.defaultPortalBaseURL) + #expect(resolution.origin == .default) + } + + @Test + func `stored portal host outside nousresearch.com is rejected and never receives the token`() throws { + let untrusted = NousSettingsReader.portalBaseURL(environment: [:], stored: "https://stored.example/") + #expect(untrusted.url == NousSettingsReader.defaultPortalBaseURL) + #expect(untrusted.origin == .default) + #expect(untrusted.rejectedStoredHost == "stored.example") + + let lookalike = NousSettingsReader.portalBaseURL(environment: [:], stored: "https://nousresearch.com.evil.example") + #expect(lookalike.url == NousSettingsReader.defaultPortalBaseURL) + #expect(lookalike.rejectedStoredHost == "nousresearch.com.evil.example") + + let home = try Self.makeHome() + let path = home.appendingPathComponent(".hermes/auth.json") + try Self.write( + Self.authJSON(token: "file-token", expiresAt: "2999-01-01T00:00:00+00:00", portal: "https://stored.example"), + to: path) + let credential = try NousSettingsReader.resolveCredential(environment: ["HOME": home.path]) + #expect(credential.portalBaseURL == NousSettingsReader.defaultPortalBaseURL) + #expect(credential.rejectedPortalHost == "stored.example") + #expect(NousUsageFetcher.accountURL(portalBaseURL: credential.portalBaseURL).host == "portal.nousresearch.com") + } + + @Test + func `stored nousresearch.com preview hosts are trusted`() { + let preview = NousSettingsReader.portalBaseURL(environment: [:], stored: "https://preview.portal.nousresearch.com/") + #expect(preview.url.absoluteString == "https://preview.portal.nousresearch.com") + #expect(preview.origin == .storedTrusted) + #expect(preview.rejectedStoredHost == nil) + #expect(NousSettingsReader.isTrustedPortalHost("portal.nousresearch.com")) + #expect(NousSettingsReader.isTrustedPortalHost("NousResearch.com")) + #expect(!NousSettingsReader.isTrustedPortalHost("evilnousresearch.com")) + #expect(!NousSettingsReader.isTrustedPortalHost(nil)) + } + + @Test + func `environment override remains explicit and takes precedence over a stored host`() { + let resolution = NousSettingsReader.portalBaseURL( + environment: ["NOUS_PORTAL_BASE_URL": "https://preview.portal.example.com"], + stored: "https://portal.nousresearch.com") + #expect(resolution.url.absoluteString == "https://preview.portal.example.com") + #expect(resolution.origin == .environmentOverride) + } + + @Test + func `expired environment token is rejected before any request`() { + let header = Data("{\"alg\":\"none\"}".utf8).base64EncodedString() + let payload = Data("{\"exp\": 946684800}".utf8).base64EncodedString().replacingOccurrences(of: "=", with: "") + let env = ["HOME": "/nonexistent", "NOUS_PORTAL_ACCESS_TOKEN": "\(header).\(payload).sig"] + #expect(NousSettingsReader.credential(environment: env) == nil) + #expect { + _ = try NousSettingsReader.resolveCredential(environment: env) + } throws: { error in + error as? NousUsageError == .environmentTokenExpired + } } } diff --git a/Tests/CodexBarTests/NousUsageFetcherTests.swift b/Tests/CodexBarTests/NousUsageFetcherTests.swift index d7934fb30f..f3b2423862 100644 --- a/Tests/CodexBarTests/NousUsageFetcherTests.swift +++ b/Tests/CodexBarTests/NousUsageFetcherTests.swift @@ -51,11 +51,11 @@ struct NousUsageFetcherTests { #expect(primary.resetsAt == account.currentPeriodEnd) #expect(usage.secondary == nil) #expect(usage.subscriptionRenewsAt == account.currentPeriodEnd) - #expect(usage.loginMethod(for: .nous) == "Ultra") + #expect(usage.loginMethod(for: .nous) == "Ultra · Top-up $19.35") #expect(usage.identity(for: .nous)?.accountEmail == "dev@example.com") #expect(usage.dataConfidence == .exact) #expect(usage.details.map(\.title) == ["Subscription", "Credits"]) - #expect(usage.details[0].rows.map(\.label) == ["Monthly credits", "Renews"]) + #expect(usage.details[0].rows.map(\.label) == ["Subscription credits", "Renews"]) #expect(usage.details[0].rows[0].value == "$55.00 of $220.00 left") let credits = account.toCreditsSnapshot() @@ -90,9 +90,10 @@ struct NousUsageFetcherTests { let account = try NousUsageFetcher._parseAccountForTesting(Data(json.utf8)) let usage = account.toUsageSnapshot() #expect(usage.primary == nil) - #expect(usage.loginMethod(for: .nous) == "Free") + #expect(usage.loginMethod(for: .nous) == "Free · Top-up $2.50") #expect(account.toCreditsSnapshot().remaining == 2.5) #expect(usage.details.map(\.title) == ["Credits"]) + #expect(usage.details[0].rows.map(\.label) == ["Top-up credits", "Total usable"]) } @Test diff --git a/docs/nous.md b/docs/nous.md index 5038ef2562..48d4cfb747 100644 --- a/docs/nous.md +++ b/docs/nous.md @@ -27,6 +27,18 @@ Overrides: - `NOUS_PORTAL_ACCESS_TOKEN`: use this token instead of the Hermes files. - `NOUS_PORTAL_BASE_URL` / `HERMES_PORTAL_BASE_URL`: point at a preview portal deployment (HTTPS only). +### Where the token is sent + +The bearer token only ever goes to one origin, resolved in this order: + +1. An explicit `NOUS_PORTAL_BASE_URL` / `HERMES_PORTAL_BASE_URL` override (HTTPS, set by you). +2. The `portal_base_url` stored by Hermes, but only when its host is `nousresearch.com` or a subdomain. +3. `https://portal.nousresearch.com`. + +A stored host outside `nousresearch.com` is ignored, logged as a warning, and reported in the verbose trace as +`rejectedStoredHost=`; the request then goes to the default portal. Expired tokens, whether from the auth file or +from `NOUS_PORTAL_ACCESS_TOKEN`, are rejected before any request is made. + ### Why CodexBar never refreshes the token Nous access tokens live for about an hour. The refresh token is single-use: the portal rotates it on every refresh and @@ -42,15 +54,21 @@ One request per refresh: `GET {portal}/api/oauth/account` with the bearer token. | --- | --- | | `subscription.monthly_credits`, `subscription.credits_remaining` | Primary meter "Monthly credits" as percent used | | `subscription.current_period_end` | Meter reset time and renewal date | -| `subscription.plan` | Plan row | +| `subscription.plan`, `purchased_credits_remaining` | Plan row, e.g. `Ultra · Top-up $19.35` | | `subscription.rollover_credits` | Subscription detail row when non-zero | -| `purchased_credits_remaining` | Credits balance | +| `purchased_credits_remaining` | Credits balance and the "Top-up credits" detail row | | `paid_service_access.total_usable_credits` | Credits detail row | | `user.email`, `organisation.name` | Identity (siloed to this provider) | Money fields are accepted both as JSON numbers and as decimal strings. A Free tier with no monthly grant shows no meter and only the purchased balance. +## API keys + +Nous Portal API keys authenticate only the inference API (`/v1/chat/completions`, `/v1/completions`). The portal's +account and billing endpoints accept the OAuth access token only, so CodexBar cannot show credits from an API key. +Use the Hermes Agent login. + ## CLI ```bash diff --git a/docs/providers.md b/docs/providers.md index 2d356846db..0e91dc2b4f 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -492,7 +492,8 @@ provider-specific cookie validation, endpoints, login detection, and error trans When the stored token expires, the card asks you to run `hermes` so Hermes refreshes it. - Reads `GET /api/oauth/account`: monthly subscription credits used/remaining with the cycle reset date, plan name, and the purchased credit balance (shown as credits). -- Override the portal host with `NOUS_PORTAL_BASE_URL` (HTTPS only). +- The token is only sent to `nousresearch.com` hosts (or an explicit `NOUS_PORTAL_BASE_URL` override you set); an + untrusted stored `portal_base_url` is ignored. API keys are inference-only and cannot read credits. - Details: `docs/nous.md`. ## Venice From 29c467b12424e89fcb8addce8b256f29d16d26bc Mon Sep 17 00:00:00 2001 From: Asis Panda Date: Wed, 2 Sep 2026 10:59:15 +0530 Subject: [PATCH 3/6] Nous Portal: keep the card diagnostic for verbose or rejected-host cases only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app renders the fetch diagnostic as a warning line, so the routine "portal=… credential=…" trace note now only appears in verbose runs or when an untrusted stored portal host was ignored. Keep the plan row to the plan name; the top-up balance lives in the Credits section. Co-Authored-By: Claude Fable 5.1 --- .../Providers/Nous/NousProviderDescriptor.swift | 12 +++++++++--- .../Providers/Nous/NousUsageFetcher.swift | 6 +++--- Tests/CodexBarTests/NousUsageFetcherTests.swift | 4 ++-- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Nous/NousProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Nous/NousProviderDescriptor.swift index 6a4523ebb9..753965bf6a 100644 --- a/Sources/CodexBarCore/Providers/Nous/NousProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Nous/NousProviderDescriptor.swift @@ -95,9 +95,15 @@ struct NousAPIFetchStrategy: ProviderFetchStrategy { // (the app only sets that flag for Codex's separate credits request). let credential = try NousSettingsReader.resolveCredential(environment: context.env) let account = try await NousUsageFetcher.fetchAccount(credential: credential, transport: self.transport) - var diagnostic = "portal=\(credential.portalBaseURL.host ?? "?") credential=\(credential.source.label)" - if let rejected = credential.rejectedPortalHost { - diagnostic += " rejectedStoredHost=\(rejected)" + // The app renders `diagnostic` as a warning line, so only emit it when there is something to warn about + // (an ignored stored host) or when the caller asked for a verbose trace. + var diagnostic: String? + if credential.rejectedPortalHost != nil || context.verbose { + var note = "portal=\(credential.portalBaseURL.host ?? "?") credential=\(credential.source.label)" + if let rejected = credential.rejectedPortalHost { + note += " rejectedStoredHost=\(rejected)" + } + diagnostic = note } return self.makeResult( usage: account.toUsageSnapshot(), diff --git a/Sources/CodexBarCore/Providers/Nous/NousUsageFetcher.swift b/Sources/CodexBarCore/Providers/Nous/NousUsageFetcher.swift index dad3347c73..2331c92c12 100644 --- a/Sources/CodexBarCore/Providers/Nous/NousUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Nous/NousUsageFetcher.swift @@ -44,10 +44,10 @@ public struct NousAccountSummary: Sendable, Equatable { self.updatedAt = updatedAt } - /// Plan row text: plan name plus the top-up balance, e.g. "Ultra · Top-up $19.35". + /// Plan row text. The header column is narrow, so keep it to the plan name; the top-up balance is shown in + /// the Credits section and the credits snapshot instead. public var planRowText: String { - let plan = self.plan ?? (self.hasActiveSubscription ? "Subscription" : "Free") - return "\(plan) · Top-up \(UsageFormatter.usdString(max(0, self.purchasedCreditsRemaining)))" + self.plan ?? (self.hasActiveSubscription ? "Subscription" : "Free") } /// Monthly subscription credits consumed this cycle, as a percentage of the monthly grant. diff --git a/Tests/CodexBarTests/NousUsageFetcherTests.swift b/Tests/CodexBarTests/NousUsageFetcherTests.swift index f3b2423862..06db3fa06c 100644 --- a/Tests/CodexBarTests/NousUsageFetcherTests.swift +++ b/Tests/CodexBarTests/NousUsageFetcherTests.swift @@ -51,7 +51,7 @@ struct NousUsageFetcherTests { #expect(primary.resetsAt == account.currentPeriodEnd) #expect(usage.secondary == nil) #expect(usage.subscriptionRenewsAt == account.currentPeriodEnd) - #expect(usage.loginMethod(for: .nous) == "Ultra · Top-up $19.35") + #expect(usage.loginMethod(for: .nous) == "Ultra") #expect(usage.identity(for: .nous)?.accountEmail == "dev@example.com") #expect(usage.dataConfidence == .exact) #expect(usage.details.map(\.title) == ["Subscription", "Credits"]) @@ -90,7 +90,7 @@ struct NousUsageFetcherTests { let account = try NousUsageFetcher._parseAccountForTesting(Data(json.utf8)) let usage = account.toUsageSnapshot() #expect(usage.primary == nil) - #expect(usage.loginMethod(for: .nous) == "Free · Top-up $2.50") + #expect(usage.loginMethod(for: .nous) == "Free") #expect(account.toCreditsSnapshot().remaining == 2.5) #expect(usage.details.map(\.title) == ["Credits"]) #expect(usage.details[0].rows.map(\.label) == ["Top-up credits", "Total usable"]) From c4ac4b3d13fee05f96bbbb79677917ba6c6e93c0 Mon Sep 17 00:00:00 2001 From: Asis Panda Date: Wed, 2 Sep 2026 11:06:30 +0530 Subject: [PATCH 4/6] Nous Portal: treat HERMES_HOME as the exclusive credential root When HERMES_HOME is set, only that root's auth.json and shared/nous_auth.json are consulted; the default ~/.hermes root is never used as a fallback, so a missing, invalid, or expired custom profile reports its own error instead of silently querying with another profile's token. Co-Authored-By: Claude Fable 5.1 --- .../Providers/Nous/NousSettingsReader.swift | 25 +++----- .../NousSettingsReaderTests.swift | 57 +++++++++++++++++++ docs/nous.md | 4 +- docs/providers.md | 2 +- 4 files changed, 70 insertions(+), 18 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Nous/NousSettingsReader.swift b/Sources/CodexBarCore/Providers/Nous/NousSettingsReader.swift index 16e6e03795..627d20f9bb 100644 --- a/Sources/CodexBarCore/Providers/Nous/NousSettingsReader.swift +++ b/Sources/CodexBarCore/Providers/Nous/NousSettingsReader.swift @@ -167,24 +167,17 @@ public enum NousSettingsReader: Sendable { } /// Hermes stores per-profile credentials in `auth.json` and a cross-profile copy in `shared/nous_auth.json`. + /// + /// An explicit `HERMES_HOME` is the exclusive credential root: when it is set, the default `~/.hermes` root is + /// never consulted, so a missing, invalid, or expired custom profile can never fall through to another + /// profile's token. static func authFileCandidates(environment: [String: String]) -> [URL] { - var roots: [URL] = [] - if let override = self.cleaned(environment[self.hermesHomeEnvironmentKey]) { - roots.append(URL(fileURLWithPath: NSString(string: override).expandingTildeInPath, isDirectory: true)) - } - roots.append(self.defaultHermesHome(environment: environment)) - - var seen = Set() - var candidates: [URL] = [] - for root in roots { - for relative in ["auth.json", "shared/nous_auth.json"] { - let url = root.appendingPathComponent(relative) - if seen.insert(url.path).inserted { - candidates.append(url) - } - } + let root: URL = if let override = self.cleaned(environment[self.hermesHomeEnvironmentKey]) { + URL(fileURLWithPath: NSString(string: override).expandingTildeInPath, isDirectory: true) + } else { + self.defaultHermesHome(environment: environment) } - return candidates + return ["auth.json", "shared/nous_auth.json"].map { root.appendingPathComponent($0) } } static func defaultHermesHome(environment: [String: String]) -> URL { diff --git a/Tests/CodexBarTests/NousSettingsReaderTests.swift b/Tests/CodexBarTests/NousSettingsReaderTests.swift index f1e7b506ca..b0c4175f89 100644 --- a/Tests/CodexBarTests/NousSettingsReaderTests.swift +++ b/Tests/CodexBarTests/NousSettingsReaderTests.swift @@ -81,6 +81,63 @@ struct NousSettingsReaderTests { #expect(credential.token == "custom-token") } + @Test + func `HERMES_HOME is exclusive: a missing custom profile never falls back to the default root`() throws { + let home = try Self.makeHome() + let custom = home.appendingPathComponent("custom-hermes", isDirectory: true) + try FileManager.default.createDirectory(at: custom, withIntermediateDirectories: true) + try Self.write( + Self.authJSON(token: "default-token", expiresAt: "2999-01-01T00:00:00+00:00"), + to: home.appendingPathComponent(".hermes/auth.json")) + let env = ["HOME": home.path, "HERMES_HOME": custom.path] + + #expect(NousSettingsReader.authFileCandidates(environment: env).allSatisfy { $0.path.hasPrefix(custom.path) }) + #expect(NousSettingsReader.credential(environment: env) == nil) + #expect { + _ = try NousSettingsReader.resolveCredential(environment: env) + } throws: { error in + error as? NousUsageError == .missingCredentials + } + } + + @Test + func `HERMES_HOME is exclusive: an expired custom profile reports expiry instead of another profile`() throws { + let home = try Self.makeHome() + let custom = home.appendingPathComponent("custom-hermes", isDirectory: true) + try FileManager.default.createDirectory(at: custom, withIntermediateDirectories: true) + try Self.write( + Self.authJSON(token: "default-token", expiresAt: "2999-01-01T00:00:00+00:00"), + to: home.appendingPathComponent(".hermes/auth.json")) + let customAuth = custom.appendingPathComponent("auth.json") + try Self.write(Self.authJSON(token: "stale", expiresAt: "2000-01-01T00:00:00+00:00"), to: customAuth) + let env = ["HOME": home.path, "HERMES_HOME": custom.path] + + #expect { + _ = try NousSettingsReader.resolveCredential(environment: env) + } throws: { error in + error as? NousUsageError == .sessionExpired(customAuth.path) + } + } + + @Test + func `HERMES_HOME is exclusive: an invalid custom profile reports the invalid file`() throws { + let home = try Self.makeHome() + let custom = home.appendingPathComponent("custom-hermes", isDirectory: true) + try FileManager.default.createDirectory(at: custom, withIntermediateDirectories: true) + try Self.write( + Self.authJSON(token: "default-token", expiresAt: "2999-01-01T00:00:00+00:00"), + to: home.appendingPathComponent(".hermes/auth.json")) + let customAuth = custom.appendingPathComponent("auth.json") + try Self.write("{ \"version\": 1, \"providers\": {} }", to: customAuth) + let env = ["HOME": home.path, "HERMES_HOME": custom.path] + + #expect { + _ = try NousSettingsReader.resolveCredential(environment: env) + } throws: { error in + error as? NousUsageError == .authFileInvalid(customAuth.path) + } + } + @Test func `falls back to shared store when profile token is expired`() throws { let home = try Self.makeHome() diff --git a/docs/nous.md b/docs/nous.md index 48d4cfb747..31225131d2 100644 --- a/docs/nous.md +++ b/docs/nous.md @@ -23,7 +23,9 @@ device-code login. CodexBar does not run its own login and does not store any No Overrides: -- `HERMES_HOME`: directory holding `auth.json` when Hermes runs from a custom root or profile. +- `HERMES_HOME`: directory holding `auth.json` when Hermes runs from a custom root or profile. It is exclusive: when + set, `~/.hermes` is never consulted, so a missing or expired custom profile reports an error rather than silently + using another profile's login. - `NOUS_PORTAL_ACCESS_TOKEN`: use this token instead of the Hermes files. - `NOUS_PORTAL_BASE_URL` / `HERMES_PORTAL_BASE_URL`: point at a preview portal deployment (HTTPS only). diff --git a/docs/providers.md b/docs/providers.md index 0e91dc2b4f..74f1285cca 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -487,7 +487,7 @@ provider-specific cookie validation, endpoints, login detection, and error trans ## Nous Portal - Reuses the OAuth access token Hermes Agent stores in `~/.hermes/auth.json` (falls back to `~/.hermes/shared/nous_auth.json`; - `HERMES_HOME` overrides the directory). `NOUS_PORTAL_ACCESS_TOKEN` supplies a token directly. + `HERMES_HOME` replaces the directory and is exclusive). `NOUS_PORTAL_ACCESS_TOKEN` supplies a token directly. - CodexBar never calls the refresh endpoint: Nous refresh tokens are single-use and reuse revokes the Hermes session. When the stored token expires, the card asks you to run `hermes` so Hermes refreshes it. - Reads `GET /api/oauth/account`: monthly subscription credits used/remaining with the cycle reset date, plan name, From 9d8a99002662d50adc3487e144ab69ffde825042 Mon Sep 17 00:00:00 2001 From: Asis Panda Date: Wed, 2 Sep 2026 11:27:13 +0530 Subject: [PATCH 5/6] Nous Portal: refuse plain-HTTP portal overrides, align plan-row docs Reject every non-HTTPS portal base URL, loopback included, so the Hermes bearer token can never be sent in cleartext. Add a request-level test that the Authorization header only targets https://portal.nousresearch.com when an http://127.0.0.1 override or an untrusted stored host is supplied. Document that the plan row shows the plan name only and the top-up balance lives in the Credits section. Co-Authored-By: Claude Fable 5.1 --- .../Providers/Nous/NousSettingsReader.swift | 10 ++-- .../NousSettingsReaderTests.swift | 18 ++++-- .../CodexBarTests/NousUsageFetcherTests.swift | 58 +++++++++++++++++++ docs/nous.md | 9 +-- 4 files changed, 81 insertions(+), 14 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Nous/NousSettingsReader.swift b/Sources/CodexBarCore/Providers/Nous/NousSettingsReader.swift index 627d20f9bb..b2a53b5044 100644 --- a/Sources/CodexBarCore/Providers/Nous/NousSettingsReader.swift +++ b/Sources/CodexBarCore/Providers/Nous/NousSettingsReader.swift @@ -243,13 +243,15 @@ public enum NousSettingsReader: Sendable { return Date(timeIntervalSince1970: exp) } + /// Accepts HTTPS origins only. Plain HTTP is refused for every host, loopback included, so the bearer token + /// can never travel in cleartext regardless of where the override came from. static func normalizedHTTPSURL(_ raw: String) -> URL? { var value = raw while value.hasSuffix("/") { value.removeLast() } - guard let url = URL(string: value), let scheme = url.scheme?.lowercased(), url.host != nil else { return nil } - guard scheme == "https" || (scheme == "http" && (url.host == "localhost" || url.host == "127.0.0.1")) else { - return nil - } + guard let url = URL(string: value), + url.scheme?.lowercased() == "https", + let host = url.host, !host.isEmpty + else { return nil } return url } diff --git a/Tests/CodexBarTests/NousSettingsReaderTests.swift b/Tests/CodexBarTests/NousSettingsReaderTests.swift index b0c4175f89..7181c2c2c8 100644 --- a/Tests/CodexBarTests/NousSettingsReaderTests.swift +++ b/Tests/CodexBarTests/NousSettingsReaderTests.swift @@ -213,12 +213,18 @@ struct NousSettingsReaderTests { } @Test - func `portal base URL rejects non-https overrides`() { - let resolution = NousSettingsReader.portalBaseURL( - environment: ["NOUS_PORTAL_BASE_URL": "http://evil.example"], - stored: nil) - #expect(resolution.url == NousSettingsReader.defaultPortalBaseURL) - #expect(resolution.origin == .default) + func `portal base URL rejects every http override including loopback`() { + for raw in ["http://evil.example", "http://localhost:3000", "http://127.0.0.1", "ftp://portal.nousresearch.com"] { + let override = NousSettingsReader.portalBaseURL(environment: ["NOUS_PORTAL_BASE_URL": raw], stored: nil) + #expect(override.url == NousSettingsReader.defaultPortalBaseURL, "override \(raw) must be refused") + #expect(override.origin == .default) + + let stored = NousSettingsReader.portalBaseURL(environment: [:], stored: raw) + #expect(stored.url == NousSettingsReader.defaultPortalBaseURL, "stored \(raw) must be refused") + #expect(stored.rejectedStoredHost == nil, "a non-HTTPS stored value is discarded, not treated as a host") + } + #expect(NousSettingsReader.normalizedHTTPSURL("http://localhost") == nil) + #expect(NousSettingsReader.normalizedHTTPSURL("https://localhost:8443")?.absoluteString == "https://localhost:8443") } @Test diff --git a/Tests/CodexBarTests/NousUsageFetcherTests.swift b/Tests/CodexBarTests/NousUsageFetcherTests.swift index 06db3fa06c..460cbef0e3 100644 --- a/Tests/CodexBarTests/NousUsageFetcherTests.swift +++ b/Tests/CodexBarTests/NousUsageFetcherTests.swift @@ -2,6 +2,27 @@ import Foundation import Testing @testable import CodexBarCore +/// Captures the outgoing request so tests can assert on the final destination and headers. +private final class NousCapturingTransport: ProviderHTTPTransport, @unchecked Sendable { + private let lock = NSLock() + private var captured: [URLRequest] = [] + let body: Data + + init(body: Data) { + self.body = body + } + + var requests: [URLRequest] { + self.lock.withLock { self.captured } + } + + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + self.lock.withLock { self.captured.append(request) } + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (self.body, response) + } +} + struct NousUsageFetcherTests { static let accountJSON = """ { @@ -134,6 +155,43 @@ struct NousUsageFetcherTests { } } + @Test + func `authorization header only ever targets an https nousresearch.com origin`() async throws { + let transport = NousCapturingTransport(body: Data(Self.accountJSON.utf8)) + let token = "hermes-access-token" + + // Environment override pointing at cleartext loopback is refused; the default HTTPS portal is used. + let overridden = NousSettingsReader.portalBaseURL( + environment: ["NOUS_PORTAL_BASE_URL": "http://127.0.0.1:8080"], + stored: nil) + let envCredential = NousSettingsReader.Credential( + token: token, + portalBaseURL: overridden.url, + expiresAt: nil, + source: .environment) + _ = try await NousUsageFetcher.fetchAccount(credential: envCredential, transport: transport) + + // Stored auth-file host outside nousresearch.com is refused the same way. + let stored = NousSettingsReader.portalBaseURL(environment: [:], stored: "https://stored.example") + let fileCredential = NousSettingsReader.Credential( + token: token, + portalBaseURL: stored.url, + expiresAt: nil, + source: .authFile("/tmp/auth.json"), + rejectedPortalHost: stored.rejectedStoredHost) + _ = try await NousUsageFetcher.fetchAccount(credential: fileCredential, transport: transport) + + let requests = transport.requests + #expect(requests.count == 2) + for request in requests { + let url = try #require(request.url) + #expect(url.scheme == "https") + #expect(url.host == "portal.nousresearch.com") + #expect(url.path == NousUsageFetcher.accountPath) + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer \(token)") + } + } + @Test func `account URL appends the oauth account path`() { let url = NousUsageFetcher.accountURL(portalBaseURL: URL(string: "https://portal.example.com")!) diff --git a/docs/nous.md b/docs/nous.md index 31225131d2..ed89d2ead3 100644 --- a/docs/nous.md +++ b/docs/nous.md @@ -27,13 +27,14 @@ Overrides: set, `~/.hermes` is never consulted, so a missing or expired custom profile reports an error rather than silently using another profile's login. - `NOUS_PORTAL_ACCESS_TOKEN`: use this token instead of the Hermes files. -- `NOUS_PORTAL_BASE_URL` / `HERMES_PORTAL_BASE_URL`: point at a preview portal deployment (HTTPS only). +- `NOUS_PORTAL_BASE_URL` / `HERMES_PORTAL_BASE_URL`: point at a preview portal deployment. HTTPS only; plain HTTP + is refused for every host, loopback included, and the default portal is used instead. ### Where the token is sent The bearer token only ever goes to one origin, resolved in this order: -1. An explicit `NOUS_PORTAL_BASE_URL` / `HERMES_PORTAL_BASE_URL` override (HTTPS, set by you). +1. An explicit `NOUS_PORTAL_BASE_URL` / `HERMES_PORTAL_BASE_URL` override (HTTPS only, set by you). 2. The `portal_base_url` stored by Hermes, but only when its host is `nousresearch.com` or a subdomain. 3. `https://portal.nousresearch.com`. @@ -56,9 +57,9 @@ One request per refresh: `GET {portal}/api/oauth/account` with the bearer token. | --- | --- | | `subscription.monthly_credits`, `subscription.credits_remaining` | Primary meter "Monthly credits" as percent used | | `subscription.current_period_end` | Meter reset time and renewal date | -| `subscription.plan`, `purchased_credits_remaining` | Plan row, e.g. `Ultra · Top-up $19.35` | +| `subscription.plan` | Plan row (plan name only, e.g. `Ultra`) | | `subscription.rollover_credits` | Subscription detail row when non-zero | -| `purchased_credits_remaining` | Credits balance and the "Top-up credits" detail row | +| `purchased_credits_remaining` | Credits snapshot and the "Top-up credits" row in the Credits section | | `paid_service_access.total_usable_credits` | Credits detail row | | `user.email`, `organisation.name` | Identity (siloed to this provider) | From 6b655b45573efe0493c61b691921f03362ce8603 Mon Sep 17 00:00:00 2001 From: Asis Panda Date: Fri, 4 Sep 2026 08:18:05 +0530 Subject: [PATCH 6/6] Nous Portal: restore the existing Unreleased changelog entries The merge from main resolved CHANGELOG.md in favor of the branch, which dropped the current Unreleased Fixed bullets and the whole 0.56.4 section. Take main's changelog verbatim and add the Nous Portal entry under a new Added heading alongside it, so the diff against main is purely additive. --- CHANGELOG.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ac31730338..6f8a33ac34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,31 @@ ### Added - Nous Portal: new provider showing monthly subscription credits, cycle reset, plan, and purchased credit balance by reusing the Hermes Agent OAuth login from `~/.hermes/auth.json` (#1367). +### Fixed +- Settings: disable iCloud sync sub-options when the main sync switch is off, preserving their saved choices for the next time sync is enabled (#3406). Thanks @elijahfriedman! +- Antigravity: match local token-history timestamps by per-turn IDs when auxiliary or reordered steps would otherwise assign usage to the wrong day, while withholding conflicting evidence and preserving legacy timestamp recovery (#3403). Thanks @WeGoToMars! +- Codex: retain completed empty session fragments during cost-history scans instead of repeatedly dropping and rediscovering them, without suppressing usage-bearing duplicates or later appended usage (partial fix for #3316; #3402). Thanks @mauriciopolvora! +- Agent sessions: explicitly force Tailscale CLI mode during remote-host discovery, preventing repeated app-binary crashes on newer Tailscale installations while preserving existing terminal settings (#3397). Thanks @tzioup! +- Claude: stop labeling restored quota history as CLI usage, while retaining the limited-detail warning, original percentages, and stale-data guidance. +- Usage & Spend: prefer heatmap tooltips above hovered cells and keep them within narrow grids; retain daily keyboard selection without the extra system focus rectangle (#3407). Thanks @elijahfriedman! + +## 0.56.4 — 2026-09-03 + +### Fixed +- Codex: let cost-history catch-up finish while active rollout files keep growing, preserving complete session and subagent accounting without publishing partial tails (#3243, #3314). Thanks @LeoLin990405! +- Antigravity: restore token history from newer local sessions whose timestamps moved to the steps table, while rejecting missing, duplicate, or conflicting timestamp evidence instead of inventing dates (#3266, #3396). Thanks @chid! +- Codex: keep each managed account's selected workspace authoritative across stacked refreshes, credits, history, menu rows, reconciliation, and System Account promotion instead of reverting to or rewriting the auth file's default workspace (#3347, #3348, #3386). Thanks @krevoit! +- Settings: prevent scrolled detail content from bleeding through the native title bar while retaining the edge-to-edge sidebar and native window title (#3235, #3315). Thanks @LeoLin990405! +- Claude: recognize Cloudflare web challenges without discarding valid cached cookies or prior usage, and offer explicit OAuth or network recovery guidance (#3367, #3375). Thanks @TPuHo4u! +- Antigravity: recover Linux port discovery when `lsof` fails with mount-namespace warnings, while preserving authentication errors and the existing startup deadline (#3362, #3364). Thanks @srijits! +- Menu bar: let live forecast and detail labels use the full row width, preventing text from clipping to its previous width until the menu reopens (#3370). +- Settings: open the About pane from the application menu as well as the status menu, reusing the existing Settings window (#3391). Thanks @elijahfriedman! +- Menu bar: discard non-finite saved status-item positions before AppKit restores them, preserving valid placements (#3361; investigated alongside #3355). Thanks @foobra! +- Claude: remove misleading defaults-suite warnings at launch while preserving shared OAuth preferences for the CLI and widget (#3381, #3384). Thanks @andresg747! + +### Documentation +- AWS Bedrock: explain that monitoring API calls may incur charges, how shared refresh controls affect them, and why Manual mode and the displayed budget do not impose a billing cap (#3387, #3393). Thanks @kyen99! + ## 0.56.3 — 2026-09-01 ### Performance