From 775f2df6c04f5017b6a1a62667744e86c545219b Mon Sep 17 00:00:00 2001 From: Ruben <17501732+Daltonganger@users.noreply.github.com> Date: Thu, 16 Jul 2026 21:23:10 +0200 Subject: [PATCH 1/4] fix: restore Claude and Gemini provider usage --- .../Providers/AntigravityProvider.swift | 12 +-- .../Providers/ClaudeProvider.swift | 93 +++++++++++++++++-- .../Providers/GeminiCLIProvider.swift | 24 +++-- .../Services/TokenManager.swift | 30 ++++-- .../ClaudeProviderTests.swift | 14 +++ .../TokenManagerTests.swift | 17 ++++ scripts/query-claude.sh | 11 ++- 7 files changed, 173 insertions(+), 28 deletions(-) diff --git a/CopilotMonitor/CopilotMonitor/Providers/AntigravityProvider.swift b/CopilotMonitor/CopilotMonitor/Providers/AntigravityProvider.swift index c73f9d19..04f617c7 100644 --- a/CopilotMonitor/CopilotMonitor/Providers/AntigravityProvider.swift +++ b/CopilotMonitor/CopilotMonitor/Providers/AntigravityProvider.swift @@ -274,12 +274,12 @@ final class AntigravityProvider: ProviderProtocol { return nil } - let primaryProjectId = account.projectId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let fallbackProjectId = account.managedProjectId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let projectId = primaryProjectId.isEmpty ? fallbackProjectId : primaryProjectId - guard !projectId.isEmpty else { - logger.warning("Antigravity fallback unavailable: selected account is missing project ID") - return nil + let projectId = GeminiProjectPolicy.resolve( + primary: account.projectId, + fallback: account.managedProjectId + ) + if projectId == GeminiProjectPolicy.fallbackProjectId { + logger.info("Antigravity fallback account has no project ID; using default project fallback") } return AntigravityFallbackAccount( diff --git a/CopilotMonitor/CopilotMonitor/Providers/ClaudeProvider.swift b/CopilotMonitor/CopilotMonitor/Providers/ClaudeProvider.swift index f43fa328..371dbd23 100644 --- a/CopilotMonitor/CopilotMonitor/Providers/ClaudeProvider.swift +++ b/CopilotMonitor/CopilotMonitor/Providers/ClaudeProvider.swift @@ -8,38 +8,107 @@ enum ClaudeOAuthRequestPolicy { static let betaHeader = "oauth-2025-04-20" static let defaultClaudeCodeVersion = "2.1.80" - static func codeVersion(environment: [String: String] = ProcessInfo.processInfo.environment) -> String { + static func codeVersion( + environment: [String: String] = ProcessInfo.processInfo.environment, + installedVersion: String? = nil + ) -> String { let rawVersion = environment["ANTHROPIC_CLI_VERSION"]?.trimmingCharacters(in: .whitespacesAndNewlines) if let rawVersion, !rawVersion.isEmpty { return rawVersion } + + if let installedVersion = normalizedVersion(installedVersion) { + return installedVersion + } + return defaultClaudeCodeVersion } - static func usageUserAgent(environment: [String: String] = ProcessInfo.processInfo.environment) -> String { + static func usageUserAgent( + environment: [String: String] = ProcessInfo.processInfo.environment, + installedVersion: String? = nil + ) -> String { let rawUserAgent = environment["ANTHROPIC_CODE_USER_AGENT"]?.trimmingCharacters(in: .whitespacesAndNewlines) if let rawUserAgent, !rawUserAgent.isEmpty { return rawUserAgent } - return "claude-code/\(codeVersion(environment: environment))" + return "claude-code/\(codeVersion(environment: environment, installedVersion: installedVersion))" + } + + static func installedClaudeCodeVersion( + environment: [String: String] = ProcessInfo.processInfo.environment, + fileManager: FileManager = .default + ) -> String? { + let homeDirectory = fileManager.homeDirectoryForCurrentUser + var candidatePaths: [String] = [] + + if let configuredPath = environment["CLAUDE_CODE_PATH"]?.trimmingCharacters(in: .whitespacesAndNewlines), + !configuredPath.isEmpty { + candidatePaths.append(configuredPath) + } + + if let path = environment["PATH"] { + candidatePaths.append(contentsOf: path.split(separator: ":").map { directory in + URL(fileURLWithPath: String(directory)).appendingPathComponent("claude").path + }) + } + + candidatePaths.append(contentsOf: [ + homeDirectory.appendingPathComponent(".local/bin/claude").path, + "/opt/homebrew/bin/claude", + "/usr/local/bin/claude" + ]) + + var visitedPaths = Set() + for path in candidatePaths where visitedPaths.insert(path).inserted { + guard fileManager.isExecutableFile(atPath: path) else { continue } + let resolvedPath = URL(fileURLWithPath: path).resolvingSymlinksInPath().path + if let version = versionFromExecutablePath(resolvedPath) { + return version + } + } + + return nil + } + + static func versionFromExecutablePath(_ path: String) -> String? { + for component in URL(fileURLWithPath: path).pathComponents.reversed() { + if let version = normalizedVersion(component) { + return version + } + } + return nil + } + + private static func normalizedVersion(_ value: String?) -> String? { + guard let value else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + let components = trimmed.split(separator: ".", omittingEmptySubsequences: false) + guard components.count == 3, + components.allSatisfy({ !$0.isEmpty && $0.allSatisfy(\.isNumber) }) else { + return nil + } + return trimmed } static func applyHeaders( to request: inout URLRequest, accessToken: String, - environment: [String: String] = ProcessInfo.processInfo.environment + environment: [String: String] = ProcessInfo.processInfo.environment, + installedVersion: String? = nil ) { + let userAgent = usageUserAgent(environment: environment, installedVersion: installedVersion) request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "Accept") request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.setValue(usageUserAgent(environment: environment), forHTTPHeaderField: "User-Agent") + request.setValue(userAgent, forHTTPHeaderField: "User-Agent") request.setValue(betaHeader, forHTTPHeaderField: "anthropic-beta") request.setValue(nil, forHTTPHeaderField: "Cookie") request.httpShouldHandleCookies = false request.timeoutInterval = 10 logger.debug( - "Prepared Claude OAuth request headers: userAgent=\(usageUserAgent(environment: environment), privacy: .public), cookies=disabled" + "Prepared Claude OAuth request headers: userAgent=\(userAgent, privacy: .public), cookies=disabled" ) } } @@ -409,7 +478,11 @@ final class ClaudeProvider: ProviderProtocol { var request = URLRequest(url: url) request.httpMethod = "GET" - ClaudeOAuthRequestPolicy.applyHeaders(to: &request, accessToken: accessToken) + ClaudeOAuthRequestPolicy.applyHeaders( + to: &request, + accessToken: accessToken, + installedVersion: ClaudeOAuthRequestPolicy.installedClaudeCodeVersion() + ) do { let (data, response) = try await session.data(for: request) @@ -547,7 +620,11 @@ final class ClaudeProvider: ProviderProtocol { var request = URLRequest(url: url) request.httpMethod = "GET" - ClaudeOAuthRequestPolicy.applyHeaders(to: &request, accessToken: accessToken) + ClaudeOAuthRequestPolicy.applyHeaders( + to: &request, + accessToken: accessToken, + installedVersion: ClaudeOAuthRequestPolicy.installedClaudeCodeVersion() + ) let (data, response) = try await session.data(for: request) diff --git a/CopilotMonitor/CopilotMonitor/Providers/GeminiCLIProvider.swift b/CopilotMonitor/CopilotMonitor/Providers/GeminiCLIProvider.swift index 9a52f00f..6746d22a 100644 --- a/CopilotMonitor/CopilotMonitor/Providers/GeminiCLIProvider.swift +++ b/CopilotMonitor/CopilotMonitor/Providers/GeminiCLIProvider.swift @@ -249,19 +249,29 @@ final class GeminiCLIProvider: ProviderProtocol { private func fetchQuotaForAccount(account: GeminiAuthAccount) async throws -> GeminiAccountQuota { let accountIndex = account.index - let projectId = account.projectId.trimmingCharacters(in: .whitespacesAndNewlines) - if projectId.isEmpty { - throw ProviderError.authenticationFailed("Missing project ID for account #\(accountIndex + 1)") - } + let configuredProjectId = account.projectId.trimmingCharacters(in: .whitespacesAndNewlines) + let projectId = GeminiProjectPolicy.resolve(primary: configuredProjectId) - guard let accessToken = await tokenManager.refreshGeminiAccessToken( + var refreshedAccessToken = await tokenManager.refreshGeminiAccessToken( refreshToken: account.refreshToken, clientId: account.clientId, clientSecret: account.clientSecret - ) else { + ) + if refreshedAccessToken == nil { + logger.info( + "Gemini CLI: Primary OAuth client failed for account #\(accountIndex + 1); trying Gemini CLI OAuth client fallback" + ) + refreshedAccessToken = await tokenManager.refreshGeminiAccessToken(refreshToken: account.refreshToken) + } + + guard let accessToken = refreshedAccessToken else { throw ProviderError.authenticationFailed("Unable to refresh token for account #\(accountIndex + 1)") } + if configuredProjectId.isEmpty { + logger.info("Gemini CLI: Missing project ID for account #\(accountIndex + 1); using default project fallback") + } + let resolvedEmail = await resolveGeminiAccountEmail(primaryEmail: account.email, accessToken: accessToken) if resolvedEmail == "Unknown" { logger.warning("Gemini CLI: Email lookup failed for account #\(accountIndex + 1)") @@ -275,7 +285,7 @@ final class GeminiCLIProvider: ProviderProtocol { request.httpMethod = "POST" request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "Content-Type") - // project parameter is required to get all models including gemini-3 variants + // The API accepts "default" when OAuth storage does not include a project ID. request.httpBody = "{\"project\":\"\(projectId)\"}".data(using: .utf8) let (data, response) = try await session.data(for: request) diff --git a/CopilotMonitor/CopilotMonitor/Services/TokenManager.swift b/CopilotMonitor/CopilotMonitor/Services/TokenManager.swift index cb2beba7..6ef27483 100644 --- a/CopilotMonitor/CopilotMonitor/Services/TokenManager.swift +++ b/CopilotMonitor/CopilotMonitor/Services/TokenManager.swift @@ -614,6 +614,24 @@ struct GeminiAuthAccount { let source: GeminiAuthSource } +enum GeminiProjectPolicy { + static let fallbackProjectId = "default" + + static func resolve(primary: String?, fallback: String? = nil) -> String { + let primaryProjectId = primary?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !primaryProjectId.isEmpty { + return primaryProjectId + } + + let fallbackProjectId = fallback?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !fallbackProjectId.isEmpty { + return fallbackProjectId + } + + return Self.fallbackProjectId + } +} + /// Minimal OpenCode auth payload for jenslys/opencode-gemini-auth stored under "google" struct OpenCodeGeminiAuthContainer: Decodable { let google: GeminiOAuthAuth? @@ -4342,12 +4360,12 @@ final class TokenManager: @unchecked Sendable { return nil } - let primaryProjectId = account.projectId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let fallbackProjectId = account.managedProjectId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let projectId = primaryProjectId.isEmpty ? fallbackProjectId : primaryProjectId - if projectId.isEmpty { - logger.warning("Skipping Antigravity account at index \(index): missing project ID") - return nil + let projectId = GeminiProjectPolicy.resolve( + primary: account.projectId, + fallback: account.managedProjectId + ) + if projectId == GeminiProjectPolicy.fallbackProjectId { + logger.info("Antigravity account at index \(index) has no project ID; using default project fallback") } let normalizedEmail = normalizedNonEmpty(account.email) diff --git a/CopilotMonitor/CopilotMonitorTests/ClaudeProviderTests.swift b/CopilotMonitor/CopilotMonitorTests/ClaudeProviderTests.swift index bd9ca227..a83754a1 100644 --- a/CopilotMonitor/CopilotMonitorTests/ClaudeProviderTests.swift +++ b/CopilotMonitor/CopilotMonitorTests/ClaudeProviderTests.swift @@ -136,6 +136,20 @@ final class ClaudeProviderTests: XCTestCase { XCTAssertEqual(userAgent, "claude-code/3.0.0") } + + func testClaudeOAuthRequestPolicyUsesInstalledVersionForUserAgent() { + let userAgent = ClaudeOAuthRequestPolicy.usageUserAgent( + environment: [:], + installedVersion: "2.1.199" + ) + + XCTAssertEqual(userAgent, "claude-code/2.1.199") + } + + func testClaudeOAuthRequestPolicyExtractsVersionFromOfficialInstallPath() { + let path = "/Users/example/.local/share/claude/versions/2.1.199" + XCTAssertEqual(ClaudeOAuthRequestPolicy.versionFromExecutablePath(path), "2.1.199") + } private func loadFixture(named: String) -> Data { let bundle = Bundle(for: type(of: self)) diff --git a/CopilotMonitor/CopilotMonitorTests/TokenManagerTests.swift b/CopilotMonitor/CopilotMonitorTests/TokenManagerTests.swift index 97db21c5..f5c17c44 100644 --- a/CopilotMonitor/CopilotMonitorTests/TokenManagerTests.swift +++ b/CopilotMonitor/CopilotMonitorTests/TokenManagerTests.swift @@ -2,6 +2,23 @@ import XCTest @testable import OpenCode_Bar final class TokenManagerTests: XCTestCase { + func testGeminiProjectPolicyPrefersConfiguredProject() { + XCTAssertEqual( + GeminiProjectPolicy.resolve(primary: "configured-project", fallback: "managed-project"), + "configured-project" + ) + } + + func testGeminiProjectPolicyUsesManagedProjectFallback() { + XCTAssertEqual( + GeminiProjectPolicy.resolve(primary: "", fallback: "managed-project"), + "managed-project" + ) + } + + func testGeminiProjectPolicyUsesDefaultWhenProjectIsMissing() { + XCTAssertEqual(GeminiProjectPolicy.resolve(primary: nil), "default") + } private func makeTestJWT(payload: String) -> String { func encode(_ string: String) -> String { diff --git a/scripts/query-claude.sh b/scripts/query-claude.sh index 7a778ead..f923c357 100755 --- a/scripts/query-claude.sh +++ b/scripts/query-claude.sh @@ -5,7 +5,16 @@ set -euo pipefail CLAUDE_USAGE_URL="https://api.anthropic.com/api/oauth/usage" CLAUDE_BETA_HEADER="${ANTHROPIC_OAUTH_BETA:-oauth-2025-04-20}" -CLAUDE_CODE_VERSION="${ANTHROPIC_CLI_VERSION:-2.1.80}" + +detect_claude_code_version() { + local claude_bin="" + claude_bin="$(command -v claude 2>/dev/null || true)" + [[ -n "$claude_bin" ]] || return 1 + "$claude_bin" --version 2>/dev/null | sed -E 's/^([0-9]+\.[0-9]+\.[0-9]+).*/\1/' +} + +DETECTED_CLAUDE_CODE_VERSION="$(detect_claude_code_version || true)" +CLAUDE_CODE_VERSION="${ANTHROPIC_CLI_VERSION:-${DETECTED_CLAUDE_CODE_VERSION:-2.1.80}}" CLAUDE_USER_AGENT="${ANTHROPIC_CODE_USER_AGENT:-claude-code/${CLAUDE_CODE_VERSION}}" AUTH_SOURCE="" From 22c83ce79784ead528c816c41eb21c89dacd3361 Mon Sep 17 00:00:00 2001 From: Ruben <17501732+Daltonganger@users.noreply.github.com> Date: Fri, 17 Jul 2026 14:48:14 +0200 Subject: [PATCH 2/4] fix: harden provider auth fallback contracts --- .github/workflows/test.yml | 9 + .../Providers/AntigravityProvider.swift | 34 +--- .../Providers/ClaudeProvider.swift | 134 +++++++++--- .../Providers/GeminiCLIProvider.swift | 124 +++++++---- .../Services/TokenManager.swift | 112 ++++++++-- .../ClaudeProviderTests.swift | 29 ++- .../GeminiCLIProviderTests.swift | 192 ++++++++++++++++++ scripts/claude-version.sh | 47 +++++ scripts/query-claude.sh | 14 +- scripts/tests/query-claude-version-test.sh | 53 +++++ 10 files changed, 620 insertions(+), 128 deletions(-) create mode 100644 scripts/claude-version.sh create mode 100755 scripts/tests/query-claude-version-test.sh diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 92ac5dda..6b6e44ca 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -8,6 +8,9 @@ on: paths: - '**.swift' - '**.xcodeproj/**' + - 'scripts/claude-version.sh' + - 'scripts/query-claude.sh' + - 'scripts/tests/query-claude-version-test.sh' - '.github/workflows/test.yml' pull_request: branches: @@ -16,6 +19,9 @@ on: paths: - '**.swift' - '**.xcodeproj/**' + - 'scripts/claude-version.sh' + - 'scripts/query-claude.sh' + - 'scripts/tests/query-claude-version-test.sh' - '.github/workflows/test.yml' workflow_dispatch: @@ -27,6 +33,9 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + + - name: Test Claude version detection + run: bash scripts/tests/query-claude-version-test.sh - name: Setup Xcode uses: maxim-lobanov/setup-xcode@v1 diff --git a/CopilotMonitor/CopilotMonitor/Providers/AntigravityProvider.swift b/CopilotMonitor/CopilotMonitor/Providers/AntigravityProvider.swift index 04f617c7..cbc95e93 100644 --- a/CopilotMonitor/CopilotMonitor/Providers/AntigravityProvider.swift +++ b/CopilotMonitor/CopilotMonitor/Providers/AntigravityProvider.swift @@ -166,7 +166,7 @@ final class AntigravityProvider: ProviderProtocol { private func fetchFromAccountsFallback(cacheError: Error) async throws -> ProviderResult { guard let account = resolveFallbackAccount() else { throw ProviderError.providerError( - "Antigravity cache unavailable and no enabled antigravity-accounts.json account with project ID was found" + "Antigravity cache unavailable and no enabled antigravity-accounts.json account with a refresh token was found" ) } @@ -174,33 +174,11 @@ final class AntigravityProvider: ProviderProtocol { throw ProviderError.authenticationFailed("Unable to refresh Antigravity fallback token") } - guard let url = URL(string: "https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota") else { - throw ProviderError.networkError("Invalid API endpoint") - } - - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.httpBody = "{\"project\":\"\(account.projectId)\"}".data(using: .utf8) - - let (data, response) = try await session.data(for: request) - guard let httpResponse = response as? HTTPURLResponse else { - throw ProviderError.networkError("Invalid response type") - } - - if httpResponse.statusCode == 401 { - throw ProviderError.authenticationFailed("Antigravity fallback token expired") - } - - guard (200...299).contains(httpResponse.statusCode) else { - throw ProviderError.networkError("HTTP \(httpResponse.statusCode)") - } - - let quotaResponse = try JSONDecoder().decode(GeminiQuotaResponse.self, from: data) - guard !quotaResponse.buckets.isEmpty else { - throw ProviderError.decodingError("Empty buckets array") - } + let quotaResponse = try await GeminiQuotaAPI.fetchQuota( + accessToken: accessToken, + projectId: account.projectId, + session: session + ) let parsed = parseQuotaBuckets(quotaResponse.buckets) let minRemaining = parsed.modelBreakdown.values.min() ?? 0.0 diff --git a/CopilotMonitor/CopilotMonitor/Providers/ClaudeProvider.swift b/CopilotMonitor/CopilotMonitor/Providers/ClaudeProvider.swift index 371dbd23..84a6f886 100644 --- a/CopilotMonitor/CopilotMonitor/Providers/ClaudeProvider.swift +++ b/CopilotMonitor/CopilotMonitor/Providers/ClaudeProvider.swift @@ -13,8 +13,8 @@ enum ClaudeOAuthRequestPolicy { installedVersion: String? = nil ) -> String { let rawVersion = environment["ANTHROPIC_CLI_VERSION"]?.trimmingCharacters(in: .whitespacesAndNewlines) - if let rawVersion, !rawVersion.isEmpty { - return rawVersion + if let overrideVersion = normalizedVersion(rawVersion) { + return overrideVersion } if let installedVersion = normalizedVersion(installedVersion) { @@ -38,7 +38,18 @@ enum ClaudeOAuthRequestPolicy { static func installedClaudeCodeVersion( environment: [String: String] = ProcessInfo.processInfo.environment, fileManager: FileManager = .default - ) -> String? { + ) async -> String? { + guard let executableURL = findClaudeExecutable(environment: environment, fileManager: fileManager) else { + return nil + } + + return await runVersionCommand(executableURL: executableURL) + } + + private static func findClaudeExecutable( + environment: [String: String], + fileManager: FileManager + ) -> URL? { let homeDirectory = fileManager.homeDirectoryForCurrentUser var candidatePaths: [String] = [] @@ -62,22 +73,68 @@ enum ClaudeOAuthRequestPolicy { var visitedPaths = Set() for path in candidatePaths where visitedPaths.insert(path).inserted { guard fileManager.isExecutableFile(atPath: path) else { continue } - let resolvedPath = URL(fileURLWithPath: path).resolvingSymlinksInPath().path - if let version = versionFromExecutablePath(resolvedPath) { - return version - } + return URL(fileURLWithPath: path) } return nil } - static func versionFromExecutablePath(_ path: String) -> String? { - for component in URL(fileURLWithPath: path).pathComponents.reversed() { - if let version = normalizedVersion(component) { - return version + static func versionFromCommandOutput(_ output: String) -> String? { + let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, + !trimmed.contains(where: \.isNewline) else { + return nil + } + + let versionText = String(trimmed.prefix { !$0.isWhitespace }) + guard let version = normalizedVersion(versionText) else { return nil } + + let suffix = String(trimmed.dropFirst(versionText.count)) + guard suffix.isEmpty || suffix == " (Claude Code)" else { return nil } + return version + } + + private static func runVersionCommand(executableURL: URL) async -> String? { + await withTaskGroup(of: String?.self) { group in + let process = Process() + let pipe = Pipe() + process.executableURL = executableURL + process.arguments = ["--version"] + process.standardOutput = pipe + process.standardError = FileHandle.nullDevice + + group.addTask { + await withCheckedContinuation { continuation in + process.terminationHandler = { _ in + let data = pipe.fileHandleForReading.readDataToEndOfFile() + continuation.resume(returning: String(data: data, encoding: .utf8)) + } + + do { + try process.run() + } catch { + continuation.resume(returning: nil) + } + } + } + + group.addTask { + try? await Task.sleep(nanoseconds: 3_000_000_000) + return nil + } + + let output: String? + if let firstResult = await group.next() { + output = firstResult + } else { + output = nil } + group.cancelAll() + if process.isRunning { + process.terminate() + } + return output.flatMap(versionFromCommandOutput) } - return nil } private static func normalizedVersion(_ value: String?) -> String? { @@ -260,11 +317,12 @@ final class ClaudeProvider: ProviderProtocol { throw ProviderError.authenticationFailed("Anthropic access token not available") } + let installedVersion = await ClaudeOAuthRequestPolicy.installedClaudeCodeVersion() var candidates: [ClaudeAccountCandidate] = [] var fetchErrors: [Error] = [] for account in accounts { do { - let candidate = try await fetchUsageForAccount(account) + let candidate = try await fetchUsageForAccount(account, installedVersion: installedVersion) candidates.append(candidate) } catch { fetchErrors.append(error) @@ -273,7 +331,11 @@ final class ClaudeProvider: ProviderProtocol { logger.info("Skipping unavailable OpenCode Claude account") continue } - let fallback = await unavailableCandidate(for: account, error: error) + let fallback = await unavailableCandidate( + for: account, + error: error, + installedVersion: installedVersion + ) candidates.append(fallback) } } @@ -467,7 +529,10 @@ final class ClaudeProvider: ProviderProtocol { return String(full.prefix(12)) } - private func fetchAccountIdentity(accessToken: String) async -> (accountId: String?, email: String?)? { + private func fetchAccountIdentity( + accessToken: String, + installedVersion: String? + ) async -> (accountId: String?, email: String?)? { let endpoints = [ "/api/oauth/profile", "/api/oauth/account" @@ -481,7 +546,7 @@ final class ClaudeProvider: ProviderProtocol { ClaudeOAuthRequestPolicy.applyHeaders( to: &request, accessToken: accessToken, - installedVersion: ClaudeOAuthRequestPolicy.installedClaudeCodeVersion() + installedVersion: installedVersion ) do { @@ -531,12 +596,18 @@ final class ClaudeProvider: ProviderProtocol { return nil } - private func resolveAccountIdentity(_ account: ClaudeAuthAccount) async -> ClaudeResolvedIdentity { + private func resolveAccountIdentity( + _ account: ClaudeAuthAccount, + installedVersion: String? + ) async -> ClaudeResolvedIdentity { var resolvedAccountId = normalizedNonEmpty(account.accountId) var resolvedEmail = normalizedNonEmpty(account.email, lowercase: true) if resolvedAccountId == nil || resolvedEmail == nil, - let apiIdentity = await fetchAccountIdentity(accessToken: account.accessToken) { + let apiIdentity = await fetchAccountIdentity( + accessToken: account.accessToken, + installedVersion: installedVersion + ) { if resolvedAccountId == nil { resolvedAccountId = apiIdentity.accountId } @@ -612,7 +683,10 @@ final class ClaudeProvider: ProviderProtocol { return message } - private func requestClaudeUsageData(accessToken: String) async throws -> Data { + private func requestClaudeUsageData( + accessToken: String, + installedVersion: String? + ) async throws -> Data { guard let url = claudeUsageEndpoint else { logger.error("Invalid Claude API URL") throw ProviderError.networkError("Invalid API endpoint") @@ -623,7 +697,7 @@ final class ClaudeProvider: ProviderProtocol { ClaudeOAuthRequestPolicy.applyHeaders( to: &request, accessToken: accessToken, - installedVersion: ClaudeOAuthRequestPolicy.installedClaudeCodeVersion() + installedVersion: installedVersion ) let (data, response) = try await session.data(for: request) @@ -653,10 +727,14 @@ final class ClaudeProvider: ProviderProtocol { return data } - private func unavailableCandidate(for account: ClaudeAuthAccount, error: Error) async -> ClaudeAccountCandidate { + private func unavailableCandidate( + for account: ClaudeAuthAccount, + error: Error, + installedVersion: String? + ) async -> ClaudeAccountCandidate { let sourceLabels = account.sourceLabels.isEmpty ? [sourceLabel(account.source)] : account.sourceLabels let authUsageSummary = sourceSummary(sourceLabels, fallback: "Unknown") - let identity = await resolveAccountIdentity(account) + let identity = await resolveAccountIdentity(account, installedVersion: installedVersion) logger.info( "Claude account fallback (\(authUsageSummary)): reason=\(error.localizedDescription)" @@ -680,10 +758,16 @@ final class ClaudeProvider: ProviderProtocol { ) } - private func fetchUsageForAccount(_ account: ClaudeAuthAccount) async throws -> ClaudeAccountCandidate { + private func fetchUsageForAccount( + _ account: ClaudeAuthAccount, + installedVersion: String? + ) async throws -> ClaudeAccountCandidate { // No token refresh here — refresh tokens are single-use. // If this app consumes the refresh token, OpenCode can no longer re-authenticate. - let data = try await requestClaudeUsageData(accessToken: account.accessToken) + let data = try await requestClaudeUsageData( + accessToken: account.accessToken, + installedVersion: installedVersion + ) do { let decoder = JSONDecoder() @@ -734,7 +818,7 @@ final class ClaudeProvider: ProviderProtocol { let sourceLabels = account.sourceLabels.isEmpty ? [sourceLabel(account.source)] : account.sourceLabels let authUsageSummary = sourceSummary(sourceLabels, fallback: "Unknown") - let identity = await resolveAccountIdentity(account) + let identity = await resolveAccountIdentity(account, installedVersion: installedVersion) logger.info("Claude usage fetched (\(authUsageSummary)): 7d=\(utilization)%, 5h=\(fiveHourUsage?.description ?? "N/A")%, fable7d=\(fableUsage?.description ?? "N/A", privacy: .public)%") logger.debug("Claude account identity (\(authUsageSummary)): dedupeKey=\(identity.dedupeKey), accountId=\(identity.accountId ?? "nil"), email=\(identity.email ?? "nil")") diff --git a/CopilotMonitor/CopilotMonitor/Providers/GeminiCLIProvider.swift b/CopilotMonitor/CopilotMonitor/Providers/GeminiCLIProvider.swift index 6746d22a..b971f174 100644 --- a/CopilotMonitor/CopilotMonitor/Providers/GeminiCLIProvider.swift +++ b/CopilotMonitor/CopilotMonitor/Providers/GeminiCLIProvider.swift @@ -20,6 +20,45 @@ struct GeminiUserInfoResponse: Codable { let email: String? } +enum GeminiQuotaAPI { + private static let endpoint = URL(string: "https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota") + + static func fetchQuota( + accessToken: String, + projectId: String, + session: URLSession + ) async throws -> GeminiQuotaResponse { + guard let endpoint else { + throw ProviderError.networkError("Invalid API endpoint") + } + + var request = URLRequest(url: endpoint) + request.httpMethod = "POST" + request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONSerialization.data(withJSONObject: ["project": projectId]) + + let (data, response) = try await session.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + throw ProviderError.networkError("Invalid response type") + } + + if httpResponse.statusCode == 401 { + throw ProviderError.authenticationFailed("Gemini quota token expired") + } + + guard (200...299).contains(httpResponse.statusCode) else { + throw ProviderError.networkError("HTTP \(httpResponse.statusCode)") + } + + let quotaResponse = try JSONDecoder().decode(GeminiQuotaResponse.self, from: data) + guard !quotaResponse.buckets.isEmpty else { + throw ProviderError.decodingError("Empty buckets array") + } + return quotaResponse + } +} + // MARK: - GeminiCLIProvider Implementation /// Provider for Google Gemini CLI quota tracking via cloudcode-pa.googleapis.com @@ -247,26 +286,46 @@ final class GeminiCLIProvider: ProviderProtocol { // MARK: - Private Helpers - private func fetchQuotaForAccount(account: GeminiAuthAccount) async throws -> GeminiAccountQuota { - let accountIndex = account.index - let configuredProjectId = account.projectId.trimmingCharacters(in: .whitespacesAndNewlines) - let projectId = GeminiProjectPolicy.resolve(primary: configuredProjectId) + private func refreshAccessToken(for account: GeminiAuthAccount) async throws -> String { + do { + return try await tokenManager.requestGeminiAccessToken( + refreshToken: account.refreshToken, + clientId: account.clientId, + clientSecret: account.clientSecret, + session: session + ) + } catch let primaryError as GeminiOAuthRefreshError { + guard GeminiOAuthRetryPolicy.shouldRetryWithGeminiCLIClient( + source: account.source, + error: primaryError + ) else { + throw primaryError + } - var refreshedAccessToken = await tokenManager.refreshGeminiAccessToken( - refreshToken: account.refreshToken, - clientId: account.clientId, - clientSecret: account.clientSecret - ) - if refreshedAccessToken == nil { logger.info( - "Gemini CLI: Primary OAuth client failed for account #\(accountIndex + 1); trying Gemini CLI OAuth client fallback" + "Gemini CLI: OAuth client mismatch for account #\(account.index + 1); trying Gemini CLI client" ) - refreshedAccessToken = await tokenManager.refreshGeminiAccessToken(refreshToken: account.refreshToken) - } - guard let accessToken = refreshedAccessToken else { - throw ProviderError.authenticationFailed("Unable to refresh token for account #\(accountIndex + 1)") + do { + return try await tokenManager.requestGeminiAccessToken( + refreshToken: account.refreshToken, + session: session + ) + } catch { + throw ProviderError.authenticationFailed( + "OAuth client fallback failed for account #\(account.index + 1). " + + "Primary: \(primaryError.localizedDescription). Fallback: \(error.localizedDescription)" + ) + } } + } + + private func fetchQuotaForAccount(account: GeminiAuthAccount) async throws -> GeminiAccountQuota { + let accountIndex = account.index + let configuredProjectId = account.projectId.trimmingCharacters(in: .whitespacesAndNewlines) + let projectId = GeminiProjectPolicy.resolve(primary: configuredProjectId) + + let accessToken = try await refreshAccessToken(for: account) if configuredProjectId.isEmpty { logger.info("Gemini CLI: Missing project ID for account #\(accountIndex + 1); using default project fallback") @@ -277,36 +336,11 @@ final class GeminiCLIProvider: ProviderProtocol { logger.warning("Gemini CLI: Email lookup failed for account #\(accountIndex + 1)") } - guard let url = URL(string: "https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota") else { - throw ProviderError.networkError("Invalid API endpoint") - } - - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - // The API accepts "default" when OAuth storage does not include a project ID. - request.httpBody = "{\"project\":\"\(projectId)\"}".data(using: .utf8) - - let (data, response) = try await session.data(for: request) - - guard let httpResponse = response as? HTTPURLResponse else { - throw ProviderError.networkError("Invalid response type") - } - - if httpResponse.statusCode == 401 { - throw ProviderError.authenticationFailed("Token expired for account #\(accountIndex + 1)") - } - - guard (200...299).contains(httpResponse.statusCode) else { - throw ProviderError.networkError("HTTP \(httpResponse.statusCode)") - } - - let quotaResponse = try JSONDecoder().decode(GeminiQuotaResponse.self, from: data) - - guard !quotaResponse.buckets.isEmpty else { - throw ProviderError.decodingError("Empty buckets array") - } + let quotaResponse = try await GeminiQuotaAPI.fetchQuota( + accessToken: accessToken, + projectId: projectId, + session: session + ) var modelBreakdown: [String: Double] = [:] var modelResetTimes: [String: Date] = [:] diff --git a/CopilotMonitor/CopilotMonitor/Services/TokenManager.swift b/CopilotMonitor/CopilotMonitor/Services/TokenManager.swift index 6ef27483..a5d7c652 100644 --- a/CopilotMonitor/CopilotMonitor/Services/TokenManager.swift +++ b/CopilotMonitor/CopilotMonitor/Services/TokenManager.swift @@ -591,7 +591,7 @@ struct AntigravityAccounts: Codable { } /// Auth source types for Gemini CLI token discovery -enum GeminiAuthSource { +enum GeminiAuthSource: Equatable { /// NoeFabris/opencode-antigravity-auth (~/.config/opencode/antigravity-accounts.json) case antigravity /// jenslys/opencode-gemini-auth (OpenCode auth.json google.oauth) @@ -632,6 +632,46 @@ enum GeminiProjectPolicy { } } +enum GeminiOAuthRefreshError: LocalizedError, Equatable { + case invalidResponse + case rejected(statusCode: Int, code: String?, message: String?) + case invalidTokenResponse + case transport(String) + + var errorDescription: String? { + switch self { + case .invalidResponse: + return "Invalid OAuth response" + case .rejected(let statusCode, let code, let message): + let detail = message ?? code ?? "Unknown OAuth error" + return "OAuth token refresh returned HTTP \(statusCode): \(detail)" + case .invalidTokenResponse: + return "OAuth token response did not contain a valid access token" + case .transport(let message): + return "OAuth token refresh failed: \(message)" + } + } + + var isClientMismatch: Bool { + guard case .rejected(let statusCode, let code, _) = self, + statusCode == 401, + let normalizedCode = code?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() else { + return false + } + + return ["invalid_client", "unauthorized", "unauthorized_client"].contains(normalizedCode) + } +} + +enum GeminiOAuthRetryPolicy { + static func shouldRetryWithGeminiCLIClient( + source: GeminiAuthSource, + error: GeminiOAuthRefreshError + ) -> Bool { + source == .opencodeAuth && error.isClientMismatch + } +} + /// Minimal OpenCode auth payload for jenslys/opencode-gemini-auth stored under "google" struct OpenCodeGeminiAuthContainer: Decodable { let google: GeminiOAuthAuth? @@ -771,6 +811,16 @@ struct GeminiTokenResponse: Codable { let token_type: String? } +private struct GeminiOAuthErrorResponse: Decodable { + let error: String? + let errorDescription: String? + + enum CodingKeys: String, CodingKey { + case error + case errorDescription = "error_description" + } +} + private extension String { var nilIfEmpty: String? { let trimmed = trimmingCharacters(in: .whitespacesAndNewlines) @@ -4471,22 +4521,23 @@ final class TokenManager: @unchecked Sendable { private static let geminiAuthPluginClientId = "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com" private static let geminiAuthPluginClientSecret = "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" - /// Refreshes Gemini OAuth access token using refresh token + /// Requests a Gemini OAuth access token and preserves typed OAuth failures. /// - Parameters: /// - refreshToken: The refresh token from Antigravity accounts /// - clientId: Google OAuth client ID (default: public CLI client ID) /// - clientSecret: Google OAuth client secret (default: public CLI client secret) - /// - Returns: New access token if successful, nil otherwise - func refreshGeminiAccessToken( + /// - session: URL session used for the token request + /// - Returns: New access token if successful + func requestGeminiAccessToken( refreshToken: String, clientId: String = TokenManager.geminiClientId, - clientSecret: String = TokenManager.geminiClientSecret - ) async -> String? { + clientSecret: String = TokenManager.geminiClientSecret, + session: URLSession = .shared + ) async throws -> String { let endpoint = "https://oauth2.googleapis.com/token" guard let url = URL(string: endpoint) else { - logger.error("Invalid OAuth endpoint URL") - return nil + throw GeminiOAuthRefreshError.invalidResponse } // Build request body @@ -4499,8 +4550,7 @@ final class TokenManager: @unchecked Sendable { ] guard let bodyString = components.query else { - logger.error("Failed to build request body") - return nil + throw GeminiOAuthRefreshError.invalidResponse } var request = URLRequest(url: url) @@ -4509,21 +4559,49 @@ final class TokenManager: @unchecked Sendable { request.httpBody = bodyString.data(using: .utf8) do { - let (data, response) = try await URLSession.shared.data(for: request) + let (data, response) = try await session.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { - logger.error("Invalid response type") - return nil + throw GeminiOAuthRefreshError.invalidResponse } guard httpResponse.statusCode == 200 else { - logger.error("OAuth token refresh failed with status: \(httpResponse.statusCode)") - return nil + let payload = try? JSONDecoder().decode(GeminiOAuthErrorResponse.self, from: data) + throw GeminiOAuthRefreshError.rejected( + statusCode: httpResponse.statusCode, + code: payload?.error, + message: payload?.errorDescription + ) + } + + guard let tokenResponse = try? JSONDecoder().decode(GeminiTokenResponse.self, from: data), + !tokenResponse.access_token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw GeminiOAuthRefreshError.invalidTokenResponse } - let tokenResponse = try JSONDecoder().decode(GeminiTokenResponse.self, from: data) - logger.info("Successfully refreshed Gemini access token") return tokenResponse.access_token + } catch let error as GeminiOAuthRefreshError { + throw error + } catch { + throw GeminiOAuthRefreshError.transport(error.localizedDescription) + } + } + + /// Refreshes Gemini OAuth access token using refresh token. + /// - Returns: New access token if successful, nil otherwise + func refreshGeminiAccessToken( + refreshToken: String, + clientId: String = TokenManager.geminiClientId, + clientSecret: String = TokenManager.geminiClientSecret + ) async -> String? { + do { + let accessToken = try await requestGeminiAccessToken( + refreshToken: refreshToken, + clientId: clientId, + clientSecret: clientSecret + ) + logger.info("Successfully refreshed Gemini access token") + return accessToken } catch { logger.error("Failed to refresh Gemini token: \(error.localizedDescription)") return nil diff --git a/CopilotMonitor/CopilotMonitorTests/ClaudeProviderTests.swift b/CopilotMonitor/CopilotMonitorTests/ClaudeProviderTests.swift index a83754a1..161ddde4 100644 --- a/CopilotMonitor/CopilotMonitorTests/ClaudeProviderTests.swift +++ b/CopilotMonitor/CopilotMonitorTests/ClaudeProviderTests.swift @@ -146,9 +146,32 @@ final class ClaudeProviderTests: XCTestCase { XCTAssertEqual(userAgent, "claude-code/2.1.199") } - func testClaudeOAuthRequestPolicyExtractsVersionFromOfficialInstallPath() { - let path = "/Users/example/.local/share/claude/versions/2.1.199" - XCTAssertEqual(ClaudeOAuthRequestPolicy.versionFromExecutablePath(path), "2.1.199") + func testClaudeOAuthRequestPolicyRejectsInvalidVersionOverride() { + let userAgent = ClaudeOAuthRequestPolicy.usageUserAgent( + environment: ["ANTHROPIC_CLI_VERSION": "invalid"], + installedVersion: "2.1.199" + ) + + XCTAssertEqual(userAgent, "claude-code/2.1.199") + } + + func testClaudeOAuthRequestPolicyParsesOfficialVersionOutput() { + XCTAssertEqual( + ClaudeOAuthRequestPolicy.versionFromCommandOutput("2.1.199 (Claude Code)\n"), + "2.1.199" + ) + } + + func testClaudeOAuthRequestPolicyRejectsPrefixedVersionOutput() { + XCTAssertNil(ClaudeOAuthRequestPolicy.versionFromCommandOutput("Claude Code 2.1.199")) + } + + func testClaudeOAuthRequestPolicyRejectsMultilineVersionOutput() { + XCTAssertNil(ClaudeOAuthRequestPolicy.versionFromCommandOutput("2.1.199\nunexpected")) + } + + func testClaudeOAuthRequestPolicyRejectsPrereleaseVersionOutput() { + XCTAssertNil(ClaudeOAuthRequestPolicy.versionFromCommandOutput("2.1.199-beta")) } private func loadFixture(named: String) -> Data { diff --git a/CopilotMonitor/CopilotMonitorTests/GeminiCLIProviderTests.swift b/CopilotMonitor/CopilotMonitorTests/GeminiCLIProviderTests.swift index 28527cc9..b661ce91 100644 --- a/CopilotMonitor/CopilotMonitorTests/GeminiCLIProviderTests.swift +++ b/CopilotMonitor/CopilotMonitorTests/GeminiCLIProviderTests.swift @@ -2,6 +2,66 @@ import XCTest @testable import OpenCode_Bar final class GeminiCLIProviderTests: XCTestCase { + private final class MockURLProtocol: URLProtocol { + static var requestHandler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + + override static func canInit(with request: URLRequest) -> Bool { + true + } + + override static func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + guard let handler = MockURLProtocol.requestHandler else { + client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse)) + return + } + + do { + let (response, data) = try handler(request) + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + } catch { + client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} + } + + private func makeSession() -> URLSession { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [MockURLProtocol.self] + return URLSession(configuration: configuration) + } + + private static func requestBodyData(from request: URLRequest) -> Data? { + if let body = request.httpBody { + return body + } + + guard let stream = request.httpBodyStream else { return nil } + stream.open() + defer { stream.close() } + + var data = Data() + var buffer = [UInt8](repeating: 0, count: 1_024) + while stream.hasBytesAvailable { + let count = stream.read(&buffer, maxLength: buffer.count) + guard count >= 0 else { return nil } + if count == 0 { break } + data.append(buffer, count: count) + } + return data + } + + override func tearDown() { + MockURLProtocol.requestHandler = nil + super.tearDown() + } func testParseGeminiQuotaResponse() throws { let fixture = try loadFixture(named: "gemini_response") @@ -61,6 +121,138 @@ final class GeminiCLIProviderTests: XCTestCase { let earliestReset = resetDates.min() XCTAssertNotNil(earliestReset) } + + func testQuotaRequestUsesDefaultProjectAndDecodesSuccessfulResponse() async throws { + let session = makeSession() + defer { session.invalidateAndCancel() } + let fixture = try loadFixture(named: "gemini_response") + let responseData = try JSONSerialization.data(withJSONObject: fixture) + + MockURLProtocol.requestHandler = { request in + XCTAssertEqual(request.httpMethod, "POST") + XCTAssertEqual(request.url?.absoluteString, "https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota") + XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "Bearer test-access-token") + XCTAssertEqual(request.value(forHTTPHeaderField: "Content-Type"), "application/json") + + let requestData = try XCTUnwrap(Self.requestBodyData(from: request)) + let payload = try XCTUnwrap( + JSONSerialization.jsonObject(with: requestData) as? [String: String] + ) + XCTAssertEqual(payload, ["project": "default"]) + + let url = try XCTUnwrap(request.url) + let response = try XCTUnwrap( + HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil) + ) + return (response, responseData) + } + + let response = try await GeminiQuotaAPI.fetchQuota( + accessToken: "test-access-token", + projectId: GeminiProjectPolicy.resolve(primary: nil), + session: session + ) + + XCTAssertEqual(response.buckets.count, 5) + XCTAssertEqual(response.buckets.last?.modelId, "gemini-3-pro-preview") + } + + func testOAuthRefreshPreservesInvalidGrantWithoutRetry() async throws { + let session = makeSession() + defer { session.invalidateAndCancel() } + + MockURLProtocol.requestHandler = { request in + let url = try XCTUnwrap(request.url) + let response = try XCTUnwrap( + HTTPURLResponse(url: url, statusCode: 400, httpVersion: nil, headerFields: nil) + ) + let data = Data(#"{"error":"invalid_grant","error_description":"Token expired"}"#.utf8) + return (response, data) + } + + do { + _ = try await TokenManager.shared.requestGeminiAccessToken( + refreshToken: "test-refresh-token", + clientId: "account-client", + clientSecret: "account-secret", + session: session + ) + XCTFail("Expected typed OAuth rejection") + } catch let error as GeminiOAuthRefreshError { + XCTAssertEqual( + error, + .rejected(statusCode: 400, code: "invalid_grant", message: "Token expired") + ) + XCTAssertFalse( + GeminiOAuthRetryPolicy.shouldRetryWithGeminiCLIClient( + source: .opencodeAuth, + error: error + ) + ) + } catch { + XCTFail("Unexpected error: \(error)") + } + } + + func testOAuthRefreshClassifiesOpenCodeClientMismatchForRetry() async throws { + let session = makeSession() + defer { session.invalidateAndCancel() } + + MockURLProtocol.requestHandler = { request in + let url = try XCTUnwrap(request.url) + let response = try XCTUnwrap( + HTTPURLResponse(url: url, statusCode: 401, httpVersion: nil, headerFields: nil) + ) + return (response, Data(#"{"error":"Unauthorized"}"#.utf8)) + } + + do { + _ = try await TokenManager.shared.requestGeminiAccessToken( + refreshToken: "test-refresh-token", + clientId: "opencode-plugin-client", + clientSecret: "opencode-plugin-secret", + session: session + ) + XCTFail("Expected typed OAuth rejection") + } catch let error as GeminiOAuthRefreshError { + XCTAssertTrue(error.isClientMismatch) + XCTAssertTrue( + GeminiOAuthRetryPolicy.shouldRetryWithGeminiCLIClient( + source: .opencodeAuth, + error: error + ) + ) + } catch { + XCTFail("Unexpected error: \(error)") + } + } + + func testOAuthRetryPolicyAllowsOnlyOpenCodeClientMismatch() { + let mismatch = GeminiOAuthRefreshError.rejected( + statusCode: 401, + code: "unauthorized_client", + message: nil + ) + + XCTAssertTrue( + GeminiOAuthRetryPolicy.shouldRetryWithGeminiCLIClient( + source: .opencodeAuth, + error: mismatch + ) + ) + XCTAssertFalse( + GeminiOAuthRetryPolicy.shouldRetryWithGeminiCLIClient( + source: .antigravity, + error: mismatch + ) + ) + XCTAssertFalse( + GeminiOAuthRetryPolicy.shouldRetryWithGeminiCLIClient( + source: .oauthCreds, + error: mismatch + ) + ) + } private func loadFixture(named: String) throws -> Any { let testBundle = Bundle(for: type(of: self)) diff --git a/scripts/claude-version.sh b/scripts/claude-version.sh new file mode 100644 index 00000000..4d71bfd3 --- /dev/null +++ b/scripts/claude-version.sh @@ -0,0 +1,47 @@ +#!/bin/bash +# Shared Claude Code version policy for shell diagnostics. + +CLAUDE_DEFAULT_CODE_VERSION="2.1.80" + +normalize_claude_code_version() { + local raw="${1:-}" + raw="${raw#"${raw%%[![:space:]]*}"}" + raw="${raw%"${raw##*[![:space:]]}"}" + + [[ -n "$raw" ]] || return 1 + [[ "$raw" != *$'\n'* && "$raw" != *$'\r'* ]] || return 1 + + if [[ "$raw" =~ ^([0-9]+\.[0-9]+\.[0-9]+)(\ \(Claude\ Code\))?$ ]]; then + printf '%s\n' "${BASH_REMATCH[1]}" + return 0 + fi + + return 1 +} + +detect_claude_code_version() { + local claude_bin="" + local output="" + claude_bin="$(command -v claude 2>/dev/null || true)" + [[ -n "$claude_bin" ]] || return 1 + + output="$("$claude_bin" --version 2>/dev/null)" || return 1 + normalize_claude_code_version "$output" +} + +resolve_claude_code_version() { + local override="${ANTHROPIC_CLI_VERSION:-}" + local version="" + + if version="$(normalize_claude_code_version "$override")"; then + printf '%s\n' "$version" + return 0 + fi + + if version="$(detect_claude_code_version)"; then + printf '%s\n' "$version" + return 0 + fi + + printf '%s\n' "$CLAUDE_DEFAULT_CODE_VERSION" +} diff --git a/scripts/query-claude.sh b/scripts/query-claude.sh index f923c357..d66b8d34 100755 --- a/scripts/query-claude.sh +++ b/scripts/query-claude.sh @@ -3,18 +3,12 @@ set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/claude-version.sh" + CLAUDE_USAGE_URL="https://api.anthropic.com/api/oauth/usage" CLAUDE_BETA_HEADER="${ANTHROPIC_OAUTH_BETA:-oauth-2025-04-20}" - -detect_claude_code_version() { - local claude_bin="" - claude_bin="$(command -v claude 2>/dev/null || true)" - [[ -n "$claude_bin" ]] || return 1 - "$claude_bin" --version 2>/dev/null | sed -E 's/^([0-9]+\.[0-9]+\.[0-9]+).*/\1/' -} - -DETECTED_CLAUDE_CODE_VERSION="$(detect_claude_code_version || true)" -CLAUDE_CODE_VERSION="${ANTHROPIC_CLI_VERSION:-${DETECTED_CLAUDE_CODE_VERSION:-2.1.80}}" +CLAUDE_CODE_VERSION="$(resolve_claude_code_version)" CLAUDE_USER_AGENT="${ANTHROPIC_CODE_USER_AGENT:-claude-code/${CLAUDE_CODE_VERSION}}" AUTH_SOURCE="" diff --git a/scripts/tests/query-claude-version-test.sh b/scripts/tests/query-claude-version-test.sh new file mode 100755 index 00000000..908a7a65 --- /dev/null +++ b/scripts/tests/query-claude-version-test.sh @@ -0,0 +1,53 @@ +#!/bin/bash +# Contract tests for Claude Code version detection used by query-claude.sh. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +source "$REPO_ROOT/scripts/claude-version.sh" + +TEMP_DIR="$(mktemp -d)" +FAKE_BIN="$TEMP_DIR/bin" +mkdir -p "$FAKE_BIN" +trap 'rm -rf "$TEMP_DIR"' EXIT + +write_fake_claude() { + local command_body="$1" + printf '%s\n%s\n' '#!/bin/bash' "$command_body" > "$FAKE_BIN/claude" + chmod +x "$FAKE_BIN/claude" +} + +assert_version() { + local expected="$1" + local description="$2" + local actual="" + actual="$(PATH="$FAKE_BIN:/usr/bin:/bin" resolve_claude_code_version)" + if [[ "$actual" != "$expected" ]]; then + echo "FAIL: $description (expected $expected, got $actual)" >&2 + exit 1 + fi +} + +unset ANTHROPIC_CLI_VERSION + +write_fake_claude 'printf "%s\n" "2.1.199 (Claude Code)"' +assert_version "2.1.199" "official Claude Code output" + +write_fake_claude 'printf "%s\n" "Claude Code 2.1.199"' +assert_version "$CLAUDE_DEFAULT_CODE_VERSION" "prefixed output fallback" + +write_fake_claude 'printf "2.1.199\nunexpected\n"' +assert_version "$CLAUDE_DEFAULT_CODE_VERSION" "multiline output fallback" + +write_fake_claude 'printf "%s\n" "2.1.199-beta"' +assert_version "$CLAUDE_DEFAULT_CODE_VERSION" "prerelease output fallback" + +write_fake_claude 'exit 1' +assert_version "$CLAUDE_DEFAULT_CODE_VERSION" "command failure fallback" + +write_fake_claude 'printf "%s\n" "2.1.199 (Claude Code)"' +ANTHROPIC_CLI_VERSION="invalid" assert_version "2.1.199" "invalid override fallback" +ANTHROPIC_CLI_VERSION="3.0.0" assert_version "3.0.0" "valid override" + +echo "Claude version shell tests passed" From bec84c440cf6d510b8e2e1148fc59043c31c3680 Mon Sep 17 00:00:00 2001 From: Ruben <17501732+Daltonganger@users.noreply.github.com> Date: Fri, 17 Jul 2026 15:59:42 +0200 Subject: [PATCH 3/4] fix: complete provider fallback edge cases --- .../CopilotMonitor.xcodeproj/project.pbxproj | 4 + .../Providers/AntigravityProvider.swift | 55 +++++----- .../Providers/ClaudeProvider.swift | 6 +- .../Providers/GeminiCLIProvider.swift | 1 + .../Services/TokenManager.swift | 100 +++++++++++------- .../AntigravityProviderVarintTests.swift | 29 ++++- .../ClaudeProviderTests.swift | 4 + .../GeminiCLIProviderTests.swift | 53 +++++++++- .../TokenManagerTests.swift | 22 ++++ docs/AI_USAGE_API_REFERENCE.md | 7 +- scripts/tests/query-claude-version-test.sh | 3 + 11 files changed, 214 insertions(+), 70 deletions(-) diff --git a/CopilotMonitor/CopilotMonitor.xcodeproj/project.pbxproj b/CopilotMonitor/CopilotMonitor.xcodeproj/project.pbxproj index 1e7d12f2..b9619f34 100644 --- a/CopilotMonitor/CopilotMonitor.xcodeproj/project.pbxproj +++ b/CopilotMonitor/CopilotMonitor.xcodeproj/project.pbxproj @@ -10,6 +10,7 @@ 0B6CF8BEB936A2055F19AA8C /* CodexProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76783C44AA2329AE3FA7E981 /* CodexProvider.swift */; }; 0EA5E7A5BCD47582D6C2175B /* TokenManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF0C48368166F3AD0C79F92 /* TokenManager.swift */; }; 23AA5FD37F2062EACCA79F50 /* GeminiCLIProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B63B85E0F2A8C591D7E26AE2 /* GeminiCLIProviderTests.swift */; }; + ANTIGRAVITYTESTBF11111 /* AntigravityProviderVarintTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ANTIGRAVITYTESTFR11111 /* AntigravityProviderVarintTests.swift */; }; 27A0B56ADAC5B75A3270F90D /* CopilotProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4AE672516E564CC52739BD9 /* CopilotProvider.swift */; }; 283348F92F313096004DADE1 /* ProviderResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = PR2222222222222222222222 /* ProviderResult.swift */; }; 283348FA2F313096004DADE1 /* UsageHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = E44444444444444444444444 /* UsageHistory.swift */; }; @@ -186,6 +187,7 @@ AD0CA52627AF05BD67B56E /* StatusBarIconView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusBarIconView.swift; sourceTree = ""; }; AM2222222222222222222222 /* AppMigrationHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppMigrationHelper.swift; sourceTree = ""; }; B63B85E0F2A8C591D7E26AE2 /* GeminiCLIProviderTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GeminiCLIProviderTests.swift; sourceTree = ""; }; + ANTIGRAVITYTESTFR11111 /* AntigravityProviderVarintTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AntigravityProviderVarintTests.swift; sourceTree = ""; }; BE39D5663A7ABADC7E54B91D /* ProviderProtocol.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ProviderProtocol.swift; sourceTree = ""; }; C1C7C9FDB4600114F02B2915 /* MenuDesignToken.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MenuDesignToken.swift; sourceTree = ""; }; C8493409A9188CFF5B73D5B3 /* MenuDesignTokenTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MenuDesignTokenTests.swift; sourceTree = ""; }; @@ -445,6 +447,7 @@ T22222222222222222222222 /* ProviderUsageTests.swift */, TOOOOOOOOOOOOOOOOOOOOOOO /* Fixtures */, B63B85E0F2A8C591D7E26AE2 /* GeminiCLIProviderTests.swift */, + ANTIGRAVITYTESTFR11111 /* AntigravityProviderVarintTests.swift */, ED35D8BD06A436306085737D /* CopilotProviderTests.swift */, OR3333333333333333333333 /* OpenRouterProviderTests.swift */, 2D2B4000E33060BA5E00351B /* DependencyTests.swift */, @@ -704,6 +707,7 @@ files = ( T11111111111111111111111 /* ProviderUsageTests.swift in Sources */, 23AA5FD37F2062EACCA79F50 /* GeminiCLIProviderTests.swift in Sources */, + ANTIGRAVITYTESTBF11111 /* AntigravityProviderVarintTests.swift in Sources */, 632E52F6050FAD5CAF928E36 /* CopilotProviderTests.swift in Sources */, OR4444444444444444444444 /* OpenRouterProviderTests.swift in Sources */, 668B6906C95903D51823808A /* DependencyTests.swift in Sources */, diff --git a/CopilotMonitor/CopilotMonitor/Providers/AntigravityProvider.swift b/CopilotMonitor/CopilotMonitor/Providers/AntigravityProvider.swift index cbc95e93..dff62312 100644 --- a/CopilotMonitor/CopilotMonitor/Providers/AntigravityProvider.swift +++ b/CopilotMonitor/CopilotMonitor/Providers/AntigravityProvider.swift @@ -218,36 +218,12 @@ final class AntigravityProvider: ProviderProtocol { return nil } - let preferredIndexes: [Int?] = [ - antigravityAccounts.activeIndexByFamily?["gemini"], - antigravityAccounts.activeIndex - ] - - func accountAtPreferredIndex() -> AntigravityAccounts.Account? { - for preferredIndex in preferredIndexes { - guard let index = preferredIndex, - antigravityAccounts.accounts.indices.contains(index) else { - continue - } - - let account = antigravityAccounts.accounts[index] - if account.enabled == false { - continue - } - - return account - } - - return antigravityAccounts.accounts.first(where: { $0.enabled != false }) - } - - guard let account = accountAtPreferredIndex() else { - logger.warning("Antigravity fallback unavailable: no enabled account found") + guard let account = Self.selectFallbackAccount(from: antigravityAccounts) else { + logger.warning("Antigravity fallback unavailable: no enabled account with a refresh token found") return nil } - let refreshToken = account.refreshToken?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - guard !refreshToken.isEmpty else { + guard let refreshToken = nonEmptyTrimmed(account.refreshToken) else { logger.warning("Antigravity fallback unavailable: selected account is missing refresh token") return nil } @@ -272,6 +248,31 @@ final class AntigravityProvider: ProviderProtocol { ) } + static func selectFallbackAccount(from accounts: AntigravityAccounts) -> AntigravityAccounts.Account? { + let preferredIndexes: [Int?] = [ + accounts.activeIndexByFamily?["gemini"], + accounts.activeIndex + ] + var orderedIndexes = preferredIndexes.compactMap { $0 } + orderedIndexes.append(contentsOf: accounts.accounts.indices) + var visitedIndexes = Set() + + for index in orderedIndexes { + guard visitedIndexes.insert(index).inserted, + accounts.accounts.indices.contains(index) else { + continue + } + + let account = accounts.accounts[index] + let refreshToken = account.refreshToken?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if account.enabled != false, !refreshToken.isEmpty { + return account + } + } + + return nil + } + private func parseQuotaBuckets(_ buckets: [GeminiQuotaResponse.Bucket]) -> AntigravityParsedCacheUsage { var modelBreakdown: [String: Double] = [:] var modelResetTimes: [String: Date] = [:] diff --git a/CopilotMonitor/CopilotMonitor/Providers/ClaudeProvider.swift b/CopilotMonitor/CopilotMonitor/Providers/ClaudeProvider.swift index 84a6f886..535db4d7 100644 --- a/CopilotMonitor/CopilotMonitor/Providers/ClaudeProvider.swift +++ b/CopilotMonitor/CopilotMonitor/Providers/ClaudeProvider.swift @@ -142,7 +142,11 @@ enum ClaudeOAuthRequestPolicy { let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) let components = trimmed.split(separator: ".", omittingEmptySubsequences: false) guard components.count == 3, - components.allSatisfy({ !$0.isEmpty && $0.allSatisfy(\.isNumber) }) else { + components.allSatisfy({ component in + !component.isEmpty && component.utf8.allSatisfy { byte in + byte >= 48 && byte <= 57 + } + }) else { return nil } return trimmed diff --git a/CopilotMonitor/CopilotMonitor/Providers/GeminiCLIProvider.swift b/CopilotMonitor/CopilotMonitor/Providers/GeminiCLIProvider.swift index b971f174..32b380bd 100644 --- a/CopilotMonitor/CopilotMonitor/Providers/GeminiCLIProvider.swift +++ b/CopilotMonitor/CopilotMonitor/Providers/GeminiCLIProvider.swift @@ -37,6 +37,7 @@ enum GeminiQuotaAPI { request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = try JSONSerialization.data(withJSONObject: ["project": projectId]) + request.timeoutInterval = 10 let (data, response) = try await session.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { diff --git a/CopilotMonitor/CopilotMonitor/Services/TokenManager.swift b/CopilotMonitor/CopilotMonitor/Services/TokenManager.swift index a5d7c652..28ad3ecf 100644 --- a/CopilotMonitor/CopilotMonitor/Services/TokenManager.swift +++ b/CopilotMonitor/CopilotMonitor/Services/TokenManager.swift @@ -618,17 +618,22 @@ enum GeminiProjectPolicy { static let fallbackProjectId = "default" static func resolve(primary: String?, fallback: String? = nil) -> String { - let primaryProjectId = primary?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - if !primaryProjectId.isEmpty { - return primaryProjectId - } + firstNonEmpty([primary, fallback]) ?? fallbackProjectId + } - let fallbackProjectId = fallback?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - if !fallbackProjectId.isEmpty { - return fallbackProjectId - } + static func resolveStandalone( + projectId: String?, + quotaProjectId: String?, + environmentProjectId: String? + ) -> String { + firstNonEmpty([projectId, quotaProjectId, environmentProjectId]) ?? fallbackProjectId + } - return Self.fallbackProjectId + private static func firstNonEmpty(_ candidates: [String?]) -> String? { + candidates.lazy.compactMap { candidate in + let trimmed = candidate?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? nil : trimmed + }.first } } @@ -654,12 +659,20 @@ enum GeminiOAuthRefreshError: LocalizedError, Equatable { var isClientMismatch: Bool { guard case .rejected(let statusCode, let code, _) = self, - statusCode == 401, let normalizedCode = code?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() else { return false } - return ["invalid_client", "unauthorized", "unauthorized_client"].contains(normalizedCode) + switch (statusCode, normalizedCode) { + case (400, "invalid_client"), + (400, "unauthorized_client"), + (401, "invalid_client"), + (401, "unauthorized_client"), + (401, "unauthorized"): + return true + default: + return false + } } } @@ -4343,6 +4356,35 @@ final class TokenManager: @unchecked Sendable { return getAllGeminiAccounts().first?.email } + func standaloneGeminiAccount( + from oauthCreds: GeminiOAuthCreds, + authSource: String, + environmentProjectId: String? + ) -> GeminiAuthAccount? { + guard let refreshToken = normalizedNonEmpty(oauthCreds.refreshToken) else { + return nil + } + + let identity = decodeGeminiIDTokenPayload(oauthCreds.idToken) + let client = geminiOAuthClientCredentials(for: normalizedNonEmpty(identity?.audience)) + return GeminiAuthAccount( + index: 0, + accountId: normalizedNonEmpty(identity?.sub), + email: normalizedNonEmpty(identity?.email), + refreshToken: refreshToken, + projectId: GeminiProjectPolicy.resolveStandalone( + projectId: oauthCreds.projectId, + quotaProjectId: oauthCreds.quotaProjectId, + environmentProjectId: environmentProjectId + ), + authSource: authSource, + sourceLabels: [geminiSourceLabel(for: .oauthCreds)], + clientId: client.clientId, + clientSecret: client.clientSecret, + source: .oauthCreds + ) + } + /// Gets all Gemini accounts (NoeFabris/opencode-antigravity-auth + jenslys/opencode-gemini-auth) /// and enriches account identity metadata from ~/.gemini/oauth_creds.json when available. func getAllGeminiAccounts() -> [GeminiAuthAccount] { @@ -4353,7 +4395,6 @@ final class TokenManager: @unchecked Sendable { let oauthCredsEmail = normalizedNonEmpty(oauthCredsPayload?.email) let oauthCredsRefreshToken = normalizedNonEmpty(oauthCreds?.refreshToken) let oauthCredsAudience = normalizedNonEmpty(oauthCredsPayload?.audience) - let oauthCredsClient = geminiOAuthClientCredentials(for: oauthCredsAudience) let geminiOAuthCredsSource = lastFoundGeminiOAuthCredsPath?.path ?? geminiOAuthCredsPath().path if oauthCreds != nil { @@ -4458,30 +4499,17 @@ final class TokenManager: @unchecked Sendable { } if accounts.isEmpty, - let refreshToken = oauthCredsRefreshToken { - let standaloneProjectId = normalizedNonEmpty(oauthCreds?.projectId) - ?? normalizedNonEmpty(oauthCreds?.quotaProjectId) - ?? normalizedNonEmpty(ProcessInfo.processInfo.environment["GEMINI_PROJECT_ID"]) - - if let projectId = standaloneProjectId { - accounts.append( - GeminiAuthAccount( - index: 0, - accountId: oauthCredsAccountId, - email: oauthCredsEmail, - refreshToken: refreshToken, - projectId: projectId, - authSource: geminiOAuthCredsSource, - sourceLabels: [geminiSourceLabel(for: .oauthCreds)], - clientId: oauthCredsClient.clientId, - clientSecret: oauthCredsClient.clientSecret, - source: .oauthCreds - ) - ) - logger.info("Gemini oauth_creds.json used as standalone account source") - } else { - logger.info("Gemini oauth_creds.json found but project ID is missing; skipping standalone account source") - } + let oauthCreds, + let standaloneAccount = standaloneGeminiAccount( + from: oauthCreds, + authSource: geminiOAuthCredsSource, + environmentProjectId: ProcessInfo.processInfo.environment["GEMINI_PROJECT_ID"] + ) { + accounts.append(standaloneAccount) + if standaloneAccount.projectId == GeminiProjectPolicy.fallbackProjectId { + logger.info("Gemini oauth_creds.json has no project ID; using default project fallback") + } + logger.info("Gemini oauth_creds.json used as standalone account source") } if accounts.isEmpty { diff --git a/CopilotMonitor/CopilotMonitorTests/AntigravityProviderVarintTests.swift b/CopilotMonitor/CopilotMonitorTests/AntigravityProviderVarintTests.swift index 526e445c..f8b9c9c4 100644 --- a/CopilotMonitor/CopilotMonitorTests/AntigravityProviderVarintTests.swift +++ b/CopilotMonitor/CopilotMonitorTests/AntigravityProviderVarintTests.swift @@ -1,5 +1,5 @@ import XCTest -@testable import CopilotMonitor +@testable import OpenCode_Bar final class AntigravityProviderVarintTests: XCTestCase { private let provider = AntigravityProvider() @@ -65,6 +65,33 @@ final class AntigravityProviderVarintTests: XCTestCase { XCTAssertThrowsError(try provider.parseProtobufMessage(payload)) } + func testFallbackAccountSelectionSkipsStalePreferredAccount() throws { + let staleAccount = AntigravityAccounts.Account( + email: "stale@example.com", + refreshToken: " ", + projectId: nil, + managedProjectId: nil, + enabled: true + ) + let validAccount = AntigravityAccounts.Account( + email: "valid@example.com", + refreshToken: "valid-refresh-token", + projectId: nil, + managedProjectId: nil, + enabled: true + ) + let accounts = AntigravityAccounts( + version: 4, + accounts: [staleAccount, validAccount], + activeIndex: 0, + activeIndexByFamily: ["gemini": 0] + ) + + let selected = try XCTUnwrap(AntigravityProvider.selectFallbackAccount(from: accounts)) + XCTAssertEqual(selected.email, "valid@example.com") + XCTAssertEqual(selected.refreshToken, "valid-refresh-token") + } + private func assertVarintRoundTrip(_ expected: UInt64) throws { let encoded = encodeVarint(expected) var index = 0 diff --git a/CopilotMonitor/CopilotMonitorTests/ClaudeProviderTests.swift b/CopilotMonitor/CopilotMonitorTests/ClaudeProviderTests.swift index 161ddde4..50bc49e5 100644 --- a/CopilotMonitor/CopilotMonitorTests/ClaudeProviderTests.swift +++ b/CopilotMonitor/CopilotMonitorTests/ClaudeProviderTests.swift @@ -173,6 +173,10 @@ final class ClaudeProviderTests: XCTestCase { func testClaudeOAuthRequestPolicyRejectsPrereleaseVersionOutput() { XCTAssertNil(ClaudeOAuthRequestPolicy.versionFromCommandOutput("2.1.199-beta")) } + + func testClaudeOAuthRequestPolicyRejectsUnicodeDigits() { + XCTAssertNil(ClaudeOAuthRequestPolicy.versionFromCommandOutput("٢.١.١٩٩")) + } private func loadFixture(named: String) -> Data { let bundle = Bundle(for: type(of: self)) diff --git a/CopilotMonitor/CopilotMonitorTests/GeminiCLIProviderTests.swift b/CopilotMonitor/CopilotMonitorTests/GeminiCLIProviderTests.swift index b661ce91..8d22d188 100644 --- a/CopilotMonitor/CopilotMonitorTests/GeminiCLIProviderTests.swift +++ b/CopilotMonitor/CopilotMonitorTests/GeminiCLIProviderTests.swift @@ -122,17 +122,33 @@ final class GeminiCLIProviderTests: XCTestCase { XCTAssertNotNil(earliestReset) } - func testQuotaRequestUsesDefaultProjectAndDecodesSuccessfulResponse() async throws { + func testStandaloneOAuthCredentialsWithoutProjectReachDefaultQuotaRequest() async throws { let session = makeSession() defer { session.invalidateAndCancel() } let fixture = try loadFixture(named: "gemini_response") let responseData = try JSONSerialization.data(withJSONObject: fixture) + let credentials = try JSONDecoder().decode( + GeminiOAuthCreds.self, + from: Data(#"{"refresh_token":"standalone-refresh-token"}"#.utf8) + ) + let account = try XCTUnwrap( + TokenManager.shared.standaloneGeminiAccount( + from: credentials, + authSource: "/tmp/oauth_creds.json", + environmentProjectId: nil + ) + ) + + XCTAssertEqual(account.refreshToken, "standalone-refresh-token") + XCTAssertEqual(account.projectId, "default") + XCTAssertEqual(account.source, .oauthCreds) MockURLProtocol.requestHandler = { request in XCTAssertEqual(request.httpMethod, "POST") XCTAssertEqual(request.url?.absoluteString, "https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota") XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "Bearer test-access-token") XCTAssertEqual(request.value(forHTTPHeaderField: "Content-Type"), "application/json") + XCTAssertEqual(request.timeoutInterval, 10, accuracy: 0.001) let requestData = try XCTUnwrap(Self.requestBodyData(from: request)) let payload = try XCTUnwrap( @@ -149,7 +165,7 @@ final class GeminiCLIProviderTests: XCTestCase { let response = try await GeminiQuotaAPI.fetchQuota( accessToken: "test-access-token", - projectId: GeminiProjectPolicy.resolve(primary: nil), + projectId: account.projectId, session: session ) @@ -227,6 +243,39 @@ final class GeminiCLIProviderTests: XCTestCase { } } + func testOAuthRefreshClassifiesHTTP400ClientMismatchForRetry() async throws { + let session = makeSession() + defer { session.invalidateAndCancel() } + + MockURLProtocol.requestHandler = { request in + let url = try XCTUnwrap(request.url) + let response = try XCTUnwrap( + HTTPURLResponse(url: url, statusCode: 400, httpVersion: nil, headerFields: nil) + ) + return (response, Data(#"{"error":"invalid_client"}"#.utf8)) + } + + do { + _ = try await TokenManager.shared.requestGeminiAccessToken( + refreshToken: "test-refresh-token", + clientId: "opencode-plugin-client", + clientSecret: "opencode-plugin-secret", + session: session + ) + XCTFail("Expected typed OAuth rejection") + } catch let error as GeminiOAuthRefreshError { + XCTAssertTrue(error.isClientMismatch) + XCTAssertTrue( + GeminiOAuthRetryPolicy.shouldRetryWithGeminiCLIClient( + source: .opencodeAuth, + error: error + ) + ) + } catch { + XCTFail("Unexpected error: \(error)") + } + } + func testOAuthRetryPolicyAllowsOnlyOpenCodeClientMismatch() { let mismatch = GeminiOAuthRefreshError.rejected( statusCode: 401, diff --git a/CopilotMonitor/CopilotMonitorTests/TokenManagerTests.swift b/CopilotMonitor/CopilotMonitorTests/TokenManagerTests.swift index f5c17c44..72ea9ea9 100644 --- a/CopilotMonitor/CopilotMonitorTests/TokenManagerTests.swift +++ b/CopilotMonitor/CopilotMonitorTests/TokenManagerTests.swift @@ -20,6 +20,28 @@ final class TokenManagerTests: XCTestCase { XCTAssertEqual(GeminiProjectPolicy.resolve(primary: nil), "default") } + func testGeminiStandaloneProjectPolicyUsesDefaultWhenMetadataIsMissing() { + XCTAssertEqual( + GeminiProjectPolicy.resolveStandalone( + projectId: nil, + quotaProjectId: nil, + environmentProjectId: nil + ), + "default" + ) + } + + func testGeminiStandaloneProjectPolicyPrefersEnvironmentBeforeDefault() { + XCTAssertEqual( + GeminiProjectPolicy.resolveStandalone( + projectId: nil, + quotaProjectId: nil, + environmentProjectId: "environment-project" + ), + "environment-project" + ) + } + private func makeTestJWT(payload: String) -> String { func encode(_ string: String) -> String { Data(string.utf8) diff --git a/docs/AI_USAGE_API_REFERENCE.md b/docs/AI_USAGE_API_REFERENCE.md index f8a38d47..f77ac2f2 100644 --- a/docs/AI_USAGE_API_REFERENCE.md +++ b/docs/AI_USAGE_API_REFERENCE.md @@ -20,11 +20,12 @@ **Endpoint:** `GET https://api.anthropic.com/api/oauth/usage` -Latest Claude Code-compatible usage requests use Bearer OAuth with `anthropic-beta: oauth-2025-04-20`, a Claude Code `User-Agent` (`claude-code/`), and no browser cookies. +Latest Claude Code-compatible usage requests use Bearer OAuth with `anthropic-beta: oauth-2025-04-20`, a Claude Code `User-Agent` (`claude-code/`), and no browser cookies. OpenCode Bar locates the Claude executable through `CLAUDE_CODE_PATH`, `PATH`, or common installation paths and executes `claude --version`. Only exact `X.Y.Z` or `X.Y.Z (Claude Code)` output is accepted. `ANTHROPIC_CLI_VERSION` can provide a validated `X.Y.Z` override; otherwise invalid or unavailable version output falls back to `2.1.80`. ```bash ACCESS=$(jq -r '.anthropic.access' ~/.local/share/opencode/auth.json) -CLAUDE_CODE_VERSION="${ANTHROPIC_CLI_VERSION:-2.1.80}" +source ./scripts/claude-version.sh +CLAUDE_CODE_VERSION="$(resolve_claude_code_version)" curl -s "https://api.anthropic.com/api/oauth/usage" \ -H "Authorization: Bearer $ACCESS" \ @@ -34,7 +35,7 @@ curl -s "https://api.anthropic.com/api/oauth/usage" \ -H "anthropic-beta: oauth-2025-04-20" ``` -The bundled [`scripts/query-claude.sh`](/Users/kargnas/projects/opencode-bar/scripts/query-claude.sh) now resolves Claude auth in this order: +The bundled [`scripts/query-claude.sh`](../scripts/query-claude.sh) uses the same version policy and resolves Claude auth in this order: 1. `opencode-anthropic-auth/accounts.json` 2. OpenCode `auth.json` diff --git a/scripts/tests/query-claude-version-test.sh b/scripts/tests/query-claude-version-test.sh index 908a7a65..57f60e51 100755 --- a/scripts/tests/query-claude-version-test.sh +++ b/scripts/tests/query-claude-version-test.sh @@ -43,6 +43,9 @@ assert_version "$CLAUDE_DEFAULT_CODE_VERSION" "multiline output fallback" write_fake_claude 'printf "%s\n" "2.1.199-beta"' assert_version "$CLAUDE_DEFAULT_CODE_VERSION" "prerelease output fallback" +write_fake_claude 'printf "%s\n" "٢.١.١٩٩"' +assert_version "$CLAUDE_DEFAULT_CODE_VERSION" "Unicode digit output fallback" + write_fake_claude 'exit 1' assert_version "$CLAUDE_DEFAULT_CODE_VERSION" "command failure fallback" From 16989e8293fb574f87737eb41a47c1b81d098600 Mon Sep 17 00:00:00 2001 From: Ruben <17501732+Daltonganger@users.noreply.github.com> Date: Tue, 21 Jul 2026 20:10:21 +0200 Subject: [PATCH 4/4] fix: complete provider auth review findings --- .../Providers/ClaudeProvider.swift | 74 +++++++++++++-- .../Services/TokenManager.swift | 1 + .../ClaudeProviderTests.swift | 91 +++++++++++++++++++ .../GeminiCLIProviderTests.swift | 24 +++++ scripts/claude-version.sh | 30 +++++- scripts/tests/query-claude-version-test.sh | 9 ++ 6 files changed, 219 insertions(+), 10 deletions(-) diff --git a/CopilotMonitor/CopilotMonitor/Providers/ClaudeProvider.swift b/CopilotMonitor/CopilotMonitor/Providers/ClaudeProvider.swift index 535db4d7..ba3c7d32 100644 --- a/CopilotMonitor/CopilotMonitor/Providers/ClaudeProvider.swift +++ b/CopilotMonitor/CopilotMonitor/Providers/ClaudeProvider.swift @@ -7,6 +7,40 @@ private let logger = Logger(subsystem: "com.opencodeproviders", category: "Claud enum ClaudeOAuthRequestPolicy { static let betaHeader = "oauth-2025-04-20" static let defaultClaudeCodeVersion = "2.1.80" + private static let installedVersionCache = InstalledVersionCache() + + private actor InstalledVersionCache { + private enum Value { + case resolved(String?) + } + + private var cachedValue: Value? + private var discoveryTask: Task? + + func value(discover: @escaping @Sendable () async -> String?) async -> String? { + if case let .resolved(version)? = cachedValue { + logger.debug("Using cached Claude CLI version discovery result") + return version + } + + if let discoveryTask { + return await discoveryTask.value + } + + let discoveryTask = Task { await discover() } + self.discoveryTask = discoveryTask + let version = await discoveryTask.value + cachedValue = .resolved(version) + self.discoveryTask = nil + logger.debug("Cached Claude CLI version discovery result: found=\(version != nil)") + return version + } + + func reset() { + cachedValue = nil + discoveryTask = nil + } + } static func codeVersion( environment: [String: String] = ProcessInfo.processInfo.environment, @@ -39,11 +73,19 @@ enum ClaudeOAuthRequestPolicy { environment: [String: String] = ProcessInfo.processInfo.environment, fileManager: FileManager = .default ) async -> String? { - guard let executableURL = findClaudeExecutable(environment: environment, fileManager: fileManager) else { - return nil + await installedVersionCache.value { + guard let executableURL = findClaudeExecutable(environment: environment, fileManager: fileManager) else { + logger.debug("Claude CLI version discovery found no executable") + return nil + } + + logger.debug("Claude CLI version discovery running discovered executable") + return await runVersionCommand(executableURL: executableURL) } + } - return await runVersionCommand(executableURL: executableURL) + static func resetInstalledClaudeCodeVersionCacheForTesting() async { + await installedVersionCache.reset() } private static func findClaudeExecutable( @@ -94,8 +136,13 @@ enum ClaudeOAuthRequestPolicy { return version } + private struct VersionCommandResult { + let output: String + let terminationStatus: Int32 + } + private static func runVersionCommand(executableURL: URL) async -> String? { - await withTaskGroup(of: String?.self) { group in + await withTaskGroup(of: VersionCommandResult?.self) { group in let process = Process() let pipe = Pipe() process.executableURL = executableURL @@ -107,7 +154,10 @@ enum ClaudeOAuthRequestPolicy { await withCheckedContinuation { continuation in process.terminationHandler = { _ in let data = pipe.fileHandleForReading.readDataToEndOfFile() - continuation.resume(returning: String(data: data, encoding: .utf8)) + continuation.resume(returning: VersionCommandResult( + output: String(data: data, encoding: .utf8) ?? "", + terminationStatus: process.terminationStatus + )) } do { @@ -123,17 +173,23 @@ enum ClaudeOAuthRequestPolicy { return nil } - let output: String? + let result: VersionCommandResult? if let firstResult = await group.next() { - output = firstResult + result = firstResult } else { - output = nil + result = nil } group.cancelAll() if process.isRunning { process.terminate() } - return output.flatMap(versionFromCommandOutput) + + guard let result else { return nil } + guard result.terminationStatus == 0 else { + logger.debug("Claude CLI version command failed with status \(result.terminationStatus)") + return nil + } + return versionFromCommandOutput(result.output) } } diff --git a/CopilotMonitor/CopilotMonitor/Services/TokenManager.swift b/CopilotMonitor/CopilotMonitor/Services/TokenManager.swift index 28ad3ecf..68b6027f 100644 --- a/CopilotMonitor/CopilotMonitor/Services/TokenManager.swift +++ b/CopilotMonitor/CopilotMonitor/Services/TokenManager.swift @@ -4583,6 +4583,7 @@ final class TokenManager: @unchecked Sendable { var request = URLRequest(url: url) request.httpMethod = "POST" + request.timeoutInterval = 10 request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") request.httpBody = bodyString.data(using: .utf8) diff --git a/CopilotMonitor/CopilotMonitorTests/ClaudeProviderTests.swift b/CopilotMonitor/CopilotMonitorTests/ClaudeProviderTests.swift index 50bc49e5..31009932 100644 --- a/CopilotMonitor/CopilotMonitorTests/ClaudeProviderTests.swift +++ b/CopilotMonitor/CopilotMonitorTests/ClaudeProviderTests.swift @@ -177,6 +177,97 @@ final class ClaudeProviderTests: XCTestCase { func testClaudeOAuthRequestPolicyRejectsUnicodeDigits() { XCTAssertNil(ClaudeOAuthRequestPolicy.versionFromCommandOutput("٢.١.١٩٩")) } + + func testClaudeOAuthRequestPolicyRejectsVersionOutputFromFailedCommand() async throws { + let executableURL = try makeClaudeExecutable( + script: "#!/bin/sh\nprintf '%s\\n' '2.1.199 (Claude Code)'\nexit 1\n" + ) + defer { try? FileManager.default.removeItem(at: executableURL.deletingLastPathComponent()) } + + await ClaudeOAuthRequestPolicy.resetInstalledClaudeCodeVersionCacheForTesting() + let version = await ClaudeOAuthRequestPolicy.installedClaudeCodeVersion( + environment: ["CLAUDE_CODE_PATH": executableURL.path] + ) + await ClaudeOAuthRequestPolicy.resetInstalledClaudeCodeVersionCacheForTesting() + + XCTAssertNil(version) + } + + func testClaudeOAuthRequestPolicyCachesUnavailableDiscoveryResult() async throws { + let temporaryDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let executableURL = temporaryDirectory.appendingPathComponent("claude") + let fileManager = ClaudeExecutableFileManager() + defer { try? FileManager.default.removeItem(at: temporaryDirectory) } + + await ClaudeOAuthRequestPolicy.resetInstalledClaudeCodeVersionCacheForTesting() + let firstVersion = await ClaudeOAuthRequestPolicy.installedClaudeCodeVersion( + environment: ["CLAUDE_CODE_PATH": executableURL.path], + fileManager: fileManager + ) + + try FileManager.default.createDirectory(at: temporaryDirectory, withIntermediateDirectories: true) + try "#!/bin/sh\nprintf '%s\\n' '2.1.199 (Claude Code)'\n" + .write(to: executableURL, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: executableURL.path) + fileManager.executablePaths = [executableURL.path] + + let secondVersion = await ClaudeOAuthRequestPolicy.installedClaudeCodeVersion( + environment: ["CLAUDE_CODE_PATH": executableURL.path], + fileManager: fileManager + ) + await ClaudeOAuthRequestPolicy.resetInstalledClaudeCodeVersionCacheForTesting() + + XCTAssertNil(firstVersion) + XCTAssertNil(secondVersion) + } + + func testClaudeOAuthRequestPolicyCachesDiscoveredVersion() async throws { + let executableURL = try makeClaudeExecutable( + script: "#!/bin/sh\nprintf '%s\\n' '2.1.199 (Claude Code)'\n" + ) + let fileManager = ClaudeExecutableFileManager(executablePaths: [executableURL.path]) + defer { try? FileManager.default.removeItem(at: executableURL.deletingLastPathComponent()) } + + await ClaudeOAuthRequestPolicy.resetInstalledClaudeCodeVersionCacheForTesting() + let firstVersion = await ClaudeOAuthRequestPolicy.installedClaudeCodeVersion( + environment: ["CLAUDE_CODE_PATH": executableURL.path], + fileManager: fileManager + ) + fileManager.executablePaths = [] + let secondVersion = await ClaudeOAuthRequestPolicy.installedClaudeCodeVersion( + environment: ["CLAUDE_CODE_PATH": executableURL.path], + fileManager: fileManager + ) + await ClaudeOAuthRequestPolicy.resetInstalledClaudeCodeVersionCacheForTesting() + + XCTAssertEqual(firstVersion, "2.1.199") + XCTAssertEqual(secondVersion, "2.1.199") + } + + private final class ClaudeExecutableFileManager: FileManager, @unchecked Sendable { + var executablePaths: Set + + init(executablePaths: Set = []) { + self.executablePaths = executablePaths + super.init() + } + + override func isExecutableFile(atPath path: String) -> Bool { + executablePaths.contains(path) + } + } + + private func makeClaudeExecutable(script: String) throws -> URL { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let executableURL = directory.appendingPathComponent("claude") + + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try script.write(to: executableURL, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: executableURL.path) + return executableURL + } private func loadFixture(named: String) -> Data { let bundle = Bundle(for: type(of: self)) diff --git a/CopilotMonitor/CopilotMonitorTests/GeminiCLIProviderTests.swift b/CopilotMonitor/CopilotMonitorTests/GeminiCLIProviderTests.swift index 8d22d188..5c7fbe9e 100644 --- a/CopilotMonitor/CopilotMonitorTests/GeminiCLIProviderTests.swift +++ b/CopilotMonitor/CopilotMonitorTests/GeminiCLIProviderTests.swift @@ -173,6 +173,30 @@ final class GeminiCLIProviderTests: XCTestCase { XCTAssertEqual(response.buckets.last?.modelId, "gemini-3-pro-preview") } + func testOAuthRefreshUsesTenSecondRequestTimeout() async throws { + let session = makeSession() + defer { session.invalidateAndCancel() } + + MockURLProtocol.requestHandler = { request in + XCTAssertEqual(request.httpMethod, "POST") + XCTAssertEqual(request.timeoutInterval, 10, accuracy: 0.001) + let url = try XCTUnwrap(request.url) + let response = try XCTUnwrap( + HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil) + ) + return (response, Data(#"{"access_token":"test-access-token","expires_in":3600}"#.utf8)) + } + + let accessToken = try await TokenManager.shared.requestGeminiAccessToken( + refreshToken: "test-refresh-token", + clientId: "account-client", + clientSecret: "account-secret", + session: session + ) + + XCTAssertEqual(accessToken, "test-access-token") + } + func testOAuthRefreshPreservesInvalidGrantWithoutRetry() async throws { let session = makeSession() defer { session.invalidateAndCancel() } diff --git a/scripts/claude-version.sh b/scripts/claude-version.sh index 4d71bfd3..a12c8aff 100644 --- a/scripts/claude-version.sh +++ b/scripts/claude-version.sh @@ -22,7 +22,35 @@ normalize_claude_code_version() { detect_claude_code_version() { local claude_bin="" local output="" - claude_bin="$(command -v claude 2>/dev/null || true)" + + if [[ -n "${CLAUDE_CODE_PATH:-}" && -x "$CLAUDE_CODE_PATH" ]]; then + claude_bin="$CLAUDE_CODE_PATH" + else + local path_entry="" + local candidate="" + local -a path_entries=() + IFS=':' read -r -a path_entries <<< "${PATH:-}" + for path_entry in "${path_entries[@]}"; do + candidate="$path_entry/claude" + if [[ -x "$candidate" ]]; then + claude_bin="$candidate" + break + fi + done + + if [[ -z "$claude_bin" ]]; then + for candidate in \ + "$HOME/.local/bin/claude" \ + "/opt/homebrew/bin/claude" \ + "/usr/local/bin/claude"; do + if [[ -x "$candidate" ]]; then + claude_bin="$candidate" + break + fi + done + fi + fi + [[ -n "$claude_bin" ]] || return 1 output="$("$claude_bin" --version 2>/dev/null)" || return 1 diff --git a/scripts/tests/query-claude-version-test.sh b/scripts/tests/query-claude-version-test.sh index 57f60e51..f0b33183 100755 --- a/scripts/tests/query-claude-version-test.sh +++ b/scripts/tests/query-claude-version-test.sh @@ -34,6 +34,15 @@ unset ANTHROPIC_CLI_VERSION write_fake_claude 'printf "%s\n" "2.1.199 (Claude Code)"' assert_version "2.1.199" "official Claude Code output" +CONFIGURED_BIN="$TEMP_DIR/configured-claude" +printf '%s\n%s\n' '#!/bin/bash' 'printf "%s\n" "3.0.0 (Claude Code)"' > "$CONFIGURED_BIN" +chmod +x "$CONFIGURED_BIN" +configured_version="$(CLAUDE_CODE_PATH="$CONFIGURED_BIN" PATH="$FAKE_BIN:/usr/bin:/bin" resolve_claude_code_version)" +if [[ "$configured_version" != "3.0.0" ]]; then + echo "FAIL: CLAUDE_CODE_PATH must take precedence over PATH (got $configured_version)" >&2 + exit 1 +fi + write_fake_claude 'printf "%s\n" "Claude Code 2.1.199"' assert_version "$CLAUDE_DEFAULT_CODE_VERSION" "prefixed output fallback"