diff --git a/CHANGELOG.md b/CHANGELOG.md index 707ae54c3e..2fb5ef62a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Settings: split provider pane "Settings" sections into "Menu bar" and "Connection" so metric pickers and auth/cookie/source controls are grouped by topic. ### Fixed +- Gemini: recover expired Workspace and education OAuth sessions when current CLI packages omit `oauth2.js`, with explicit credential and install-path discovery fallbacks. Thanks @Yuxin-Qiao! - Settings: render section footer captions (Advanced keychain note, refresh hints, quota-warning and provider subtitles) leading-aligned in footnote size instead of the trailing-aligned body text macOS gives bare form footers. - Claude OAuth: remember an acknowledged CodexBar Keychain explanation for six hours without suppressing macOS authorization or either Keychain opt-out (#1990). Thanks @harjothkhara! - Codex accounts: find the Codex CLI bundled with ChatGPT when it is absent from shell PATH, restoring Add Account after the desktop apps merged (#2044). Thanks @sep1107! diff --git a/Sources/CodexBarCore/PathEnvironment.swift b/Sources/CodexBarCore/PathEnvironment.swift index 0bd1191874..b598c333a5 100644 --- a/Sources/CodexBarCore/PathEnvironment.swift +++ b/Sources/CodexBarCore/PathEnvironment.swift @@ -46,6 +46,10 @@ public struct PathDebugSnapshot: Equatable, Sendable { } public enum BinaryLocator { + /// Test-only override so parallel Gemini suites can point at fake binaries + /// without mutating process-wide `GEMINI_CLI_PATH`. + @TaskLocal public static var geminiBinaryPathOverrideForTesting: String? + public static func resolveClaudeBinary( env: [String: String] = ProcessInfo.processInfo.environment, loginPATH: [String]? = LoginShellPathCache.shared.current, @@ -154,7 +158,12 @@ public enum BinaryLocator { fileManager: FileManager = .default, home: String = NSHomeDirectory()) -> String? { - self.resolveBinary( + if let override = self.geminiBinaryPathOverrideForTesting, + fileManager.isExecutableFile(atPath: override) + { + return override + } + return self.resolveBinary( name: "gemini", overrideKey: "GEMINI_CLI_PATH", env: env, diff --git a/Sources/CodexBarCore/Providers/Gemini/GeminiConsumerTierMigration.swift b/Sources/CodexBarCore/Providers/Gemini/GeminiConsumerTierMigration.swift index 2cb0be8346..36ee22b27a 100644 --- a/Sources/CodexBarCore/Providers/Gemini/GeminiConsumerTierMigration.swift +++ b/Sources/CodexBarCore/Providers/Gemini/GeminiConsumerTierMigration.swift @@ -6,4 +6,11 @@ public enum GeminiConsumerTierMigration { Google no longer supports Gemini CLI OAuth for individual, AI Pro, or Ultra accounts. \ Enable CodexBar's Antigravity provider, sign in to Antigravity or run `agy`, then refresh. """ + + public static let oauthRecoveryError = """ + Could not refresh Gemini OAuth credentials. Reinstall or update Gemini CLI, or set \ + GEMINI_OAUTH_CLIENT_ID and GEMINI_OAUTH_CLIENT_SECRET. Consumer Google AI Pro/Ultra \ + accounts blocked by the June 2026 Gemini CLI shutdown should use CodexBar's Antigravity \ + provider instead. Workspace and education accounts should keep using Gemini. + """ } diff --git a/Sources/CodexBarCore/Providers/Gemini/GeminiOAuthConfig.swift b/Sources/CodexBarCore/Providers/Gemini/GeminiOAuthConfig.swift new file mode 100644 index 0000000000..446364896b --- /dev/null +++ b/Sources/CodexBarCore/Providers/Gemini/GeminiOAuthConfig.swift @@ -0,0 +1,77 @@ +import Foundation + +/// Gemini CLI OAuth client resolution helpers for token refresh. +/// Mirrors Antigravity's env override pattern. +public enum GeminiOAuthConfig: Sendable { + public struct ClientCredentials: Sendable, Equatable { + public let clientID: String + public let clientSecret: String + + public init(clientID: String, clientSecret: String) { + self.clientID = clientID + self.clientSecret = clientSecret + } + } + + /// Test-injectable environment view so suites can override OAuth knobs without + /// mutating process-wide env (which races parallel Gemini suites). + public struct EnvironmentValues: Sendable, Equatable { + public var clientID: String? + public var clientSecret: String? + public var oauth2JSPath: String? + + public init(clientID: String? = nil, clientSecret: String? = nil, oauth2JSPath: String? = nil) { + self.clientID = clientID + self.clientSecret = clientSecret + self.oauth2JSPath = oauth2JSPath + } + + public static func fromProcessEnvironment( + _ environment: [String: String] = ProcessInfo.processInfo.environment) -> Self + { + Self( + clientID: environment["GEMINI_OAUTH_CLIENT_ID"], + clientSecret: environment["GEMINI_OAUTH_CLIENT_SECRET"], + oauth2JSPath: environment["GEMINI_OAUTH2_JS_PATH"]) + } + } + + @TaskLocal public static var environmentOverride: EnvironmentValues? + + public static var currentEnvironment: EnvironmentValues { + self.environmentOverride ?? .fromProcessEnvironment() + } + + public static var configuredClientID: String? { + self.currentEnvironment.clientID? + .trimmingCharacters(in: .whitespacesAndNewlines) + .nilIfEmpty + } + + public static var configuredClientSecret: String? { + self.currentEnvironment.clientSecret? + .trimmingCharacters(in: .whitespacesAndNewlines) + .nilIfEmpty + } + + public static var configuredOAuth2JSPath: String? { + self.currentEnvironment.oauth2JSPath? + .trimmingCharacters(in: .whitespacesAndNewlines) + .nilIfEmpty + } + + public static func environmentClient() -> ClientCredentials? { + guard let clientID = configuredClientID, + let clientSecret = configuredClientSecret + else { + return nil + } + return ClientCredentials(clientID: clientID, clientSecret: clientSecret) + } +} + +extension String { + fileprivate var nilIfEmpty: String? { + self.isEmpty ? nil : self + } +} diff --git a/Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe.swift b/Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe.swift index bb783d2a21..27c46d8f07 100644 --- a/Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe.swift @@ -499,52 +499,11 @@ public struct GeminiStatusProbe: Sendable { let expiryDate: Date? } - private struct OAuthClientCredentials { + fileprivate struct OAuthClientCredentials { let clientId: String let clientSecret: String } - private static func extractOAuthCredentials() -> OAuthClientCredentials? { - let env = ProcessInfo.processInfo.environment - - // Find the gemini binary - guard let geminiPath = BinaryLocator.resolveGeminiBinary( - env: env, - loginPATH: LoginShellPathCache.shared.current) - ?? TTYCommandRunner.which("gemini") - else { - return nil - } - - // Resolve symlinks to find the actual installation - let resolvedGeminiPath = URL(fileURLWithPath: geminiPath).resolvingSymlinksInPath().path - - // Try the legacy layouts first — they're cheap file reads and cover the common cases - // (Homebrew, npm/bun sibling, Nix) without spawning subprocesses or walking the tree. - if let credentials = Self.extractOAuthCredentialsFromLegacyPaths(realGeminiPath: resolvedGeminiPath) { - return credentials - } - - // For fnm-managed installs, ask fnm where the package lives - if Self.isLikelyFnmManagedPath(geminiPath) || Self.isLikelyFnmManagedPath(resolvedGeminiPath), - let fnmPath = Self.resolveExecutableOnEnvironmentPath(named: "fnm", environment: env) - ?? TTYCommandRunner.which("fnm"), - let packageRoot = Self.resolveGeminiPackageRootViaFnm(fnmPath: fnmPath, environment: env), - let credentials = Self.extractOAuthCredentials(fromGeminiPackageRoot: packageRoot) - { - return credentials - } - - // Fall back to walking up the directory tree from the binary - if let packageRoot = Self.findGeminiPackageRoot(startingAt: resolvedGeminiPath), - let credentials = Self.extractOAuthCredentials(fromGeminiPackageRoot: packageRoot) - { - return credentials - } - - return nil - } - private static func isLikelyFnmManagedPath(_ path: String) -> Bool { let normalized = path.replacingOccurrences(of: "\\", with: "/") return normalized.contains("/fnm_multishells/") @@ -859,7 +818,7 @@ public struct GeminiStatusProbe: Sendable { guard let oauthCreds = Self.extractOAuthCredentials() else { Self.log.error("Could not extract OAuth credentials from Gemini CLI") - throw GeminiStatusProbeError.apiError("Could not find Gemini CLI OAuth configuration") + throw GeminiStatusProbeError.apiError(GeminiConsumerTierMigration.oauthRecoveryError) } let body = [ @@ -1135,6 +1094,158 @@ public struct GeminiStatusProbe: Sendable { } extension GeminiStatusProbe { + fileprivate static func extractOAuthCredentials() -> OAuthClientCredentials? { + if let resolved = GeminiOAuthConfig.environmentClient() { + return OAuthClientCredentials(clientId: resolved.clientID, clientSecret: resolved.clientSecret) + } + if let path = GeminiOAuthConfig.configuredOAuth2JSPath, + let credentials = Self.parseOAuthCredentials(fromFile: path) + { + return credentials + } + if let credentials = Self.discoverOAuthCredentialsFromInstalledCLI() { + return OAuthClientCredentials(clientId: credentials.clientID, clientSecret: credentials.clientSecret) + } + if let credentials = Self.discoverOAuthCredentialsFromKnownInstallPaths() { + return credentials + } + return nil + } + + /// Optional Homebrew/npm prefixes for last-resort discovery when the gemini + /// binary cannot be resolved (GUI PATH / sandbox). Tests inject fake Cellar trees. + @TaskLocal public static var knownInstallPrefixesForTesting: [String]? + + fileprivate static func discoverOAuthCredentialsFromKnownInstallPaths() -> OAuthClientCredentials? { + let oauthFile = "dist/src/code_assist/oauth2.js" + let nestedOAuthFile = + "node_modules/@google/gemini-cli/node_modules/@google/gemini-cli-core/\(oauthFile)" + let home = FileManager.default.homeDirectoryForCurrentUser.path + + // When tests inject Homebrew prefixes, skip the hard-coded host paths so + // fixture Cellar trees are not shadowed by the developer's real install. + if Self.knownInstallPrefixesForTesting == nil { + let possiblePaths = [ + "/opt/homebrew/lib/node_modules/@google/gemini-cli-core/\(oauthFile)", + "/opt/homebrew/lib/\(nestedOAuthFile)", + "/usr/local/lib/node_modules/@google/gemini-cli-core/\(oauthFile)", + "/usr/local/lib/\(nestedOAuthFile)", + "\(home)/.npm-global/lib/node_modules/@google/gemini-cli-core/\(oauthFile)", + "\(home)/.npm-global/lib/\(nestedOAuthFile)", + ] + + for path in possiblePaths { + if let credentials = Self.parseOAuthCredentials(fromFile: path) { + return credentials + } + } + } + + // Homebrew Cellar/opt libexec layouts keep credentials inside the package + // bundle when GUI PATH cannot resolve `/opt/homebrew/bin/gemini`. + for packageRoot in Self.knownHomebrewGeminiPackageRoots() { + if let credentials = Self.extractOAuthCredentials(fromGeminiPackageRoot: packageRoot) { + return credentials + } + } + return nil + } + + fileprivate static func knownHomebrewPrefixes() -> [String] { + if let overrides = self.knownInstallPrefixesForTesting, !overrides.isEmpty { + return overrides + } + return ["/opt/homebrew", "/usr/local"] + } + + fileprivate static func knownHomebrewGeminiPackageRoots( + fileManager: FileManager = .default) -> [String] + { + var roots: [String] = [] + var seen = Set() + + func appendPackageRoot(under prefixRoot: String) { + let packageRoot = URL(fileURLWithPath: prefixRoot) + .appendingPathComponent("libexec") + .appendingPathComponent("lib") + .appendingPathComponent("node_modules") + .appendingPathComponent("@google") + .appendingPathComponent("gemini-cli") + .path + guard fileManager.fileExists(atPath: packageRoot), + seen.insert(packageRoot).inserted + else { + return + } + roots.append(packageRoot) + } + + for prefix in Self.knownHomebrewPrefixes() { + let optRoot = "\(prefix)/opt/gemini-cli" + if fileManager.fileExists(atPath: optRoot) { + appendPackageRoot(under: optRoot) + } + + let cellarRoot = "\(prefix)/Cellar/gemini-cli" + guard let versions = try? fileManager.contentsOfDirectory(atPath: cellarRoot) else { + continue + } + for version in versions.sorted() { + appendPackageRoot(under: "\(cellarRoot)/\(version)") + } + } + + return roots + } + + fileprivate static func parseOAuthCredentials(fromFile path: String) -> OAuthClientCredentials? { + guard let content = try? String(contentsOfFile: path, encoding: .utf8) else { + return nil + } + return Self.parseOAuthCredentials(from: content) + } + + fileprivate static func discoverOAuthCredentialsFromInstalledCLI() -> GeminiOAuthConfig.ClientCredentials? { + let env = ProcessInfo.processInfo.environment + + guard let geminiPath = BinaryLocator.resolveGeminiBinary( + env: env, + loginPATH: LoginShellPathCache.shared.current) + ?? TTYCommandRunner.which("gemini") + else { + return nil + } + + let resolvedGeminiPath = URL(fileURLWithPath: geminiPath).resolvingSymlinksInPath().path + + if let credentials = Self.extractOAuthCredentialsFromLegacyPaths(realGeminiPath: resolvedGeminiPath) { + return GeminiOAuthConfig.ClientCredentials( + clientID: credentials.clientId, + clientSecret: credentials.clientSecret) + } + + if Self.isLikelyFnmManagedPath(geminiPath) || Self.isLikelyFnmManagedPath(resolvedGeminiPath), + let fnmPath = Self.resolveExecutableOnEnvironmentPath(named: "fnm", environment: env) + ?? TTYCommandRunner.which("fnm"), + let packageRoot = Self.resolveGeminiPackageRootViaFnm(fnmPath: fnmPath, environment: env), + let credentials = Self.extractOAuthCredentials(fromGeminiPackageRoot: packageRoot) + { + return GeminiOAuthConfig.ClientCredentials( + clientID: credentials.clientId, + clientSecret: credentials.clientSecret) + } + + if let packageRoot = Self.findGeminiPackageRoot(startingAt: resolvedGeminiPath), + let credentials = Self.extractOAuthCredentials(fromGeminiPackageRoot: packageRoot) + { + return GeminiOAuthConfig.ClientCredentials( + clientID: credentials.clientId, + clientSecret: credentials.clientSecret) + } + + return nil + } + /// Plan display strings with tier mapping: /// - paidTier.name: Most-specific paid subscription label from Google, regardless of currentTier /// - standard-tier: Paid subscription fallback (Code Assist Standard/Enterprise, Developer Program Premium) diff --git a/Tests/CodexBarTests/GeminiOAuthConfigTests.swift b/Tests/CodexBarTests/GeminiOAuthConfigTests.swift new file mode 100644 index 0000000000..25ee303418 --- /dev/null +++ b/Tests/CodexBarTests/GeminiOAuthConfigTests.swift @@ -0,0 +1,26 @@ +import CodexBarCore +import Foundation +import Testing + +@Suite(.serialized) +struct GeminiOAuthConfigTests { + @Test + func `environment client requires both id and secret`() { + let values = GeminiOAuthConfig.EnvironmentValues(clientID: "env-id", clientSecret: nil) + GeminiOAuthConfig.$environmentOverride.withValue(values) { + #expect(GeminiOAuthConfig.environmentClient() == nil) + } + } + + @Test + func `environment client returns configured credentials`() { + let values = GeminiOAuthConfig.EnvironmentValues( + clientID: "env-id", + clientSecret: "env-secret") + GeminiOAuthConfig.$environmentOverride.withValue(values) { + let resolved = GeminiOAuthConfig.environmentClient() + #expect(resolved?.clientID == "env-id") + #expect(resolved?.clientSecret == "env-secret") + } + } +} diff --git a/Tests/CodexBarTests/GeminiOAuthRecoveryAPITests.swift b/Tests/CodexBarTests/GeminiOAuthRecoveryAPITests.swift new file mode 100644 index 0000000000..b99b24efbf --- /dev/null +++ b/Tests/CodexBarTests/GeminiOAuthRecoveryAPITests.swift @@ -0,0 +1,249 @@ +import CodexBarCore +import Foundation +import Testing + +@Suite(.serialized) +struct GeminiOAuthRecoveryAPITests { + @Test + func `explicit oauth2 js path overrides installed gemini cli`() async throws { + let env = try GeminiTestEnvironment() + defer { env.cleanup() } + try env.writeCredentials( + accessToken: "old-token", + refreshToken: "refresh-token", + expiry: Date().addingTimeInterval(-3600), + idToken: GeminiAPITestHelpers.makeIDToken(email: "user@example.com")) + + let oauthURL = env.homeURL.appendingPathComponent("oauth2.js") + try """ + const OAUTH_CLIENT_ID = 'path-client-id'; + const OAUTH_CLIENT_SECRET = 'path-client-secret'; + """.write(to: oauthURL, atomically: true, encoding: .utf8) + + let binURL = try env.writeFakeGeminiCLI() + let oauthEnv = GeminiOAuthConfig.EnvironmentValues(oauth2JSPath: oauthURL.path) + let dataLoader = GeminiAPITestHelpers.dataLoader { request in + guard let url = request.url, let host = url.host else { + throw URLError(.badURL) + } + + switch host { + case "oauth2.googleapis.com": + let body = request.httpBody.flatMap { String(data: $0, encoding: .utf8) } ?? "" + guard body.contains("client_id=path-client-id") else { + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 400, body: Data()) + } + let json = GeminiAPITestHelpers.jsonData([ + "access_token": "new-token", + "expires_in": 3600, + "id_token": GeminiAPITestHelpers.makeIDToken(email: "user@example.com"), + ]) + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 200, body: json) + case "cloudresourcemanager.googleapis.com": + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.jsonData(["projects": []])) + case "cloudcode-pa.googleapis.com": + if url.path == "/v1internal:loadCodeAssist" { + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.loadCodeAssistStandardTierResponse()) + } + if url.path != "/v1internal:retrieveUserQuota" { + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data()) + } + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.sampleQuotaResponse()) + default: + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data()) + } + } + + let probe = GeminiStatusProbe(timeout: 2, homeDirectory: env.homeURL.path, dataLoader: dataLoader) + let snapshot = try await BinaryLocator.$geminiBinaryPathOverrideForTesting.withValue(binURL.path) { + try await GeminiOAuthConfig.$environmentOverride.withValue(oauthEnv) { + try await probe.fetch() + } + } + #expect(snapshot.accountPlan == "Paid") + } + + @Test + func `prefers environment oauth client over installed gemini cli`() async throws { + let env = try GeminiTestEnvironment() + defer { env.cleanup() } + try env.writeCredentials( + accessToken: "old-token", + refreshToken: "refresh-token", + expiry: Date().addingTimeInterval(-3600), + idToken: nil) + + let binURL = try env.writeFakeGeminiCLI() + let oauthEnv = GeminiOAuthConfig.EnvironmentValues( + clientID: "env-client-id", + clientSecret: "env-client-secret") + let dataLoader = GeminiAPITestHelpers.dataLoader { request in + guard let url = request.url, let host = url.host else { + throw URLError(.badURL) + } + switch host { + case "oauth2.googleapis.com": + let body = request.httpBody.flatMap { String(data: $0, encoding: .utf8) } ?? "" + guard body.contains("client_id=env-client-id"), + body.contains("client_secret=env-client-secret") + else { + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 400, body: Data()) + } + let json = GeminiAPITestHelpers.jsonData([ + "access_token": "new-token", + "expires_in": 3600, + ]) + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 200, body: json) + case "cloudresourcemanager.googleapis.com": + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.jsonData(["projects": []])) + case "cloudcode-pa.googleapis.com": + if url.path == "/v1internal:loadCodeAssist" { + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.loadCodeAssistFreeTierResponse()) + } + if url.path != "/v1internal:retrieveUserQuota" { + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data()) + } + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.sampleFlashQuotaResponse()) + default: + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data()) + } + } + + let probe = GeminiStatusProbe(timeout: 2, homeDirectory: env.homeURL.path, dataLoader: dataLoader) + _ = try await BinaryLocator.$geminiBinaryPathOverrideForTesting.withValue(binURL.path) { + try await GeminiOAuthConfig.$environmentOverride.withValue(oauthEnv) { + try await probe.fetch() + } + } + } + + @Test + func `refreshes via known Homebrew Cellar libexec path without gemini binary`() async throws { + let env = try GeminiTestEnvironment() + defer { env.cleanup() } + try env.writeCredentials( + accessToken: "old-token", + refreshToken: "refresh-token", + expiry: Date().addingTimeInterval(-3600), + idToken: GeminiAPITestHelpers.makeIDToken(email: "user@example.com")) + + // Resolvable gemini binary exists but omits OAuth config; Cellar package root + // under a synthetic Homebrew prefix holds the credentials instead. + let binURL = try env.writeFakeGeminiCLI(includeOAuth: false, layout: .npmNested) + let homebrewPrefix = env.homeURL.appendingPathComponent("homebrew-prefix") + try Self.plantHomebrewCellarGeminiPackage( + under: homebrewPrefix, + clientID: "cellar-client-id", + clientSecret: "cellar-client-secret") + + let clearOAuthEnv = GeminiOAuthConfig.EnvironmentValues() + let dataLoader = GeminiAPITestHelpers.dataLoader { request in + guard let url = request.url, let host = url.host else { + throw URLError(.badURL) + } + + switch host { + case "oauth2.googleapis.com": + let body = request.httpBody.flatMap { String(data: $0, encoding: .utf8) } ?? "" + guard body.contains("client_id=cellar-client-id") else { + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 400, body: Data()) + } + let json = GeminiAPITestHelpers.jsonData([ + "access_token": "new-token", + "expires_in": 3600, + "id_token": GeminiAPITestHelpers.makeIDToken(email: "user@example.com"), + ]) + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 200, body: json) + case "cloudresourcemanager.googleapis.com": + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.jsonData(["projects": []])) + case "cloudcode-pa.googleapis.com": + guard request.value(forHTTPHeaderField: "Authorization") == "Bearer new-token" else { + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 401, body: Data()) + } + if url.path == "/v1internal:loadCodeAssist" { + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.loadCodeAssistStandardTierResponse()) + } + if url.path != "/v1internal:retrieveUserQuota" { + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data()) + } + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.sampleQuotaResponse()) + default: + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data()) + } + } + + let probe = GeminiStatusProbe(timeout: 2, homeDirectory: env.homeURL.path, dataLoader: dataLoader) + let snapshot = try await BinaryLocator.$geminiBinaryPathOverrideForTesting.withValue(binURL.path) { + try await GeminiOAuthConfig.$environmentOverride.withValue(clearOAuthEnv) { + try await GeminiStatusProbe.$knownInstallPrefixesForTesting.withValue([homebrewPrefix.path]) { + try await probe.fetch() + } + } + } + #expect(snapshot.accountPlan == "Paid") + } + + private static func plantHomebrewCellarGeminiPackage( + under prefix: URL, + clientID: String, + clientSecret: String) throws + { + let packageRoot = prefix + .appendingPathComponent("Cellar") + .appendingPathComponent("gemini-cli") + .appendingPathComponent("0.41.2") + .appendingPathComponent("libexec") + .appendingPathComponent("lib") + .appendingPathComponent("node_modules") + .appendingPathComponent("@google") + .appendingPathComponent("gemini-cli") + let bundleDir = packageRoot.appendingPathComponent("bundle") + try FileManager.default.createDirectory(at: bundleDir, withIntermediateDirectories: true) + try """ + { + "name": "@google/gemini-cli" + } + """.write( + to: packageRoot.appendingPathComponent("package.json"), + atomically: true, + encoding: .utf8) + try "#!/usr/bin/env node\nawait import('./chunk-OAUTH.js');\n".write( + to: bundleDir.appendingPathComponent("gemini.js"), + atomically: true, + encoding: .utf8) + try """ + var OAUTH_CLIENT_ID = "\(clientID)"; + var OAUTH_CLIENT_SECRET = "\(clientSecret)"; + """.write( + to: bundleDir.appendingPathComponent("chunk-OAUTH.js"), + atomically: true, + encoding: .utf8) + } +} diff --git a/Tests/CodexBarTests/GeminiStatusProbeAPITests.swift b/Tests/CodexBarTests/GeminiStatusProbeAPITests.swift index 8ea2650e96..3fcb60ade9 100644 --- a/Tests/CodexBarTests/GeminiStatusProbeAPITests.swift +++ b/Tests/CodexBarTests/GeminiStatusProbeAPITests.swift @@ -679,33 +679,6 @@ struct GeminiStatusProbeAPITests { #expect(counts.fallback == 0) } - @Test - func `fails refresh when O auth config missing`() async throws { - let env = try GeminiTestEnvironment() - defer { env.cleanup() } - try env.writeCredentials( - accessToken: "old-token", - refreshToken: "refresh-token", - expiry: Date().addingTimeInterval(-3600), - idToken: nil) - - let binURL = try env.writeFakeGeminiCLI(includeOAuth: false) - let previousValue = ProcessInfo.processInfo.environment["GEMINI_CLI_PATH"] - setenv("GEMINI_CLI_PATH", binURL.path, 1) - defer { - if let previousValue { - setenv("GEMINI_CLI_PATH", previousValue, 1) - } else { - unsetenv("GEMINI_CLI_PATH") - } - } - - let probe = GeminiStatusProbe(timeout: 1, homeDirectory: env.homeURL.path) - await Self.expectError(.apiError("Could not find Gemini CLI OAuth configuration")) { - _ = try await probe.fetch() - } - } - @Test func `reports api errors`() async throws { let env = try GeminiTestEnvironment() diff --git a/docs/gemini.md b/docs/gemini.md index 9d1d615101..de133f717d 100644 --- a/docs/gemini.md +++ b/docs/gemini.md @@ -27,13 +27,24 @@ Gemini uses the Gemini CLI OAuth credentials and private quota APIs. No browser from the Gemini CLI install (see below). ## OAuth client ID/secret extraction +- Resolution order: + 1. `GEMINI_OAUTH_CLIENT_ID` + `GEMINI_OAUTH_CLIENT_SECRET` environment override. + 2. `GEMINI_OAUTH2_JS_PATH` pointing at a readable `oauth2.js` file. + 3. Installed Gemini CLI package (`oauth2.js` / bundle regex extraction). + 4. Known global Gemini CLI install paths (Homebrew npm prefix layouts, then + Homebrew Cellar/`opt` `libexec` package roots when the GUI cannot resolve + the `gemini` binary). - We locate the installed `gemini` binary, then search for: - Homebrew nested path: - `.../libexec/lib/node_modules/@google/gemini-cli/node_modules/@google/gemini-cli-core/dist/src/code_assist/oauth2.js` + - Homebrew Cellar/opt package root (no-binary fallback): + - `/opt/homebrew/Cellar/gemini-cli//libexec/lib/node_modules/@google/gemini-cli` + - `/opt/homebrew/opt/gemini-cli/libexec/lib/node_modules/@google/gemini-cli` + - same under `/usr/local` - Bun/npm sibling path: - `.../node_modules/@google/gemini-cli-core/dist/src/code_assist/oauth2.js` - Regex extraction: - - `OAUTH_CLIENT_ID` and `OAUTH_CLIENT_SECRET` from `oauth2.js`. + - `OAUTH_CLIENT_ID` and `OAUTH_CLIENT_SECRET` from `oauth2.js` or Homebrew bundle chunks. ## API endpoints - Quota: @@ -83,6 +94,8 @@ Gemini uses the Gemini CLI OAuth credentials and private quota APIs. No browser - The action is explicit: CodexBar never automatically enables Antigravity or falls back to it. - Ordinary Gemini login, `notLoggedIn`, and Antigravity setup errors remain unchanged. CodexBar does not capture Terminal `gemini` OAuth output, so Terminal-only failures cannot activate the migration action. +- Workspace and education Google accounts are outside the June 2026 consumer shutdown; keep using the + Gemini provider. Antigravity remains the consumer replacement path for individual, AI Pro, and Ultra. ## Key files - `Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe.swift`