From f6cafd1a152c53c655d0ed2a9f3a6d3d475c67d2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 14 Dec 2025 19:10:48 +0000 Subject: [PATCH] fix(macos): clarify OAuth detection --- .../Sources/Clawdis/AnthropicOAuth.swift | 74 +++++++++++++++--- apps/macos/Sources/Clawdis/Onboarding.swift | 12 ++- .../ClawdisIPCTests/PiOAuthStoreTests.swift | 78 +++++++++++++++++++ 3 files changed, 151 insertions(+), 13 deletions(-) create mode 100644 apps/macos/Tests/ClawdisIPCTests/PiOAuthStoreTests.swift diff --git a/apps/macos/Sources/Clawdis/AnthropicOAuth.swift b/apps/macos/Sources/Clawdis/AnthropicOAuth.swift index 745f5f793..eae266b53 100644 --- a/apps/macos/Sources/Clawdis/AnthropicOAuth.swift +++ b/apps/macos/Sources/Clawdis/AnthropicOAuth.swift @@ -108,6 +108,31 @@ enum PiOAuthStore { static let oauthFilename = "oauth.json" private static let providerKey = "anthropic" + enum AnthropicOAuthStatus: Equatable { + case missingFile + case unreadableFile + case invalidJSON + case missingProviderEntry + case missingTokens + case connected(expiresAtMs: Int64?) + + var isConnected: Bool { + if case .connected = self { return true } + return false + } + + var shortDescription: String { + switch self { + case .missingFile: "oauth.json not found" + case .unreadableFile: "oauth.json not readable" + case .invalidJSON: "oauth.json invalid" + case .missingProviderEntry: "oauth.json has no anthropic entry" + case .missingTokens: "anthropic entry missing tokens" + case .connected: "OAuth credentials found" + } + } + } + static func oauthDir() -> URL { FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent(".pi", isDirectory: true) @@ -118,21 +143,46 @@ enum PiOAuthStore { self.oauthDir().appendingPathComponent(self.oauthFilename) } - static func hasAnthropicOAuth() -> Bool { - let url = self.oauthURL() - guard FileManager.default.fileExists(atPath: url.path) else { return false } + static func anthropicOAuthStatus() -> AnthropicOAuthStatus { + self.anthropicOAuthStatus(at: self.oauthURL()) + } - guard let data = try? Data(contentsOf: url), - let json = try? JSONSerialization.jsonObject(with: data, options: []), - let storage = json as? [String: Any], - let entry = storage[self.providerKey] as? [String: Any] - else { - return false + static func hasAnthropicOAuth() -> Bool { + self.anthropicOAuthStatus().isConnected + } + + static func anthropicOAuthStatus(at url: URL) -> AnthropicOAuthStatus { + guard FileManager.default.fileExists(atPath: url.path) else { return .missingFile } + + guard let data = try? Data(contentsOf: url) else { return .unreadableFile } + guard let json = try? JSONSerialization.jsonObject(with: data, options: []) else { return .invalidJSON } + guard let storage = json as? [String: Any] else { return .invalidJSON } + guard let rawEntry = storage[self.providerKey] else { return .missingProviderEntry } + guard let entry = rawEntry as? [String: Any] else { return .invalidJSON } + + let refresh = self.firstString(in: entry, keys: ["refresh", "refresh_token", "refreshToken"]) + let access = self.firstString(in: entry, keys: ["access", "access_token", "accessToken"]) + guard refresh?.isEmpty == false, access?.isEmpty == false else { return .missingTokens } + + let expiresAny = entry["expires"] ?? entry["expires_at"] ?? entry["expiresAt"] + let expiresAtMs: Int64? = if let ms = expiresAny as? Int64 { + ms + } else if let number = expiresAny as? NSNumber { + number.int64Value + } else if let ms = expiresAny as? Double { + Int64(ms) + } else { + nil } - let refresh = entry["refresh"] as? String - let access = entry["access"] as? String - return (refresh?.isEmpty == false) && (access?.isEmpty == false) + return .connected(expiresAtMs: expiresAtMs) + } + + private static func firstString(in dict: [String: Any], keys: [String]) -> String? { + for key in keys { + if let value = dict[key] as? String { return value } + } + return nil } static func saveAnthropicOAuth(_ creds: AnthropicOAuthCredentials) throws { diff --git a/apps/macos/Sources/Clawdis/Onboarding.swift b/apps/macos/Sources/Clawdis/Onboarding.swift index cad8ee130..478d71cdb 100644 --- a/apps/macos/Sources/Clawdis/Onboarding.swift +++ b/apps/macos/Sources/Clawdis/Onboarding.swift @@ -58,6 +58,7 @@ struct OnboardingView: View { @State private var anthropicAuthStatus: String? @State private var anthropicAuthBusy = false @State private var anthropicAuthConnected = false + @State private var anthropicAuthDetectedStatus: PiOAuthStore.AnthropicOAuthStatus = .missingFile @State private var monitoringAuth = false @State private var authMonitorTask: Task? @State private var identityName: String = "" @@ -323,6 +324,13 @@ struct OnboardingView: View { Spacer() } + if !self.anthropicAuthConnected { + Text(self.anthropicAuthDetectedStatus.shortDescription) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + Text( "This writes Pi-compatible credentials to `~/.pi/agent/oauth.json` (owner-only). " + "You can redo this anytime.") @@ -451,7 +459,9 @@ struct OnboardingView: View { } private func refreshAnthropicOAuthStatus() { - self.anthropicAuthConnected = PiOAuthStore.hasAnthropicOAuth() + let status = PiOAuthStore.anthropicOAuthStatus() + self.anthropicAuthDetectedStatus = status + self.anthropicAuthConnected = status.isConnected } private func identityPage() -> some View { diff --git a/apps/macos/Tests/ClawdisIPCTests/PiOAuthStoreTests.swift b/apps/macos/Tests/ClawdisIPCTests/PiOAuthStoreTests.swift new file mode 100644 index 000000000..5ccae416d --- /dev/null +++ b/apps/macos/Tests/ClawdisIPCTests/PiOAuthStoreTests.swift @@ -0,0 +1,78 @@ +import Foundation +import Testing +@testable import Clawdis + +@Suite +struct PiOAuthStoreTests { + @Test + func returnsMissingWhenFileAbsent() { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("clawdis-oauth-\(UUID().uuidString)") + .appendingPathComponent("oauth.json") + #expect(PiOAuthStore.anthropicOAuthStatus(at: url) == .missingFile) + } + + @Test + func acceptsPiFormatTokens() throws { + let url = try self.writeOAuthFile([ + "anthropic": [ + "type": "oauth", + "refresh": "r1", + "access": "a1", + "expires": 1_234_567_890, + ], + ]) + + #expect(PiOAuthStore.anthropicOAuthStatus(at: url).isConnected) + } + + @Test + func acceptsTokenKeyVariants() throws { + let url = try self.writeOAuthFile([ + "anthropic": [ + "type": "oauth", + "refresh_token": "r1", + "access_token": "a1", + ], + ]) + + #expect(PiOAuthStore.anthropicOAuthStatus(at: url).isConnected) + } + + @Test + func reportsMissingProviderEntry() throws { + let url = try self.writeOAuthFile([ + "other": [ + "type": "oauth", + "refresh": "r1", + "access": "a1", + ], + ]) + + #expect(PiOAuthStore.anthropicOAuthStatus(at: url) == .missingProviderEntry) + } + + @Test + func reportsMissingTokens() throws { + let url = try self.writeOAuthFile([ + "anthropic": [ + "type": "oauth", + "refresh": "", + "access": "a1", + ], + ]) + + #expect(PiOAuthStore.anthropicOAuthStatus(at: url) == .missingTokens) + } + + private func writeOAuthFile(_ json: [String: Any]) throws -> URL { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("clawdis-oauth-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + + let url = dir.appendingPathComponent("oauth.json") + let data = try JSONSerialization.data(withJSONObject: json, options: [.prettyPrinted, .sortedKeys]) + try data.write(to: url, options: [.atomic]) + return url + } +}