import Foundation import SweetCookieKit #if os(macOS) private let augmentCookieImportOrder: BrowserCookieImportOrder = ProviderDefaults.metadata[.augment]?.browserCookieOrder ?? Browser.defaultImportOrder // MARK: - Augment Cookie Importer /// Imports Augment session cookies from browser cookies. public enum AugmentCookieImporter { private static let cookieClient = BrowserCookieClient() /// Auth0 session cookies used by Augment /// NOTE: This list may not be exhaustive. If authentication fails with cookies present, /// check debug logs for cookie names and report them. private static let sessionCookieNames: Set = [ "session", // Augment auth session (auth.augmentcode.com) "_session", // Legacy session cookie (app.augmentcode.com) "web_rpc_proxy_session", // Augment RPC proxy session "auth0", // Auth0 session "auth0.is.authenticated", // Auth0 authentication flag "a0.spajs.txs", // Auth0 SPA transaction state "__Secure-next-auth.session-token", // NextAuth secure session "next-auth.session-token", // NextAuth session "__Secure-authjs.session-token", // AuthJS secure session "__Host-authjs.csrf-token", // AuthJS CSRF token "authjs.session-token", // AuthJS session ] public struct SessionInfo: Sendable { public let cookies: [HTTPCookie] public let sourceLabel: String public init(cookies: [HTTPCookie], sourceLabel: String) { self.cookies = cookies self.sourceLabel = sourceLabel } public var cookieHeader: String { self.cookies.map { "\($0.name)=\($0.value)" }.joined(separator: "; ") } /// Returns cookie header filtered for a specific target URL public func cookieHeader(for url: URL) -> String { guard let host = url.host else { return "" } let matchingCookies = self.cookies.filter { cookie in let domain = cookie.domain // Handle wildcard domains (e.g., ".augmentcode.com") if domain.hasPrefix(".") { let baseDomain = String(domain.dropFirst()) return host == baseDomain || host.hasSuffix(".\(baseDomain)") } // Exact match or subdomain match return host == domain || host.hasSuffix(".\(domain)") } return matchingCookies.map { "\($0.name)=\($0.value)" }.joined(separator: "; ") } } /// Attempts to import Augment cookies using the standard browser import order. public static func importSession(logger: ((String) -> Void)? = nil) throws -> SessionInfo { let log: (String) -> Void = { msg in logger?("[augment-cookie] \(msg)") } let cookieDomains = ["augmentcode.com", "app.augmentcode.com"] for browserSource in augmentCookieImportOrder { do { let query = BrowserCookieQuery(domains: cookieDomains) let sources = try Self.cookieClient.codexBarRecords( matching: query, in: browserSource, logger: log) for source in sources where !source.records.isEmpty { let httpCookies = BrowserCookieClient.makeHTTPCookies(source.records, origin: query.origin) // Log all cookies found for debugging let cookieNames = httpCookies.map(\.name).joined(separator: ", ") log("Found \(httpCookies.count) cookies in \(source.label): \(cookieNames)") // Check if we have any session cookies let matchingCookies = httpCookies.filter { Self.sessionCookieNames.contains($0.name) } if !matchingCookies.isEmpty { let matchingCookieNames = matchingCookies.map(\.name).joined(separator: ", ") log("✓ Found Augment session cookies in \(source.label): \(matchingCookieNames)") return SessionInfo(cookies: httpCookies, sourceLabel: source.label) } else if !httpCookies.isEmpty { // Log unrecognized cookies to help discover missing session cookie names log( "⚠️ \(source.label) has \(httpCookies.count) cookies but none match known session cookies") log(" Cookie names found: \(cookieNames)") log(" If you're logged into Augment, please report these cookie names") log(" to help improve detection") } } } catch { BrowserCookieAccessGate.recordIfNeeded(error) log("\(browserSource.displayName) cookie import failed: \(error.localizedDescription)") } } throw AugmentStatusProbeError.noSessionCookie } /// Check if Augment session cookies are available public static func hasSession(logger: ((String) -> Void)? = nil) -> Bool { do { let session = try self.importSession(logger: logger) return !session.cookies.isEmpty } catch { return false } } } // MARK: - Augment API Models public struct AugmentCreditsResponse: Codable, Sendable { public let usageUnitsRemaining: Double? public let usageUnitsConsumedThisBillingCycle: Double? public let usageUnitsAvailable: Double? public let usageBalanceStatus: String? /// Computed properties for compatibility with existing code public var credits: Double? { self.usageUnitsRemaining } public var creditsUsed: Double? { self.usageUnitsConsumedThisBillingCycle } public var creditsLimit: Double? { if let available = self.usageUnitsAvailable, available > 0 { return available } guard let remaining = self.usageUnitsRemaining, let consumed = self.usageUnitsConsumedThisBillingCycle else { return nil } return remaining + consumed } private enum CodingKeys: String, CodingKey { case usageUnitsRemaining case usageUnitsConsumedThisBillingCycle case usageUnitsAvailable case usageBalanceStatus } } public struct AugmentSubscriptionResponse: Codable, Sendable { public let planName: String? public let billingPeriodEnd: String? public let email: String? public let organization: String? private enum CodingKeys: String, CodingKey { case planName case billingPeriodEnd case email case organization } } // MARK: - Augment Status Snapshot public struct AugmentStatusSnapshot: Sendable { public let creditsRemaining: Double? public let creditsUsed: Double? public let creditsLimit: Double? public let billingCycleEnd: Date? public let accountEmail: String? public let accountPlan: String? public let rawJSON: String? public init( creditsRemaining: Double?, creditsUsed: Double?, creditsLimit: Double?, billingCycleEnd: Date?, accountEmail: String?, accountPlan: String?, rawJSON: String?) { self.creditsRemaining = creditsRemaining self.creditsUsed = creditsUsed self.creditsLimit = creditsLimit self.billingCycleEnd = billingCycleEnd self.accountEmail = accountEmail self.accountPlan = accountPlan self.rawJSON = rawJSON } public func toUsageSnapshot() -> UsageSnapshot { let percentUsed: Double = if let used = self.creditsUsed, let limit = self.creditsLimit, limit > 0 { (used / limit) * 100.0 } else if let remaining = self.creditsRemaining, let limit = self.creditsLimit, limit > 0 { ((limit - remaining) / limit) * 100.0 } else { 0 } let primary = RateWindow( usedPercent: percentUsed, windowMinutes: nil, resetsAt: self.billingCycleEnd, resetDescription: self.billingCycleEnd.map { "Resets \(Self.formatResetDate($0))" }) let identity = ProviderIdentitySnapshot( providerID: .augment, accountEmail: self.accountEmail, accountOrganization: nil, loginMethod: self.accountPlan) return UsageSnapshot( primary: primary, secondary: nil, tertiary: nil, providerCost: nil, updatedAt: Date(), identity: identity) } private static func formatResetDate(_ date: Date) -> String { let formatter = RelativeDateTimeFormatter() formatter.locale = Locale(identifier: "en_US") formatter.unitsStyle = .full return formatter.localizedString(for: date, relativeTo: Date()) } } // MARK: - Augment Status Probe Error public enum AugmentStatusProbeError: LocalizedError, Sendable { case notLoggedIn case networkError(String) case parseFailed(String) case noSessionCookie case sessionExpired public var errorDescription: String? { switch self { case .notLoggedIn: "Not logged in to Augment. Please log in via the CodexBar menu." case let .networkError(msg): "Augment API error: \(msg)" case let .parseFailed(msg): "Could not parse Augment usage: \(msg)" case .noSessionCookie: "No Augment session found. Please log in to app.augmentcode.com in \(augmentCookieImportOrder.loginHint)." case .sessionExpired: "Augment session expired. Please log in again." } } } // MARK: - Augment Session Store public actor AugmentSessionStore { public static let shared = AugmentSessionStore() private var sessionCookies: [HTTPCookie] = [] private var hasLoadedFromDisk = false private let fileURL: URL private init() { let fm = FileManager.default let appSupport = fm.urls(for: .applicationSupportDirectory, in: .userDomainMask).first ?? fm.temporaryDirectory let dir = appSupport.appendingPathComponent("CodexBar", isDirectory: true) try? fm.createDirectory(at: dir, withIntermediateDirectories: true) self.fileURL = dir.appendingPathComponent("augment-session.json") // Load saved cookies on init Task { await self.loadFromDiskIfNeeded() } } public func setCookies(_ cookies: [HTTPCookie]) { self.hasLoadedFromDisk = true self.sessionCookies = cookies self.saveToDisk() } public func getCookies() -> [HTTPCookie] { self.loadFromDiskIfNeeded() return self.sessionCookies } public func clearCookies() { self.hasLoadedFromDisk = true self.sessionCookies = [] try? FileManager.default.removeItem(at: self.fileURL) } public func hasValidSession() -> Bool { self.loadFromDiskIfNeeded() return !self.sessionCookies.isEmpty } #if DEBUG func resetForTesting(clearDisk: Bool = true) { self.hasLoadedFromDisk = false self.sessionCookies = [] if clearDisk { try? FileManager.default.removeItem(at: self.fileURL) } } #endif private func loadFromDiskIfNeeded() { guard !self.hasLoadedFromDisk else { return } self.hasLoadedFromDisk = true self.loadFromDisk() } private func saveToDisk() { // Convert cookie properties to JSON-serializable format // Date values must be converted to TimeInterval (Double) let cookieData = self.sessionCookies.compactMap { cookie -> [String: Any]? in guard let props = cookie.properties else { return nil } var serializable: [String: Any] = [:] for (key, value) in props { let keyString = key.rawValue if let date = value as? Date { // Convert Date to TimeInterval for JSON compatibility serializable[keyString] = date.timeIntervalSince1970 serializable[keyString + "_isDate"] = true } else if let url = value as? URL { serializable[keyString] = url.absoluteString serializable[keyString + "_isURL"] = true } else if JSONSerialization.isValidJSONObject([value]) || value is String || value is Bool || value is NSNumber { serializable[keyString] = value } } return serializable } guard !cookieData.isEmpty, let data = try? JSONSerialization.data(withJSONObject: cookieData, options: [.prettyPrinted]) else { return } try? data.write(to: self.fileURL) } private func loadFromDisk() { guard let data = try? Data(contentsOf: self.fileURL), let cookieArray = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { return } self.sessionCookies = cookieArray.compactMap { props in // Convert back to HTTPCookiePropertyKey dictionary var cookieProps: [HTTPCookiePropertyKey: Any] = [:] for (key, value) in props { // Skip marker keys if key.hasSuffix("_isDate") || key.hasSuffix("_isURL") { continue } let propKey = HTTPCookiePropertyKey(key) // Check if this was a Date if props[key + "_isDate"] as? Bool == true, let interval = value as? TimeInterval { cookieProps[propKey] = Date(timeIntervalSince1970: interval) } // Check if this was a URL else if props[key + "_isURL"] as? Bool == true, let urlString = value as? String { cookieProps[propKey] = URL(string: urlString) } else { cookieProps[propKey] = value } } return HTTPCookie(properties: cookieProps) } } } // MARK: - Augment Status Probe public struct AugmentStatusProbe: Sendable { public let baseURL: URL public var timeout: TimeInterval = 15.0 public init(baseURL: URL = URL(string: "https://app.augmentcode.com")!, timeout: TimeInterval = 15.0) { self.baseURL = baseURL self.timeout = timeout } /// Fetch Augment usage with manual cookie header (for debugging). public func fetchWithManualCookies(_ cookieHeader: String) async throws -> AugmentStatusSnapshot { try await self.fetchWithCookieHeader(cookieHeader) } /// Fetch Augment usage using browser cookies with fallback to stored session. public func fetch(cookieHeaderOverride: String? = nil, logger: ((String) -> Void)? = nil) async throws -> AugmentStatusSnapshot { let log: (String) -> Void = { msg in logger?("[augment] \(msg)") } if let override = CookieHeaderNormalizer.normalize(cookieHeaderOverride) { log("Using manual cookie header") return try await self.fetchWithCookieHeader(override) } if let cached = CookieHeaderCache.load(provider: .augment), !cached.cookieHeader.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { log("Using cached cookie header from \(cached.sourceLabel)") do { return try await self.fetchWithCookieHeader(cached.cookieHeader) } catch let error as AugmentStatusProbeError { switch error { case .notLoggedIn, .sessionExpired: CookieHeaderCache.clear(provider: .augment) default: throw error } } catch { throw error } } // Try importing cookies from the configured browser order first. do { let session = try AugmentCookieImporter.importSession(logger: log) log("Using cookies from \(session.sourceLabel)") let snapshot = try await self.fetchWithCookieHeader(session.cookieHeader) // SUCCESS: Save cookies to fallback store for future use await AugmentSessionStore.shared.setCookies(session.cookies) log("Saved session cookies to fallback store") CookieHeaderCache.store( provider: .augment, cookieHeader: session.cookieHeader, sourceLabel: session.sourceLabel) return snapshot } catch { BrowserCookieAccessGate.recordIfNeeded(error) log("Browser cookie import failed: \(error.localizedDescription)") } // Fall back to stored session cookies (from previous successful fetch or "Add Account" login flow) let storedCookies = await AugmentSessionStore.shared.getCookies() if !storedCookies.isEmpty { log("Using stored session cookies") let cookieHeader = storedCookies.map { "\($0.name)=\($0.value)" }.joined(separator: "; ") do { return try await self.fetchWithCookieHeader(cookieHeader) } catch { if case AugmentStatusProbeError.notLoggedIn = error { // Clear only when auth is invalid; keep for transient failures. await AugmentSessionStore.shared.clearCookies() log("Stored session invalid, cleared") } else if case AugmentStatusProbeError.sessionExpired = error { await AugmentSessionStore.shared.clearCookies() log("Stored session expired, cleared") } else { log("Stored session failed: \(error.localizedDescription)") } } } throw AugmentStatusProbeError.noSessionCookie } private func fetchWithCookieHeader(_ cookieHeader: String) async throws -> AugmentStatusSnapshot { // Fetch credits (required) let (creditsResponse, creditsJSON) = try await self.fetchCredits(cookieHeader: cookieHeader) // Fetch subscription (optional - provides plan name and billing cycle) let subscriptionResult: (AugmentSubscriptionResponse?, String?) = await { do { let (response, json) = try await self.fetchSubscription(cookieHeader: cookieHeader) return (response, json) } catch { // Subscription API is optional - don't fail the whole fetch if it's unavailable return (nil, nil) } }() return self.parseResponse( credits: creditsResponse, subscription: subscriptionResult.0, creditsJSON: creditsJSON, subscriptionJSON: subscriptionResult.1) } private func fetchCredits(cookieHeader: String) async throws -> (AugmentCreditsResponse, String) { let url = self.baseURL.appendingPathComponent("/api/credits") var request = URLRequest(url: url) request.timeoutInterval = self.timeout request.setValue("application/json", forHTTPHeaderField: "Accept") request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") let (data, response) = try await ProviderHTTPClient.shared.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw AugmentStatusProbeError.networkError("Invalid response") } if httpResponse.statusCode == 401 { // Session expired - trigger automatic recovery throw AugmentStatusProbeError.sessionExpired } if httpResponse.statusCode == 403 { let responseBody = String(data: data, encoding: .utf8) ?? "" throw AugmentStatusProbeError.networkError("HTTP \(httpResponse.statusCode): \(responseBody)") } guard httpResponse.statusCode == 200 else { let responseBody = String(data: data, encoding: .utf8) ?? "" throw AugmentStatusProbeError.networkError("HTTP \(httpResponse.statusCode): \(responseBody)") } let rawJSON = String(data: data, encoding: .utf8) ?? "" let decoder = JSONDecoder() do { let response = try decoder.decode(AugmentCreditsResponse.self, from: data) return (response, rawJSON) } catch { throw AugmentStatusProbeError.parseFailed("Credits response: \(error.localizedDescription)") } } private func fetchSubscription(cookieHeader: String) async throws -> (AugmentSubscriptionResponse, String) { let url = self.baseURL.appendingPathComponent("/api/subscription") var request = URLRequest(url: url) request.timeoutInterval = self.timeout request.setValue("application/json", forHTTPHeaderField: "Accept") request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") let (data, response) = try await ProviderHTTPClient.shared.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw AugmentStatusProbeError.networkError("Invalid response") } if httpResponse.statusCode == 401 { // Session expired - trigger automatic recovery throw AugmentStatusProbeError.sessionExpired } if httpResponse.statusCode == 403 { throw AugmentStatusProbeError.notLoggedIn } guard httpResponse.statusCode == 200 else { throw AugmentStatusProbeError.networkError("HTTP \(httpResponse.statusCode)") } let rawJSON = String(data: data, encoding: .utf8) ?? "" let decoder = JSONDecoder() do { let response = try decoder.decode(AugmentSubscriptionResponse.self, from: data) return (response, rawJSON) } catch { throw AugmentStatusProbeError.parseFailed("Subscription response: \(error.localizedDescription)") } } private func parseResponse( credits: AugmentCreditsResponse, subscription: AugmentSubscriptionResponse?, creditsJSON: String, subscriptionJSON: String?) -> AugmentStatusSnapshot { // Combine both API responses for debugging var combinedJSON = "Credits API:\n\(creditsJSON)" if let subJSON = subscriptionJSON { combinedJSON += "\n\nSubscription API:\n\(subJSON)" } // Parse billing period end date from ISO8601 string let billingCycleEnd: Date? = { guard let dateString = subscription?.billingPeriodEnd else { return nil } let formatter = ISO8601DateFormatter() return formatter.date(from: dateString) }() return AugmentStatusSnapshot( creditsRemaining: credits.credits, creditsUsed: credits.creditsUsed, creditsLimit: credits.creditsLimit, billingCycleEnd: billingCycleEnd, accountEmail: subscription?.email, accountPlan: subscription?.planName, rawJSON: combinedJSON) } /// Debug probe that returns raw API responses public func debugRawProbe(cookieHeaderOverride: String? = nil) async -> String { let stamp = ISO8601DateFormatter().string(from: Date()) var lines: [String] = [] lines.append("=== Augment Debug Probe @ \(stamp) ===") lines.append("") do { let snapshot = try await self.fetch( cookieHeaderOverride: cookieHeaderOverride, logger: { msg in lines.append("[log] \(msg)") }) lines.append("") lines.append("Probe Success") lines.append("") lines.append("Credits Balance:") lines.append(" Remaining: \(snapshot.creditsRemaining?.description ?? "nil")") lines.append(" Used: \(snapshot.creditsUsed?.description ?? "nil")") lines.append(" Limit: \(snapshot.creditsLimit?.description ?? "nil")") lines.append("") lines.append("Billing Cycle End: \(snapshot.billingCycleEnd?.description ?? "nil")") lines.append("Account Email: \(snapshot.accountEmail ?? "nil")") lines.append("Account Plan: \(snapshot.accountPlan ?? "nil")") if let rawJSON = snapshot.rawJSON { lines.append("") lines.append("Raw API Response:") lines.append(rawJSON) } let output = lines.joined(separator: "\n") await MainActor.run { Self.recordDump(output) } return output } catch { lines.append("") lines.append("Probe Failed: \(error.localizedDescription)") let output = lines.joined(separator: "\n") await MainActor.run { Self.recordDump(output) } return output } } // MARK: - Dump storage (in-memory ring buffer) @MainActor private static var recentDumps: [String] = [] @MainActor private static func recordDump(_ text: String) { if self.recentDumps.count >= 5 { self.recentDumps.removeFirst() } self.recentDumps.append(text) } public static func latestDumps() async -> String { await MainActor.run { let result = Self.recentDumps.joined(separator: "\n\n---\n\n") return result.isEmpty ? "No Augment probe dumps captured yet." : result } } } #else // MARK: - Augment (Unsupported) public enum AugmentStatusProbeError: LocalizedError, Sendable { case notSupported public var errorDescription: String? { "Augment is only supported on macOS." } } public struct AugmentStatusSnapshot: Sendable { public init() {} public func toUsageSnapshot() -> UsageSnapshot { UsageSnapshot( primary: RateWindow(usedPercent: 0, windowMinutes: nil, resetsAt: nil, resetDescription: nil), secondary: nil, tertiary: nil, providerCost: nil, updatedAt: Date(), identity: nil) } } public struct AugmentStatusProbe: Sendable { public init(baseURL: URL = URL(string: "https://app.augmentcode.com")!, timeout: TimeInterval = 15.0) { _ = baseURL _ = timeout } public func fetch(cookieHeaderOverride: String? = nil, logger: ((String) -> Void)? = nil) async throws -> AugmentStatusSnapshot { _ = cookieHeaderOverride _ = logger throw AugmentStatusProbeError.notSupported } } #endif