import Foundation public struct AntigravityOAuthCredentials: Codable, Sendable, Equatable { public var accessToken: String? public var refreshToken: String? public var expiryDateMilliseconds: Double? public var idToken: String? public var email: String? public var projectID: String? public var clientID: String? public var clientSecret: String? public init( accessToken: String?, refreshToken: String?, expiryDate: Date?, idToken: String? = nil, email: String? = nil, projectID: String? = nil, clientID: String? = nil, clientSecret: String? = nil) { self.accessToken = accessToken self.refreshToken = refreshToken self.expiryDateMilliseconds = expiryDate.map { $0.timeIntervalSince1970 * 1000 } self.idToken = idToken self.email = email self.projectID = projectID self.clientID = clientID self.clientSecret = clientSecret } public var expiryDate: Date? { guard let expiryDateMilliseconds else { return nil } return Date(timeIntervalSince1970: expiryDateMilliseconds / 1000) } /// Email of the Google account these credentials authenticate, preferring the /// signed `id_token` claim (what the remote OAuth fetcher reports) and falling /// back to the stored `email` field. Used to verify that an ambient local/CLI /// Antigravity snapshot belongs to the account the user explicitly selected. public var resolvedAccountEmail: String? { Self.email(fromIDToken: self.idToken) ?? self.email?.trimmedNonEmptyEmail } static func email(fromIDToken idToken: String?) -> String? { guard let idToken else { return nil } let parts = idToken.components(separatedBy: ".") guard parts.count >= 2 else { return nil } var payload = parts[1] .replacingOccurrences(of: "-", with: "+") .replacingOccurrences(of: "_", with: "/") let remainder = payload.count % 4 if remainder > 0 { payload += String(repeating: "=", count: 4 - remainder) } guard let data = Data(base64Encoded: payload, options: .ignoreUnknownCharacters), let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil } return (json["email"] as? String)?.trimmedNonEmptyEmail } public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.accessToken = try container.decodeIfPresent(String.self, forKey: .accessTokenSnake) ?? container.decodeIfPresent(String.self, forKey: .accessTokenCamel) self.refreshToken = try container.decodeIfPresent(String.self, forKey: .refreshTokenSnake) ?? container.decodeIfPresent(String.self, forKey: .refreshTokenCamel) self.idToken = try container.decodeIfPresent(String.self, forKey: .idTokenSnake) ?? container.decodeIfPresent(String.self, forKey: .idTokenCamel) self.email = try container.decodeIfPresent(String.self, forKey: .email) self.projectID = try container.decodeIfPresent(String.self, forKey: .projectIDSnake) ?? container.decodeIfPresent(String.self, forKey: .projectIDCamel) self.clientID = try container.decodeIfPresent(String.self, forKey: .clientIDSnake) ?? container.decodeIfPresent(String.self, forKey: .clientIDCamel) self.clientSecret = try container.decodeIfPresent(String.self, forKey: .clientSecretSnake) ?? container.decodeIfPresent(String.self, forKey: .clientSecretCamel) if let expiryDateMilliseconds = try container.decodeIfPresent(Double.self, forKey: .expiryDateSnake) ?? container.decodeIfPresent(Double.self, forKey: .expiresAtCamel) { self.expiryDateMilliseconds = expiryDateMilliseconds } else if let expiryDateMilliseconds = try container.decodeIfPresent(Int.self, forKey: .expiryDateSnake) ?? container.decodeIfPresent(Int.self, forKey: .expiresAtCamel) { self.expiryDateMilliseconds = Double(expiryDateMilliseconds) } else { self.expiryDateMilliseconds = nil } } public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encodeIfPresent(self.accessToken, forKey: .accessTokenSnake) try container.encodeIfPresent(self.refreshToken, forKey: .refreshTokenSnake) try container.encodeIfPresent(self.expiryDateMilliseconds, forKey: .expiryDateSnake) try container.encodeIfPresent(self.idToken, forKey: .idTokenSnake) try container.encodeIfPresent(self.email, forKey: .email) try container.encodeIfPresent(self.projectID, forKey: .projectIDSnake) try container.encodeIfPresent(self.clientID, forKey: .clientIDSnake) try container.encodeIfPresent(self.clientSecret, forKey: .clientSecretSnake) } enum CodingKeys: String, CodingKey { case accessTokenSnake = "access_token" case accessTokenCamel = "accessToken" case refreshTokenSnake = "refresh_token" case refreshTokenCamel = "refreshToken" case expiryDateSnake = "expiry_date" case expiresAtCamel = "expiresAt" case idTokenSnake = "id_token" case idTokenCamel = "idToken" case email case projectIDSnake = "project_id" case projectIDCamel = "projectId" case clientIDSnake = "client_id" case clientIDCamel = "clientId" case clientSecretSnake = "client_secret" case clientSecretCamel = "clientSecret" } } public struct AntigravityOAuthClient: Sendable, Equatable { public let clientID: String public let clientSecret: String public init(clientID: String, clientSecret: String) { self.clientID = clientID self.clientSecret = clientSecret } } public enum AntigravityOAuthConfig { public static var configuredClientID: String? { let value = ProcessInfo.processInfo.environment["ANTIGRAVITY_OAUTH_CLIENT_ID"] return value?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty } public static var configuredClientSecret: String? { let value = ProcessInfo.processInfo.environment["ANTIGRAVITY_OAUTH_CLIENT_SECRET"] return value?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty } public static let authURL = URL(string: "https://accounts.google.com/o/oauth2/v2/auth")! public static let tokenURL = URL(string: "https://oauth2.googleapis.com/token")! public static let userInfoURL = URL(string: "https://www.googleapis.com/oauth2/v2/userinfo")! public static let scopes = [ "https://www.googleapis.com/auth/cloud-platform", "https://www.googleapis.com/auth/userinfo.email", ] public static let missingCredentialsMessage = """ Antigravity OAuth client is not configured. Install Antigravity.app or set \ ANTIGRAVITY_OAUTH_CLIENT_ID and ANTIGRAVITY_OAUTH_CLIENT_SECRET before logging in. """ public static func resolvedClient() -> AntigravityOAuthClient? { if let client = environmentClient() { return client } return Self.discoverClientFromInstalledApp() } private static func environmentClient() -> AntigravityOAuthClient? { guard let clientID = configuredClientID, let clientSecret = configuredClientSecret else { return nil } return AntigravityOAuthClient(clientID: clientID, clientSecret: clientSecret) } static func discoverClientFromInstalledApp( applicationRoots: [URL]? = nil, fileManager: FileManager = .default) -> AntigravityOAuthClient? { for url in self.candidateOAuthClientArtifactURLs( applicationRoots: applicationRoots, fileManager: fileManager) where fileManager.fileExists(atPath: url.path) { guard let data = try? Data(contentsOf: url), let client = Self.parseClient(fromInstalledArtifactData: data) else { continue } return client } return nil } static func candidateOAuthClientArtifactURLs( applicationRoots: [URL]? = nil, fileManager: FileManager = .default) -> [URL] { let roots = [ URL(fileURLWithPath: "/Applications", isDirectory: true), fileManager.homeDirectoryForCurrentUser.appendingPathComponent("Applications", isDirectory: true), ] let applicationRoots = applicationRoots ?? roots let appBundleURLs = self.candidateAntigravityAppBundleURLs( applicationRoots: applicationRoots, fileManager: fileManager) let relativePaths = [ "Contents/Resources/app/extensions/antigravity/bin/language_server_macos_arm", "Contents/Resources/app/extensions/antigravity/bin/language_server_macos_x64", "Contents/Resources/app/extensions/antigravity/bin/language_server_macos", "Contents/Resources/app/out/main.js", "Contents/Resources/bin/language_server", "Contents/Resources/bin/language_server_macos", ] return appBundleURLs.flatMap { bundleURL in relativePaths.map { bundleURL.appendingPathComponent($0) } } } private static func candidateAntigravityAppBundleURLs( applicationRoots: [URL], fileManager: FileManager) -> [URL] { var urls: [URL] = [] for root in applicationRoots { urls.append(root.appendingPathComponent("Antigravity.app", isDirectory: true)) let appURLs = (try? fileManager.contentsOfDirectory( at: root, includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsHiddenFiles])) ?? [] for appURL in appURLs where appURL.pathExtension == "app" { guard self.isAntigravityAppBundle(appURL) else { continue } urls.append(appURL) } } var seen = Set() return urls.filter { url in let key = url.standardizedFileURL.path guard !seen.contains(key) else { return false } seen.insert(key) return true } } private static func isAntigravityAppBundle(_ url: URL) -> Bool { switch Bundle(url: url)?.bundleIdentifier { case "com.google.antigravity", "com.google.antigravity-ide": true default: false } } static func parseClient(fromInstalledArtifactData data: Data) -> AntigravityOAuthClient? { if let content = String(data: data, encoding: .utf8), let client = parseClient(fromInstalledArtifactText: content) { return client } let clientIDs = Self.clientIDs(in: data) let clientSecrets = Self.clientSecrets(in: data) guard let client = Self.preferredBinaryClient( clientIDs: clientIDs, clientSecrets: clientSecrets) else { return nil } return client } static func parseClient(fromInstalledArtifactText content: String) -> AntigravityOAuthClient? { let marker = "vs/platform/cloudCode/common/oauthClient.js" let searchStart = content.range(of: marker)?.lowerBound ?? content.startIndex let searchEnd = content.index(searchStart, offsetBy: 4000, limitedBy: content.endIndex) ?? content.endIndex let haystack = String(content[searchStart.. [String] { let suffix = Data(".apps.googleusercontent.com".utf8) var searchRange = data.startIndex.. data.startIndex { let previous = data.index(before: start) guard Self.isOAuthClientIDPrefixByte(data[previous]) else { break } start = previous } let candidateData = Data(data[start.. [String] { let prefix = Data("GOCSPX-".utf8) let secretLength = 35 var searchRange = data.startIndex.. AntigravityOAuthClient? { guard !clientIDs.isEmpty, !clientSecrets.isEmpty else { return nil } if clientSecrets.count == 1, clientIDs.count > 1 { return AntigravityOAuthClient(clientID: clientIDs[clientIDs.count - 1], clientSecret: clientSecrets[0]) } let clientSecret: String = if clientSecrets.count == clientIDs.count, clientSecrets.count > 1 { // Antigravity 2's language_server binary stores the secret table before the client id table. clientSecrets[clientSecrets.count - 1] } else { clientSecrets[0] } return AntigravityOAuthClient(clientID: clientIDs[0], clientSecret: clientSecret) } private static func isOAuthClientIDPrefixByte(_ byte: UInt8) -> Bool { (byte >= 48 && byte <= 57) || (byte >= 65 && byte <= 90) || (byte >= 97 && byte <= 122) || byte == 45 || byte == 95 } private static func isOAuthClientSecretByte(_ byte: UInt8) -> Bool { self.isOAuthClientIDPrefixByte(byte) } private static func firstMatch(pattern: String, in text: String) -> String? { guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil } let range = NSRange(text.startIndex.. [String] { var seen = Set() return values.filter { value in guard !seen.contains(value) else { return false } seen.insert(value) return true } } } public struct AntigravityOAuthCredentialsStore: @unchecked Sendable { public static let environmentCredentialsKey = "ANTIGRAVITY_OAUTH_CREDENTIALS_JSON" private static let fileLock = NSLock() public let fileURL: URL private let fileManager: FileManager public init(fileURL: URL = Self.defaultURL(), fileManager: FileManager = .default) { self.fileURL = fileURL self.fileManager = fileManager } public func load() throws -> AntigravityOAuthCredentials? { try Self.fileLock.withLock { try self.loadUnlocked() } } private func loadUnlocked() throws -> AntigravityOAuthCredentials? { guard self.fileManager.fileExists(atPath: self.fileURL.path) else { return nil } let data = try Data(contentsOf: self.fileURL) return try JSONDecoder().decode(AntigravityOAuthCredentials.self, from: data) } public func save(_ credentials: AntigravityOAuthCredentials) throws { try Self.fileLock.withLock { let data = try JSONEncoder.antigravityCredentials.encode(credentials) let directory = self.fileURL.deletingLastPathComponent() if !self.fileManager.fileExists(atPath: directory.path) { try self.fileManager.createDirectory(at: directory, withIntermediateDirectories: true) } try data.write(to: self.fileURL, options: [.atomic]) try self.applySecurePermissionsIfNeeded() } } public func deleteIfPresent() throws { try Self.fileLock.withLock { try self.deleteIfPresentUnlocked() } } public func deleteIfPresent( matching predicate: (AntigravityOAuthCredentials) -> Bool) throws { try Self.fileLock.withLock { guard let credentials = try self.loadUnlocked(), predicate(credentials) else { return } try self.deleteIfPresentUnlocked() } } private func deleteIfPresentUnlocked() throws { guard self.fileManager.fileExists(atPath: self.fileURL.path) else { return } try self.fileManager.removeItem(at: self.fileURL) } public static func defaultDirectoryURL(home: URL = FileManager.default.homeDirectoryForCurrentUser) -> URL { home .appendingPathComponent(".codexbar", isDirectory: true) .appendingPathComponent("antigravity", isDirectory: true) } public static func defaultURL(home: URL = FileManager.default.homeDirectoryForCurrentUser) -> URL { self.defaultDirectoryURL(home: home) .appendingPathComponent("oauth_creds.json") } public static func tokenAccountValue(for credentials: AntigravityOAuthCredentials) throws -> String { let data = try JSONEncoder.antigravityCredentials.encode(credentials) guard let value = String(data: data, encoding: .utf8) else { throw CocoaError(.coderInvalidValue) } return value } public static func credentials(fromTokenAccountValue value: String) -> AntigravityOAuthCredentials? { let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty, let data = trimmed.data(using: .utf8) else { return nil } return try? JSONDecoder().decode(AntigravityOAuthCredentials.self, from: data) } private func applySecurePermissionsIfNeeded() throws { #if os(macOS) || os(Linux) try self.fileManager.setAttributes([ .posixPermissions: NSNumber(value: Int16(0o600)), ], ofItemAtPath: self.fileURL.path) #endif } } extension JSONEncoder { fileprivate static let antigravityCredentials: JSONEncoder = { let encoder = JSONEncoder() encoder.outputFormatting = [.prettyPrinted, .sortedKeys] return encoder }() } extension String { fileprivate var nilIfEmpty: String? { self.isEmpty ? nil : self } fileprivate var trimmedNonEmptyEmail: String? { let trimmed = self.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? nil : trimmed } }