From c984434e71480c1f3feca41612acc3c7efbc9940 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aaron=2Ehj=28=E1=84=8B=E1=85=A1=E1=84=85=E1=85=A9=E1=86=AB?= =?UTF-8?q?=29?= Date: Mon, 9 Mar 2026 15:08:14 +0900 Subject: [PATCH] Support Claude file-based credentials in usage provider --- AgentBar/Services/ClaudeUsageProvider.swift | 20 ++++++- AgentBarTests/ClaudeUsageProviderTests.swift | 60 ++++++++++++++++++++ 2 files changed, 79 insertions(+), 1 deletion(-) diff --git a/AgentBar/Services/ClaudeUsageProvider.swift b/AgentBar/Services/ClaudeUsageProvider.swift index 0e7f94b..1bf854a 100644 --- a/AgentBar/Services/ClaudeUsageProvider.swift +++ b/AgentBar/Services/ClaudeUsageProvider.swift @@ -73,6 +73,7 @@ final class ClaudeUsageProvider: UsageProviderProtocol, @unchecked Sendable { static let usageURL = URL(string: "https://api.anthropic.com/api/oauth/usage")! + private static let credentialsFileRelativePath = ".claude/.credentials.json" private static let keychainService = "Claude Code-credentials" private static let tokenCacheLock = NSLock() private static let defaultCacheTTL: TimeInterval = 60 @@ -80,6 +81,13 @@ final class ClaudeUsageProvider: UsageProviderProtocol, @unchecked Sendable { nonisolated(unsafe) private static var cachedToken: String? nonisolated(unsafe) private static var tokenLastLookupAt: Date? typealias SecurityCLIProcessRuntime = CLIProcessRuntime + nonisolated(unsafe) static var credentialsFileURLProvider: @Sendable () -> URL = { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(credentialsFileRelativePath) + } + nonisolated(unsafe) static var credentialsFileReader: @Sendable (_ url: URL) -> String? = { url in + try? String(contentsOf: url, encoding: .utf8) + } nonisolated(unsafe) static var securityCLIRunner: @Sendable (_ timeout: TimeInterval) -> String? = { timeout in runSecurityCLICommand(timeout: timeout) } @@ -90,7 +98,7 @@ final class ClaudeUsageProvider: UsageProviderProtocol, @unchecked Sendable { defaults: UserDefaults = .standard ) { self.session = session - self.credentialProvider = credentialProvider ?? { Self.readKeychainTokenViaCLI() } + self.credentialProvider = credentialProvider ?? { Self.readAccessToken() } self.defaults = defaults } @@ -260,6 +268,16 @@ final class ClaudeUsageProvider: UsageProviderProtocol, @unchecked Sendable { // MARK: - Keychain Access via security CLI + static func readAccessToken() -> String? { + readTokenFromCredentialsFile() ?? readKeychainTokenViaCLI() + } + + static func readTokenFromCredentialsFile() -> String? { + let url = credentialsFileURLProvider() + guard let rawJSON = credentialsFileReader(url) else { return nil } + return parseAccessToken(from: rawJSON) + } + /// Reads the Claude Code OAuth token using the `security` CLI to avoid /// per-app Keychain ACL prompts. The result is cached with a TTL matching /// the app's refresh interval. diff --git a/AgentBarTests/ClaudeUsageProviderTests.swift b/AgentBarTests/ClaudeUsageProviderTests.swift index 0c25eac..d19ee3d 100644 --- a/AgentBarTests/ClaudeUsageProviderTests.swift +++ b/AgentBarTests/ClaudeUsageProviderTests.swift @@ -2,18 +2,24 @@ import XCTest @testable import AgentBar final class ClaudeUsageProviderTests: XCTestCase { + private var originalCredentialsFileURLProvider: (@Sendable () -> URL)! + private var originalCredentialsFileReader: (@Sendable (URL) -> String?)! private var originalSecurityCLIRunner: (@Sendable (TimeInterval) -> String?)! override func setUp() { super.setUp() MockURLProtocol.reset() ClaudeUsageProvider.resetTokenCache() + originalCredentialsFileURLProvider = ClaudeUsageProvider.credentialsFileURLProvider + originalCredentialsFileReader = ClaudeUsageProvider.credentialsFileReader originalSecurityCLIRunner = ClaudeUsageProvider.securityCLIRunner } override func tearDown() { MockURLProtocol.reset() ClaudeUsageProvider.resetTokenCache() + ClaudeUsageProvider.credentialsFileURLProvider = originalCredentialsFileURLProvider + ClaudeUsageProvider.credentialsFileReader = originalCredentialsFileReader ClaudeUsageProvider.securityCLIRunner = originalSecurityCLIRunner super.tearDown() } @@ -421,6 +427,60 @@ final class ClaudeUsageProviderTests: XCTestCase { XCTAssertEqual(counter.value, 1) } + func testReadAccessTokenPrefersCredentialsFile() { + ClaudeUsageProvider.credentialsFileURLProvider = { + URL(fileURLWithPath: "/tmp/test-claude-credentials.json") + } + ClaudeUsageProvider.credentialsFileReader = { _ in + """ + {"claudeAiOauth":{"accessToken":"file-token"}} + """ + } + ClaudeUsageProvider.securityCLIRunner = { _ in + XCTFail("Should not read keychain when file credentials are present") + return nil + } + + XCTAssertEqual(ClaudeUsageProvider.readAccessToken(), "file-token") + } + + func testReadAccessTokenFallsBackToKeychainWhenFileMissing() { + ClaudeUsageProvider.credentialsFileReader = { _ in nil } + ClaudeUsageProvider.securityCLIRunner = { _ in + """ + {"claudeAiOauth":{"accessToken":"keychain-token"}} + """ + } + + XCTAssertEqual(ClaudeUsageProvider.readAccessToken(), "keychain-token") + } + + func testReadAccessTokenFallsBackToKeychainWhenFileIsMalformed() { + ClaudeUsageProvider.credentialsFileReader = { _ in "not json" } + ClaudeUsageProvider.securityCLIRunner = { _ in + """ + {"claudeAiOauth":{"accessToken":"keychain-token"}} + """ + } + + XCTAssertEqual(ClaudeUsageProvider.readAccessToken(), "keychain-token") + } + + func testDefaultCredentialProviderUsesFileCredentials() async { + let defaults = makeDefaultsSuite() + ClaudeUsageProvider.credentialsFileReader = { _ in + """ + {"claudeAiOauth":{"accessToken":"file-token"}} + """ + } + ClaudeUsageProvider.securityCLIRunner = { _ in nil } + + let provider = ClaudeUsageProvider(defaults: defaults) + + let isConfigured = await provider.isConfigured() + XCTAssertTrue(isConfigured) + } + func testExecuteSecurityCLICommandTimeoutForceKillsIfStillRunning() { var terminateCallCount = 0 var forceTerminateCallCount = 0