fix(macos): clarify OAuth detection
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
78
apps/macos/Tests/ClawdisIPCTests/PiOAuthStoreTests.swift
Normal file
78
apps/macos/Tests/ClawdisIPCTests/PiOAuthStoreTests.swift
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user