fix(macos): clarify OAuth detection
parent
5792887883
commit
f6cafd1a15
|
|
@ -108,6 +108,31 @@ enum PiOAuthStore {
|
||||||
static let oauthFilename = "oauth.json"
|
static let oauthFilename = "oauth.json"
|
||||||
private static let providerKey = "anthropic"
|
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 {
|
static func oauthDir() -> URL {
|
||||||
FileManager.default.homeDirectoryForCurrentUser
|
FileManager.default.homeDirectoryForCurrentUser
|
||||||
.appendingPathComponent(".pi", isDirectory: true)
|
.appendingPathComponent(".pi", isDirectory: true)
|
||||||
|
|
@ -118,21 +143,46 @@ enum PiOAuthStore {
|
||||||
self.oauthDir().appendingPathComponent(self.oauthFilename)
|
self.oauthDir().appendingPathComponent(self.oauthFilename)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func hasAnthropicOAuth() -> Bool {
|
static func anthropicOAuthStatus() -> AnthropicOAuthStatus {
|
||||||
let url = self.oauthURL()
|
self.anthropicOAuthStatus(at: self.oauthURL())
|
||||||
guard FileManager.default.fileExists(atPath: url.path) else { return false }
|
}
|
||||||
|
|
||||||
guard let data = try? Data(contentsOf: url),
|
static func hasAnthropicOAuth() -> Bool {
|
||||||
let json = try? JSONSerialization.jsonObject(with: data, options: []),
|
self.anthropicOAuthStatus().isConnected
|
||||||
let storage = json as? [String: Any],
|
}
|
||||||
let entry = storage[self.providerKey] as? [String: Any]
|
|
||||||
else {
|
static func anthropicOAuthStatus(at url: URL) -> AnthropicOAuthStatus {
|
||||||
return false
|
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
|
return .connected(expiresAtMs: expiresAtMs)
|
||||||
let access = entry["access"] as? String
|
}
|
||||||
return (refresh?.isEmpty == false) && (access?.isEmpty == false)
|
|
||||||
|
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 {
|
static func saveAnthropicOAuth(_ creds: AnthropicOAuthCredentials) throws {
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@ struct OnboardingView: View {
|
||||||
@State private var anthropicAuthStatus: String?
|
@State private var anthropicAuthStatus: String?
|
||||||
@State private var anthropicAuthBusy = false
|
@State private var anthropicAuthBusy = false
|
||||||
@State private var anthropicAuthConnected = false
|
@State private var anthropicAuthConnected = false
|
||||||
|
@State private var anthropicAuthDetectedStatus: PiOAuthStore.AnthropicOAuthStatus = .missingFile
|
||||||
@State private var monitoringAuth = false
|
@State private var monitoringAuth = false
|
||||||
@State private var authMonitorTask: Task<Void, Never>?
|
@State private var authMonitorTask: Task<Void, Never>?
|
||||||
@State private var identityName: String = ""
|
@State private var identityName: String = ""
|
||||||
|
|
@ -323,6 +324,13 @@ struct OnboardingView: View {
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !self.anthropicAuthConnected {
|
||||||
|
Text(self.anthropicAuthDetectedStatus.shortDescription)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
"This writes Pi-compatible credentials to `~/.pi/agent/oauth.json` (owner-only). " +
|
"This writes Pi-compatible credentials to `~/.pi/agent/oauth.json` (owner-only). " +
|
||||||
"You can redo this anytime.")
|
"You can redo this anytime.")
|
||||||
|
|
@ -451,7 +459,9 @@ struct OnboardingView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func refreshAnthropicOAuthStatus() {
|
private func refreshAnthropicOAuthStatus() {
|
||||||
self.anthropicAuthConnected = PiOAuthStore.hasAnthropicOAuth()
|
let status = PiOAuthStore.anthropicOAuthStatus()
|
||||||
|
self.anthropicAuthDetectedStatus = status
|
||||||
|
self.anthropicAuthConnected = status.isConnected
|
||||||
}
|
}
|
||||||
|
|
||||||
private func identityPage() -> some View {
|
private func identityPage() -> some View {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue