fix(macos): clarify OAuth detection

This commit is contained in:
Peter Steinberger
2025-12-14 19:10:48 +00:00
parent 5792887883
commit f6cafd1a15
3 changed files with 151 additions and 13 deletions

View File

@@ -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 {

View File

@@ -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<Void, Never>?
@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 {

View File

@@ -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
}
}