From 67604373bfeaa05b5acb79bec0681df73847b2be Mon Sep 17 00:00:00 2001 From: Yuxin Qiao Date: Wed, 8 Jul 2026 11:40:00 +0800 Subject: [PATCH 1/8] Fix Gemini OAuth refresh when CLI oauth2.js is missing Add env overrides, GEMINI_OAUTH2_JS_PATH, and known global install-path discovery so token refresh can proceed without a resolvable gemini binary layout. Surface combined Gemini/Antigravity recovery guidance when OAuth client discovery still fails. Fixes steipete/CodexBar#1978 Co-authored-by: Cursor --- .../Gemini/GeminiConsumerTierMigration.swift | 7 + .../Providers/Gemini/GeminiOAuthConfig.swift | 45 ++++++ .../Providers/Gemini/GeminiStatusProbe.swift | 67 ++++++-- .../GeminiOAuthConfigTests.swift | 55 +++++++ .../GeminiStatusProbeAPITests.swift | 153 +++++++++++++++++- docs/gemini.md | 7 + 6 files changed, 316 insertions(+), 18 deletions(-) create mode 100644 Sources/CodexBarCore/Providers/Gemini/GeminiOAuthConfig.swift create mode 100644 Tests/CodexBarTests/GeminiOAuthConfigTests.swift 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..e76b355b5b --- /dev/null +++ b/Sources/CodexBarCore/Providers/Gemini/GeminiOAuthConfig.swift @@ -0,0 +1,45 @@ +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 + } + } + + public static var configuredClientID: String? { + let value = ProcessInfo.processInfo.environment["GEMINI_OAUTH_CLIENT_ID"] + return value?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty + } + + public static var configuredClientSecret: String? { + let value = ProcessInfo.processInfo.environment["GEMINI_OAUTH_CLIENT_SECRET"] + return value?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty + } + + public static var configuredOAuth2JSPath: String? { + let value = ProcessInfo.processInfo.environment["GEMINI_OAUTH2_JS_PATH"] + return value?.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..0017bc655e 100644 --- a/Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe.swift @@ -505,9 +505,55 @@ public struct GeminiStatusProbe: Sendable { } private static func extractOAuthCredentials() -> OAuthClientCredentials? { + if let resolved = GeminiOAuthConfig.environmentClient() { + return OAuthClientCredentials(clientId: resolved.clientID, clientSecret: resolved.clientSecret) + } + if let credentials = Self.discoverOAuthCredentialsFromInstalledCLI() { + return OAuthClientCredentials(clientId: credentials.clientID, clientSecret: credentials.clientSecret) + } + if let path = GeminiOAuthConfig.configuredOAuth2JSPath, + let credentials = Self.parseOAuthCredentials(fromFile: path) + { + return credentials + } + if let credentials = Self.discoverOAuthCredentialsFromKnownInstallPaths() { + return credentials + } + return nil + } + + private 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 + 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 + } + } + return nil + } + + private static func parseOAuthCredentials(fromFile path: String) -> OAuthClientCredentials? { + guard let content = try? String(contentsOfFile: path, encoding: .utf8) else { + return nil + } + return Self.parseOAuthCredentials(from: content) + } + + private static func discoverOAuthCredentialsFromInstalledCLI() -> GeminiOAuthConfig.ClientCredentials? { let env = ProcessInfo.processInfo.environment - // Find the gemini binary guard let geminiPath = BinaryLocator.resolveGeminiBinary( env: env, loginPATH: LoginShellPathCache.shared.current) @@ -516,30 +562,31 @@ public struct GeminiStatusProbe: Sendable { 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 + return GeminiOAuthConfig.ClientCredentials( + clientID: credentials.clientId, + clientSecret: credentials.clientSecret) } - // 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 + return GeminiOAuthConfig.ClientCredentials( + clientID: credentials.clientId, + clientSecret: credentials.clientSecret) } - // 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 GeminiOAuthConfig.ClientCredentials( + clientID: credentials.clientId, + clientSecret: credentials.clientSecret) } return nil @@ -859,7 +906,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 = [ diff --git a/Tests/CodexBarTests/GeminiOAuthConfigTests.swift b/Tests/CodexBarTests/GeminiOAuthConfigTests.swift new file mode 100644 index 0000000000..8431d03ba3 --- /dev/null +++ b/Tests/CodexBarTests/GeminiOAuthConfigTests.swift @@ -0,0 +1,55 @@ +import CodexBarCore +import Foundation +import Testing +#if canImport(Darwin) +import Darwin +#else +import Glibc +#endif + +@Suite(.serialized) +struct GeminiOAuthConfigTests { + @Test + func `environment client requires both id and secret`() { + let previousClientID = ProcessInfo.processInfo.environment["GEMINI_OAUTH_CLIENT_ID"] + let previousClientSecret = ProcessInfo.processInfo.environment["GEMINI_OAUTH_CLIENT_SECRET"] + setenv("GEMINI_OAUTH_CLIENT_ID", "env-id", 1) + unsetenv("GEMINI_OAUTH_CLIENT_SECRET") + defer { + if let previousClientID { + setenv("GEMINI_OAUTH_CLIENT_ID", previousClientID, 1) + } else { + unsetenv("GEMINI_OAUTH_CLIENT_ID") + } + if let previousClientSecret { + setenv("GEMINI_OAUTH_CLIENT_SECRET", previousClientSecret, 1) + } + } + + #expect(GeminiOAuthConfig.environmentClient() == nil) + } + + @Test + func `environment client returns configured credentials`() { + let previousClientID = ProcessInfo.processInfo.environment["GEMINI_OAUTH_CLIENT_ID"] + let previousClientSecret = ProcessInfo.processInfo.environment["GEMINI_OAUTH_CLIENT_SECRET"] + setenv("GEMINI_OAUTH_CLIENT_ID", "env-id", 1) + setenv("GEMINI_OAUTH_CLIENT_SECRET", "env-secret", 1) + defer { + if let previousClientID { + setenv("GEMINI_OAUTH_CLIENT_ID", previousClientID, 1) + } else { + unsetenv("GEMINI_OAUTH_CLIENT_ID") + } + if let previousClientSecret { + setenv("GEMINI_OAUTH_CLIENT_SECRET", previousClientSecret, 1) + } else { + unsetenv("GEMINI_OAUTH_CLIENT_SECRET") + } + } + + let resolved = GeminiOAuthConfig.environmentClient() + #expect(resolved?.clientID == "env-id") + #expect(resolved?.clientSecret == "env-secret") + } +} diff --git a/Tests/CodexBarTests/GeminiStatusProbeAPITests.swift b/Tests/CodexBarTests/GeminiStatusProbeAPITests.swift index 8ea2650e96..6a9f453162 100644 --- a/Tests/CodexBarTests/GeminiStatusProbeAPITests.swift +++ b/Tests/CodexBarTests/GeminiStatusProbeAPITests.swift @@ -680,30 +680,167 @@ struct GeminiStatusProbeAPITests { } @Test - func `fails refresh when O auth config missing`() async throws { + func `refreshes using oauth2 js path when gemini cli omits oauth config`() async throws { let env = try GeminiTestEnvironment() defer { env.cleanup() } try env.writeCredentials( accessToken: "old-token", refreshToken: "refresh-token", expiry: Date().addingTimeInterval(-3600), - idToken: nil) + 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(includeOAuth: false) - let previousValue = ProcessInfo.processInfo.environment["GEMINI_CLI_PATH"] + let previousGeminiPath = ProcessInfo.processInfo.environment["GEMINI_CLI_PATH"] + let previousOAuthPath = ProcessInfo.processInfo.environment["GEMINI_OAUTH2_JS_PATH"] + let previousClientID = ProcessInfo.processInfo.environment["GEMINI_OAUTH_CLIENT_ID"] + let previousClientSecret = ProcessInfo.processInfo.environment["GEMINI_OAUTH_CLIENT_SECRET"] setenv("GEMINI_CLI_PATH", binURL.path, 1) + setenv("GEMINI_OAUTH2_JS_PATH", oauthURL.path, 1) + unsetenv("GEMINI_OAUTH_CLIENT_ID") + unsetenv("GEMINI_OAUTH_CLIENT_SECRET") defer { - if let previousValue { - setenv("GEMINI_CLI_PATH", previousValue, 1) + if let previousGeminiPath { + setenv("GEMINI_CLI_PATH", previousGeminiPath, 1) } else { unsetenv("GEMINI_CLI_PATH") } + if let previousOAuthPath { + setenv("GEMINI_OAUTH2_JS_PATH", previousOAuthPath, 1) + } else { + unsetenv("GEMINI_OAUTH2_JS_PATH") + } + if let previousClientID { + setenv("GEMINI_OAUTH_CLIENT_ID", previousClientID, 1) + } else { + unsetenv("GEMINI_OAUTH_CLIENT_ID") + } + if let previousClientSecret { + setenv("GEMINI_OAUTH_CLIENT_SECRET", previousClientSecret, 1) + } else { + unsetenv("GEMINI_OAUTH_CLIENT_SECRET") + } } - let probe = GeminiStatusProbe(timeout: 1, homeDirectory: env.homeURL.path) - await Self.expectError(.apiError("Could not find Gemini CLI OAuth configuration")) { - _ = try await probe.fetch() + 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 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 previousGeminiPath = ProcessInfo.processInfo.environment["GEMINI_CLI_PATH"] + setenv("GEMINI_CLI_PATH", binURL.path, 1) + setenv("GEMINI_OAUTH_CLIENT_ID", "env-client-id", 1) + setenv("GEMINI_OAUTH_CLIENT_SECRET", "env-client-secret", 1) + defer { + if let previousGeminiPath { + setenv("GEMINI_CLI_PATH", previousGeminiPath, 1) + } else { + unsetenv("GEMINI_CLI_PATH") + } + unsetenv("GEMINI_OAUTH_CLIENT_ID") + unsetenv("GEMINI_OAUTH_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 probe.fetch() } @Test diff --git a/docs/gemini.md b/docs/gemini.md index 9d1d615101..a88b996b57 100644 --- a/docs/gemini.md +++ b/docs/gemini.md @@ -27,6 +27,11 @@ 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. Installed Gemini CLI package (`oauth2.js` regex extraction). + 3. `GEMINI_OAUTH2_JS_PATH` pointing at a readable `oauth2.js` file. + 4. Known global Gemini CLI install paths (Homebrew/npm layouts). - 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` @@ -83,6 +88,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` From 5ae65b11b87804075a5c53fde330ef678abca289 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao Date: Wed, 8 Jul 2026 11:46:40 +0800 Subject: [PATCH 2/8] Fix lint by moving Gemini OAuth recovery helpers to extension Move OAuth discovery helpers out of the main probe struct and split recovery API tests into their own file to satisfy type_body_length. Co-authored-by: Cursor --- .../Providers/Gemini/GeminiStatusProbe.swift | 178 +++++++++--------- .../GeminiOAuthRecoveryAPITests.swift | 175 +++++++++++++++++ .../GeminiStatusProbeAPITests.swift | 164 ---------------- 3 files changed, 265 insertions(+), 252 deletions(-) create mode 100644 Tests/CodexBarTests/GeminiOAuthRecoveryAPITests.swift diff --git a/Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe.swift b/Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe.swift index 0017bc655e..9101d6bdbd 100644 --- a/Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe.swift @@ -504,94 +504,6 @@ public struct GeminiStatusProbe: Sendable { let clientSecret: String } - private static func extractOAuthCredentials() -> OAuthClientCredentials? { - if let resolved = GeminiOAuthConfig.environmentClient() { - return OAuthClientCredentials(clientId: resolved.clientID, clientSecret: resolved.clientSecret) - } - if let credentials = Self.discoverOAuthCredentialsFromInstalledCLI() { - return OAuthClientCredentials(clientId: credentials.clientID, clientSecret: credentials.clientSecret) - } - if let path = GeminiOAuthConfig.configuredOAuth2JSPath, - let credentials = Self.parseOAuthCredentials(fromFile: path) - { - return credentials - } - if let credentials = Self.discoverOAuthCredentialsFromKnownInstallPaths() { - return credentials - } - return nil - } - - private 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 - 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 - } - } - return nil - } - - private static func parseOAuthCredentials(fromFile path: String) -> OAuthClientCredentials? { - guard let content = try? String(contentsOfFile: path, encoding: .utf8) else { - return nil - } - return Self.parseOAuthCredentials(from: content) - } - - private 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 - } - private static func isLikelyFnmManagedPath(_ path: String) -> Bool { let normalized = path.replacingOccurrences(of: "\\", with: "/") return normalized.contains("/fnm_multishells/") @@ -1232,6 +1144,96 @@ extension GeminiStatusProbe { } } +extension GeminiStatusProbe { + fileprivate static func extractOAuthCredentials() -> OAuthClientCredentials? { + if let resolved = GeminiOAuthConfig.environmentClient() { + return OAuthClientCredentials(clientId: resolved.clientID, clientSecret: resolved.clientSecret) + } + if let credentials = Self.discoverOAuthCredentialsFromInstalledCLI() { + return OAuthClientCredentials(clientId: credentials.clientID, clientSecret: credentials.clientSecret) + } + if let path = GeminiOAuthConfig.configuredOAuth2JSPath, + let credentials = Self.parseOAuthCredentials(fromFile: path) + { + return credentials + } + if let credentials = Self.discoverOAuthCredentialsFromKnownInstallPaths() { + return credentials + } + return nil + } + + 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 + 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 + } + } + return nil + } + + 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 + } +} + extension GeminiStatusProbe { private static let processTimeoutQueue = DispatchQueue( label: "com.steipete.codexbar.gemini-process-timeout", diff --git a/Tests/CodexBarTests/GeminiOAuthRecoveryAPITests.swift b/Tests/CodexBarTests/GeminiOAuthRecoveryAPITests.swift new file mode 100644 index 0000000000..eb430b94f3 --- /dev/null +++ b/Tests/CodexBarTests/GeminiOAuthRecoveryAPITests.swift @@ -0,0 +1,175 @@ +import CodexBarCore +import Foundation +import Testing +#if canImport(Darwin) +import Darwin +#else +import Glibc +#endif + +@Suite(.serialized) +struct GeminiOAuthRecoveryAPITests { + @Test + func `refreshes using oauth2 js path when gemini cli omits oauth config`() 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(includeOAuth: false) + let previousGeminiPath = ProcessInfo.processInfo.environment["GEMINI_CLI_PATH"] + let previousOAuthPath = ProcessInfo.processInfo.environment["GEMINI_OAUTH2_JS_PATH"] + let previousClientID = ProcessInfo.processInfo.environment["GEMINI_OAUTH_CLIENT_ID"] + let previousClientSecret = ProcessInfo.processInfo.environment["GEMINI_OAUTH_CLIENT_SECRET"] + setenv("GEMINI_CLI_PATH", binURL.path, 1) + setenv("GEMINI_OAUTH2_JS_PATH", oauthURL.path, 1) + unsetenv("GEMINI_OAUTH_CLIENT_ID") + unsetenv("GEMINI_OAUTH_CLIENT_SECRET") + defer { + if let previousGeminiPath { + setenv("GEMINI_CLI_PATH", previousGeminiPath, 1) + } else { + unsetenv("GEMINI_CLI_PATH") + } + if let previousOAuthPath { + setenv("GEMINI_OAUTH2_JS_PATH", previousOAuthPath, 1) + } else { + unsetenv("GEMINI_OAUTH2_JS_PATH") + } + if let previousClientID { + setenv("GEMINI_OAUTH_CLIENT_ID", previousClientID, 1) + } else { + unsetenv("GEMINI_OAUTH_CLIENT_ID") + } + if let previousClientSecret { + setenv("GEMINI_OAUTH_CLIENT_SECRET", previousClientSecret, 1) + } else { + unsetenv("GEMINI_OAUTH_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=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 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 previousGeminiPath = ProcessInfo.processInfo.environment["GEMINI_CLI_PATH"] + setenv("GEMINI_CLI_PATH", binURL.path, 1) + setenv("GEMINI_OAUTH_CLIENT_ID", "env-client-id", 1) + setenv("GEMINI_OAUTH_CLIENT_SECRET", "env-client-secret", 1) + defer { + if let previousGeminiPath { + setenv("GEMINI_CLI_PATH", previousGeminiPath, 1) + } else { + unsetenv("GEMINI_CLI_PATH") + } + unsetenv("GEMINI_OAUTH_CLIENT_ID") + unsetenv("GEMINI_OAUTH_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 probe.fetch() + } +} diff --git a/Tests/CodexBarTests/GeminiStatusProbeAPITests.swift b/Tests/CodexBarTests/GeminiStatusProbeAPITests.swift index 6a9f453162..3fcb60ade9 100644 --- a/Tests/CodexBarTests/GeminiStatusProbeAPITests.swift +++ b/Tests/CodexBarTests/GeminiStatusProbeAPITests.swift @@ -679,170 +679,6 @@ struct GeminiStatusProbeAPITests { #expect(counts.fallback == 0) } - @Test - func `refreshes using oauth2 js path when gemini cli omits oauth config`() 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(includeOAuth: false) - let previousGeminiPath = ProcessInfo.processInfo.environment["GEMINI_CLI_PATH"] - let previousOAuthPath = ProcessInfo.processInfo.environment["GEMINI_OAUTH2_JS_PATH"] - let previousClientID = ProcessInfo.processInfo.environment["GEMINI_OAUTH_CLIENT_ID"] - let previousClientSecret = ProcessInfo.processInfo.environment["GEMINI_OAUTH_CLIENT_SECRET"] - setenv("GEMINI_CLI_PATH", binURL.path, 1) - setenv("GEMINI_OAUTH2_JS_PATH", oauthURL.path, 1) - unsetenv("GEMINI_OAUTH_CLIENT_ID") - unsetenv("GEMINI_OAUTH_CLIENT_SECRET") - defer { - if let previousGeminiPath { - setenv("GEMINI_CLI_PATH", previousGeminiPath, 1) - } else { - unsetenv("GEMINI_CLI_PATH") - } - if let previousOAuthPath { - setenv("GEMINI_OAUTH2_JS_PATH", previousOAuthPath, 1) - } else { - unsetenv("GEMINI_OAUTH2_JS_PATH") - } - if let previousClientID { - setenv("GEMINI_OAUTH_CLIENT_ID", previousClientID, 1) - } else { - unsetenv("GEMINI_OAUTH_CLIENT_ID") - } - if let previousClientSecret { - setenv("GEMINI_OAUTH_CLIENT_SECRET", previousClientSecret, 1) - } else { - unsetenv("GEMINI_OAUTH_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=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 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 previousGeminiPath = ProcessInfo.processInfo.environment["GEMINI_CLI_PATH"] - setenv("GEMINI_CLI_PATH", binURL.path, 1) - setenv("GEMINI_OAUTH_CLIENT_ID", "env-client-id", 1) - setenv("GEMINI_OAUTH_CLIENT_SECRET", "env-client-secret", 1) - defer { - if let previousGeminiPath { - setenv("GEMINI_CLI_PATH", previousGeminiPath, 1) - } else { - unsetenv("GEMINI_CLI_PATH") - } - unsetenv("GEMINI_OAUTH_CLIENT_ID") - unsetenv("GEMINI_OAUTH_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 probe.fetch() - } - @Test func `reports api errors`() async throws { let env = try GeminiTestEnvironment() From 8631e01b18b62e3754a81163796a3150e6ee0a48 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao Date: Wed, 8 Jul 2026 11:58:30 +0800 Subject: [PATCH 3/8] Fix Linux build by widening OAuthClientCredentials visibility --- Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe.swift b/Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe.swift index 9101d6bdbd..3245de89e0 100644 --- a/Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe.swift @@ -499,7 +499,7 @@ public struct GeminiStatusProbe: Sendable { let expiryDate: Date? } - private struct OAuthClientCredentials { + fileprivate struct OAuthClientCredentials { let clientId: String let clientSecret: String } From 9491e3af058291e5d0a16f40b157ee68d850cc6f Mon Sep 17 00:00:00 2001 From: Yuxin Qiao Date: Wed, 8 Jul 2026 22:12:02 +0800 Subject: [PATCH 4/8] Cover Homebrew Cellar OAuth discovery and isolate env overrides Add Cellar/opt libexec fallback when the gemini binary cannot yield credentials, inject OAuth env via TaskLocal so parallel Gemini suites stay uncontaminated, and document the expanded discovery path. Co-authored-by: Cursor --- .../Providers/Gemini/GeminiOAuthConfig.swift | 44 ++++- .../Providers/Gemini/GeminiStatusProbe.swift | 84 ++++++++-- .../GeminiOAuthConfigTests.swift | 44 ++--- .../GeminiOAuthRecoveryAPITests.swift | 157 +++++++++++++++--- docs/gemini.md | 12 +- 5 files changed, 261 insertions(+), 80 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Gemini/GeminiOAuthConfig.swift b/Sources/CodexBarCore/Providers/Gemini/GeminiOAuthConfig.swift index e76b355b5b..446364896b 100644 --- a/Sources/CodexBarCore/Providers/Gemini/GeminiOAuthConfig.swift +++ b/Sources/CodexBarCore/Providers/Gemini/GeminiOAuthConfig.swift @@ -13,19 +13,51 @@ public enum GeminiOAuthConfig: Sendable { } } + /// 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? { - let value = ProcessInfo.processInfo.environment["GEMINI_OAUTH_CLIENT_ID"] - return value?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty + self.currentEnvironment.clientID? + .trimmingCharacters(in: .whitespacesAndNewlines) + .nilIfEmpty } public static var configuredClientSecret: String? { - let value = ProcessInfo.processInfo.environment["GEMINI_OAUTH_CLIENT_SECRET"] - return value?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty + self.currentEnvironment.clientSecret? + .trimmingCharacters(in: .whitespacesAndNewlines) + .nilIfEmpty } public static var configuredOAuth2JSPath: String? { - let value = ProcessInfo.processInfo.environment["GEMINI_OAUTH2_JS_PATH"] - return value?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty + self.currentEnvironment.oauth2JSPath? + .trimmingCharacters(in: .whitespacesAndNewlines) + .nilIfEmpty } public static func environmentClient() -> ClientCredentials? { diff --git a/Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe.swift b/Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe.swift index 3245de89e0..6a96e67464 100644 --- a/Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe.swift @@ -1163,28 +1163,92 @@ extension GeminiStatusProbe { 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 - 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) { + // 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 diff --git a/Tests/CodexBarTests/GeminiOAuthConfigTests.swift b/Tests/CodexBarTests/GeminiOAuthConfigTests.swift index 8431d03ba3..b5982632be 100644 --- a/Tests/CodexBarTests/GeminiOAuthConfigTests.swift +++ b/Tests/CodexBarTests/GeminiOAuthConfigTests.swift @@ -11,45 +11,21 @@ import Glibc struct GeminiOAuthConfigTests { @Test func `environment client requires both id and secret`() { - let previousClientID = ProcessInfo.processInfo.environment["GEMINI_OAUTH_CLIENT_ID"] - let previousClientSecret = ProcessInfo.processInfo.environment["GEMINI_OAUTH_CLIENT_SECRET"] - setenv("GEMINI_OAUTH_CLIENT_ID", "env-id", 1) - unsetenv("GEMINI_OAUTH_CLIENT_SECRET") - defer { - if let previousClientID { - setenv("GEMINI_OAUTH_CLIENT_ID", previousClientID, 1) - } else { - unsetenv("GEMINI_OAUTH_CLIENT_ID") - } - if let previousClientSecret { - setenv("GEMINI_OAUTH_CLIENT_SECRET", previousClientSecret, 1) - } + let values = GeminiOAuthConfig.EnvironmentValues(clientID: "env-id", clientSecret: nil) + GeminiOAuthConfig.$environmentOverride.withValue(values) { + #expect(GeminiOAuthConfig.environmentClient() == nil) } - - #expect(GeminiOAuthConfig.environmentClient() == nil) } @Test func `environment client returns configured credentials`() { - let previousClientID = ProcessInfo.processInfo.environment["GEMINI_OAUTH_CLIENT_ID"] - let previousClientSecret = ProcessInfo.processInfo.environment["GEMINI_OAUTH_CLIENT_SECRET"] - setenv("GEMINI_OAUTH_CLIENT_ID", "env-id", 1) - setenv("GEMINI_OAUTH_CLIENT_SECRET", "env-secret", 1) - defer { - if let previousClientID { - setenv("GEMINI_OAUTH_CLIENT_ID", previousClientID, 1) - } else { - unsetenv("GEMINI_OAUTH_CLIENT_ID") - } - if let previousClientSecret { - setenv("GEMINI_OAUTH_CLIENT_SECRET", previousClientSecret, 1) - } else { - unsetenv("GEMINI_OAUTH_CLIENT_SECRET") - } + 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") } - - 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 index eb430b94f3..6260fc8edf 100644 --- a/Tests/CodexBarTests/GeminiOAuthRecoveryAPITests.swift +++ b/Tests/CodexBarTests/GeminiOAuthRecoveryAPITests.swift @@ -27,36 +27,16 @@ struct GeminiOAuthRecoveryAPITests { let binURL = try env.writeFakeGeminiCLI(includeOAuth: false) let previousGeminiPath = ProcessInfo.processInfo.environment["GEMINI_CLI_PATH"] - let previousOAuthPath = ProcessInfo.processInfo.environment["GEMINI_OAUTH2_JS_PATH"] - let previousClientID = ProcessInfo.processInfo.environment["GEMINI_OAUTH_CLIENT_ID"] - let previousClientSecret = ProcessInfo.processInfo.environment["GEMINI_OAUTH_CLIENT_SECRET"] setenv("GEMINI_CLI_PATH", binURL.path, 1) - setenv("GEMINI_OAUTH2_JS_PATH", oauthURL.path, 1) - unsetenv("GEMINI_OAUTH_CLIENT_ID") - unsetenv("GEMINI_OAUTH_CLIENT_SECRET") defer { if let previousGeminiPath { setenv("GEMINI_CLI_PATH", previousGeminiPath, 1) } else { unsetenv("GEMINI_CLI_PATH") } - if let previousOAuthPath { - setenv("GEMINI_OAUTH2_JS_PATH", previousOAuthPath, 1) - } else { - unsetenv("GEMINI_OAUTH2_JS_PATH") - } - if let previousClientID { - setenv("GEMINI_OAUTH_CLIENT_ID", previousClientID, 1) - } else { - unsetenv("GEMINI_OAUTH_CLIENT_ID") - } - if let previousClientSecret { - setenv("GEMINI_OAUTH_CLIENT_SECRET", previousClientSecret, 1) - } else { - unsetenv("GEMINI_OAUTH_CLIENT_SECRET") - } } + 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) @@ -99,7 +79,9 @@ struct GeminiOAuthRecoveryAPITests { } let probe = GeminiStatusProbe(timeout: 2, homeDirectory: env.homeURL.path, dataLoader: dataLoader) - let snapshot = try await probe.fetch() + let snapshot = try await GeminiOAuthConfig.$environmentOverride.withValue(oauthEnv) { + try await probe.fetch() + } #expect(snapshot.accountPlan == "Paid") } @@ -116,18 +98,17 @@ struct GeminiOAuthRecoveryAPITests { let binURL = try env.writeFakeGeminiCLI() let previousGeminiPath = ProcessInfo.processInfo.environment["GEMINI_CLI_PATH"] setenv("GEMINI_CLI_PATH", binURL.path, 1) - setenv("GEMINI_OAUTH_CLIENT_ID", "env-client-id", 1) - setenv("GEMINI_OAUTH_CLIENT_SECRET", "env-client-secret", 1) defer { if let previousGeminiPath { setenv("GEMINI_CLI_PATH", previousGeminiPath, 1) } else { unsetenv("GEMINI_CLI_PATH") } - unsetenv("GEMINI_OAUTH_CLIENT_ID") - unsetenv("GEMINI_OAUTH_CLIENT_SECRET") } + 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) @@ -170,6 +151,128 @@ struct GeminiOAuthRecoveryAPITests { } let probe = GeminiStatusProbe(timeout: 2, homeDirectory: env.homeURL.path, dataLoader: dataLoader) - _ = try await probe.fetch() + _ = 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 previousGeminiPath = ProcessInfo.processInfo.environment["GEMINI_CLI_PATH"] + setenv("GEMINI_CLI_PATH", binURL.path, 1) + defer { + if let previousGeminiPath { + setenv("GEMINI_CLI_PATH", previousGeminiPath, 1) + } else { + unsetenv("GEMINI_CLI_PATH") + } + } + + 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 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/docs/gemini.md b/docs/gemini.md index a88b996b57..cd6fd8d53e 100644 --- a/docs/gemini.md +++ b/docs/gemini.md @@ -29,16 +29,22 @@ Gemini uses the Gemini CLI OAuth credentials and private quota APIs. No browser ## OAuth client ID/secret extraction - Resolution order: 1. `GEMINI_OAUTH_CLIENT_ID` + `GEMINI_OAUTH_CLIENT_SECRET` environment override. - 2. Installed Gemini CLI package (`oauth2.js` regex extraction). + 2. Installed Gemini CLI package (`oauth2.js` / bundle regex extraction). 3. `GEMINI_OAUTH2_JS_PATH` pointing at a readable `oauth2.js` file. - 4. Known global Gemini CLI install paths (Homebrew/npm layouts). + 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: From 468b2cf061a3fa45f997754351bec29a7380916e Mon Sep 17 00:00:00 2001 From: Yuxin Qiao Date: Thu, 9 Jul 2026 23:21:15 +0800 Subject: [PATCH 5/8] Fix SwiftFormat for Gemini OAuth recovery merge Co-authored-by: Cursor --- .../Providers/Gemini/GeminiStatusProbe.swift | 100 +++++++++--------- .../GeminiOAuthRecoveryAPITests.swift | 55 +++------- 2 files changed, 62 insertions(+), 93 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe.swift b/Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe.swift index 6a96e67464..138ce7686e 100644 --- a/Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe.swift @@ -1093,57 +1093,6 @@ public struct GeminiStatusProbe: Sendable { } } -extension GeminiStatusProbe { - /// 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) - /// - free-tier + hd claim: Workspace account (Gemini included free since Jan 2025) - /// - free-tier: Personal free account - /// - legacy-tier: Unknown legacy/grandfathered tier - /// - nil (API failed): Leave blank (no display) - fileprivate static func resolveAccountPlan( - tier: GeminiUserTierId?, - hostedDomain: String?, - paidTierName: String?) -> String? - { - // Match Gemini CLI's contract: a named paid tier is the most specific plan signal, - // even when currentTier is missing, unknown, or still reports free-tier. - if let paidTierName { - self.log.info("Paid tier detected", metadata: [ - "tier": tier?.rawValue ?? "unknown", - "plan": paidTierName, - ]) - return paidTierName - } - - switch (tier, hostedDomain) { - case (.standard, _): - return "Paid" - case let (.free, .some(domain)): - Self.log.info("Workspace account detected", metadata: ["domain": domain]) - return "Workspace" - case (.free, .none): - Self.log.info("Personal free account") - return "Free" - case (.legacy, _): - return "Legacy" - case (.none, _): - self.log.info("Tier detection failed, leaving plan blank") - return nil - } - } - - private static func parsePaidTierName(from json: [String: Any]) -> String? { - guard let paidTier = json["paidTier"] as? [String: Any], - let rawName = paidTier["name"] as? String - else { - return nil - } - let trimmed = rawName.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? nil : trimmed - } -} - extension GeminiStatusProbe { fileprivate static func extractOAuthCredentials() -> OAuthClientCredentials? { if let resolved = GeminiOAuthConfig.environmentClient() { @@ -1296,6 +1245,55 @@ extension GeminiStatusProbe { 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) + /// - free-tier + hd claim: Workspace account (Gemini included free since Jan 2025) + /// - free-tier: Personal free account + /// - legacy-tier: Unknown legacy/grandfathered tier + /// - nil (API failed): Leave blank (no display) + fileprivate static func resolveAccountPlan( + tier: GeminiUserTierId?, + hostedDomain: String?, + paidTierName: String?) -> String? + { + // Match Gemini CLI's contract: a named paid tier is the most specific plan signal, + // even when currentTier is missing, unknown, or still reports free-tier. + if let paidTierName { + self.log.info("Paid tier detected", metadata: [ + "tier": tier?.rawValue ?? "unknown", + "plan": paidTierName, + ]) + return paidTierName + } + + switch (tier, hostedDomain) { + case (.standard, _): + return "Paid" + case let (.free, .some(domain)): + Self.log.info("Workspace account detected", metadata: ["domain": domain]) + return "Workspace" + case (.free, .none): + Self.log.info("Personal free account") + return "Free" + case (.legacy, _): + return "Legacy" + case (.none, _): + self.log.info("Tier detection failed, leaving plan blank") + return nil + } + } + + private static func parsePaidTierName(from json: [String: Any]) -> String? { + guard let paidTier = json["paidTier"] as? [String: Any], + let rawName = paidTier["name"] as? String + else { + return nil + } + let trimmed = rawName.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } } extension GeminiStatusProbe { diff --git a/Tests/CodexBarTests/GeminiOAuthRecoveryAPITests.swift b/Tests/CodexBarTests/GeminiOAuthRecoveryAPITests.swift index 6260fc8edf..b03abad7d0 100644 --- a/Tests/CodexBarTests/GeminiOAuthRecoveryAPITests.swift +++ b/Tests/CodexBarTests/GeminiOAuthRecoveryAPITests.swift @@ -1,11 +1,6 @@ import CodexBarCore import Foundation import Testing -#if canImport(Darwin) -import Darwin -#else -import Glibc -#endif @Suite(.serialized) struct GeminiOAuthRecoveryAPITests { @@ -26,16 +21,6 @@ struct GeminiOAuthRecoveryAPITests { """.write(to: oauthURL, atomically: true, encoding: .utf8) let binURL = try env.writeFakeGeminiCLI(includeOAuth: false) - let previousGeminiPath = ProcessInfo.processInfo.environment["GEMINI_CLI_PATH"] - setenv("GEMINI_CLI_PATH", binURL.path, 1) - defer { - if let previousGeminiPath { - setenv("GEMINI_CLI_PATH", previousGeminiPath, 1) - } else { - unsetenv("GEMINI_CLI_PATH") - } - } - let oauthEnv = GeminiOAuthConfig.EnvironmentValues(oauth2JSPath: oauthURL.path) let dataLoader = GeminiAPITestHelpers.dataLoader { request in guard let url = request.url, let host = url.host else { @@ -79,8 +64,10 @@ struct GeminiOAuthRecoveryAPITests { } let probe = GeminiStatusProbe(timeout: 2, homeDirectory: env.homeURL.path, dataLoader: dataLoader) - let snapshot = try await GeminiOAuthConfig.$environmentOverride.withValue(oauthEnv) { - try await probe.fetch() + let snapshot = try await BinaryLocator.$geminiBinaryPathOverrideForTesting.withValue(binURL.path) { + try await GeminiOAuthConfig.$environmentOverride.withValue(oauthEnv) { + try await probe.fetch() + } } #expect(snapshot.accountPlan == "Paid") } @@ -96,16 +83,6 @@ struct GeminiOAuthRecoveryAPITests { idToken: nil) let binURL = try env.writeFakeGeminiCLI() - let previousGeminiPath = ProcessInfo.processInfo.environment["GEMINI_CLI_PATH"] - setenv("GEMINI_CLI_PATH", binURL.path, 1) - defer { - if let previousGeminiPath { - setenv("GEMINI_CLI_PATH", previousGeminiPath, 1) - } else { - unsetenv("GEMINI_CLI_PATH") - } - } - let oauthEnv = GeminiOAuthConfig.EnvironmentValues( clientID: "env-client-id", clientSecret: "env-client-secret") @@ -151,8 +128,10 @@ struct GeminiOAuthRecoveryAPITests { } let probe = GeminiStatusProbe(timeout: 2, homeDirectory: env.homeURL.path, dataLoader: dataLoader) - _ = try await GeminiOAuthConfig.$environmentOverride.withValue(oauthEnv) { - try await probe.fetch() + _ = try await BinaryLocator.$geminiBinaryPathOverrideForTesting.withValue(binURL.path) { + try await GeminiOAuthConfig.$environmentOverride.withValue(oauthEnv) { + try await probe.fetch() + } } } @@ -175,16 +154,6 @@ struct GeminiOAuthRecoveryAPITests { clientID: "cellar-client-id", clientSecret: "cellar-client-secret") - let previousGeminiPath = ProcessInfo.processInfo.environment["GEMINI_CLI_PATH"] - setenv("GEMINI_CLI_PATH", binURL.path, 1) - defer { - if let previousGeminiPath { - setenv("GEMINI_CLI_PATH", previousGeminiPath, 1) - } else { - unsetenv("GEMINI_CLI_PATH") - } - } - let clearOAuthEnv = GeminiOAuthConfig.EnvironmentValues() let dataLoader = GeminiAPITestHelpers.dataLoader { request in guard let url = request.url, let host = url.host else { @@ -231,9 +200,11 @@ struct GeminiOAuthRecoveryAPITests { } let probe = GeminiStatusProbe(timeout: 2, homeDirectory: env.homeURL.path, dataLoader: dataLoader) - let snapshot = try await GeminiOAuthConfig.$environmentOverride.withValue(clearOAuthEnv) { - try await GeminiStatusProbe.$knownInstallPrefixesForTesting.withValue([homebrewPrefix.path]) { - try await probe.fetch() + 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") From 4024134abda06722de547ed226e708682ee777d2 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao Date: Fri, 10 Jul 2026 00:45:22 +0800 Subject: [PATCH 6/8] ci: retrigger macOS tests after Sparkle SPM cache flake Co-authored-by: Cursor From 6c7eb9f5f1b703a7044368da0cb174338edcd2ad Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 11 Jul 2026 12:05:21 +0100 Subject: [PATCH 7/8] fix: prioritize explicit Gemini OAuth config --- CHANGELOG.md | 1 + .../CodexBarCore/Providers/Gemini/GeminiStatusProbe.swift | 6 +++--- Tests/CodexBarTests/GeminiOAuthConfigTests.swift | 5 ----- Tests/CodexBarTests/GeminiOAuthRecoveryAPITests.swift | 4 ++-- docs/gemini.md | 4 ++-- 5 files changed, 8 insertions(+), 12 deletions(-) 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/Providers/Gemini/GeminiStatusProbe.swift b/Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe.swift index 138ce7686e..27c46d8f07 100644 --- a/Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe.swift @@ -1098,14 +1098,14 @@ extension GeminiStatusProbe { if let resolved = GeminiOAuthConfig.environmentClient() { return OAuthClientCredentials(clientId: resolved.clientID, clientSecret: resolved.clientSecret) } - if let credentials = Self.discoverOAuthCredentialsFromInstalledCLI() { - return OAuthClientCredentials(clientId: credentials.clientID, clientSecret: credentials.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 } diff --git a/Tests/CodexBarTests/GeminiOAuthConfigTests.swift b/Tests/CodexBarTests/GeminiOAuthConfigTests.swift index b5982632be..25ee303418 100644 --- a/Tests/CodexBarTests/GeminiOAuthConfigTests.swift +++ b/Tests/CodexBarTests/GeminiOAuthConfigTests.swift @@ -1,11 +1,6 @@ import CodexBarCore import Foundation import Testing -#if canImport(Darwin) -import Darwin -#else -import Glibc -#endif @Suite(.serialized) struct GeminiOAuthConfigTests { diff --git a/Tests/CodexBarTests/GeminiOAuthRecoveryAPITests.swift b/Tests/CodexBarTests/GeminiOAuthRecoveryAPITests.swift index b03abad7d0..b99b24efbf 100644 --- a/Tests/CodexBarTests/GeminiOAuthRecoveryAPITests.swift +++ b/Tests/CodexBarTests/GeminiOAuthRecoveryAPITests.swift @@ -5,7 +5,7 @@ import Testing @Suite(.serialized) struct GeminiOAuthRecoveryAPITests { @Test - func `refreshes using oauth2 js path when gemini cli omits oauth config`() async throws { + func `explicit oauth2 js path overrides installed gemini cli`() async throws { let env = try GeminiTestEnvironment() defer { env.cleanup() } try env.writeCredentials( @@ -20,7 +20,7 @@ struct GeminiOAuthRecoveryAPITests { const OAUTH_CLIENT_SECRET = 'path-client-secret'; """.write(to: oauthURL, atomically: true, encoding: .utf8) - let binURL = try env.writeFakeGeminiCLI(includeOAuth: false) + 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 { diff --git a/docs/gemini.md b/docs/gemini.md index cd6fd8d53e..de133f717d 100644 --- a/docs/gemini.md +++ b/docs/gemini.md @@ -29,8 +29,8 @@ Gemini uses the Gemini CLI OAuth credentials and private quota APIs. No browser ## OAuth client ID/secret extraction - Resolution order: 1. `GEMINI_OAUTH_CLIENT_ID` + `GEMINI_OAUTH_CLIENT_SECRET` environment override. - 2. Installed Gemini CLI package (`oauth2.js` / bundle regex extraction). - 3. `GEMINI_OAUTH2_JS_PATH` pointing at a readable `oauth2.js` file. + 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). From 87eaaab9a1bbb39ff0b2e55929e470168a464a36 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 11 Jul 2026 12:09:19 +0100 Subject: [PATCH 8/8] test: isolate Gemini binary resolution --- Sources/CodexBarCore/PathEnvironment.swift | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) 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,