Skip to content
Merged
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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!
Expand Down
11 changes: 10 additions & 1 deletion Sources/CodexBarCore/PathEnvironment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""
}
77 changes: 77 additions & 0 deletions Sources/CodexBarCore/Providers/Gemini/GeminiOAuthConfig.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
197 changes: 154 additions & 43 deletions Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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/")
Expand Down Expand Up @@ -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 = [
Expand Down Expand Up @@ -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<String>()

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)
Expand Down
26 changes: 26 additions & 0 deletions Tests/CodexBarTests/GeminiOAuthConfigTests.swift
Original file line number Diff line number Diff line change
@@ -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")
}
}
}
Loading