refactor: move OAuth storage and drop legacy sessions

This commit is contained in:
Peter Steinberger
2025-12-22 21:02:48 +00:00
parent 9717f2d374
commit 4ca6591045
13 changed files with 265 additions and 123 deletions

View File

@@ -6,7 +6,7 @@ import SwiftUI
struct AnthropicAuthControls: View {
let connectionMode: AppState.ConnectionMode
@State private var oauthStatus: PiOAuthStore.AnthropicOAuthStatus = PiOAuthStore.anthropicOAuthStatus()
@State private var oauthStatus: ClawdisOAuthStore.AnthropicOAuthStatus = ClawdisOAuthStore.anthropicOAuthStatus()
@State private var pkce: AnthropicOAuth.PKCE?
@State private var code: String = ""
@State private var busy = false
@@ -20,7 +20,7 @@ struct AnthropicAuthControls: View {
var body: some View {
VStack(alignment: .leading, spacing: 10) {
if self.connectionMode != .local {
Text("Gateway isnt running locally; OAuth must be created on the gateway host where Pi runs.")
Text("Gateway isnt running locally; OAuth must be created on the gateway host.")
.font(.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
@@ -35,10 +35,10 @@ struct AnthropicAuthControls: View {
.foregroundStyle(.secondary)
Spacer()
Button("Reveal") {
NSWorkspace.shared.activateFileViewerSelecting([PiOAuthStore.oauthURL()])
NSWorkspace.shared.activateFileViewerSelecting([ClawdisOAuthStore.oauthURL()])
}
.buttonStyle(.bordered)
.disabled(!FileManager.default.fileExists(atPath: PiOAuthStore.oauthURL().path))
.disabled(!FileManager.default.fileExists(atPath: ClawdisOAuthStore.oauthURL().path))
Button("Refresh") {
self.refresh()
@@ -46,7 +46,7 @@ struct AnthropicAuthControls: View {
.buttonStyle(.bordered)
}
Text(PiOAuthStore.oauthURL().path)
Text(ClawdisOAuthStore.oauthURL().path)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.lineLimit(1)
@@ -123,7 +123,11 @@ struct AnthropicAuthControls: View {
}
private func refresh() {
self.oauthStatus = PiOAuthStore.anthropicOAuthStatus()
let imported = ClawdisOAuthStore.importLegacyAnthropicOAuthIfNeeded()
self.oauthStatus = ClawdisOAuthStore.anthropicOAuthStatus()
if imported != nil {
self.statusText = "Imported existing OAuth credentials."
}
}
private func startOAuth() {
@@ -161,11 +165,11 @@ struct AnthropicAuthControls: View {
code: parsed.code,
state: parsed.state,
verifier: pkce.verifier)
try PiOAuthStore.saveAnthropicOAuth(creds)
try ClawdisOAuthStore.saveAnthropicOAuth(creds)
self.refresh()
self.pkce = nil
self.code = ""
self.statusText = "Connected. Pi can now use Claude via OAuth."
self.statusText = "Connected. Clawdis can now use Claude via OAuth."
} catch {
self.statusText = "OAuth failed: \(error.localizedDescription)"
}

View File

@@ -18,7 +18,7 @@ enum AnthropicAuthMode: Equatable {
var shortLabel: String {
switch self {
case .oauthFile: "OAuth (Pi token file)"
case .oauthFile: "OAuth (Clawdis token file)"
case .oauthEnv: "OAuth (env var)"
case .apiKeyEnv: "API key (env var)"
case .missing: "Missing credentials"
@@ -36,7 +36,8 @@ enum AnthropicAuthMode: Equatable {
enum AnthropicAuthResolver {
static func resolve(
environment: [String: String] = ProcessInfo.processInfo.environment,
oauthStatus: PiOAuthStore.AnthropicOAuthStatus = PiOAuthStore.anthropicOAuthStatus()) -> AnthropicAuthMode
oauthStatus: ClawdisOAuthStore.AnthropicOAuthStatus = ClawdisOAuthStore.anthropicOAuthStatus()
) -> AnthropicAuthMode
{
if oauthStatus.isConnected { return .oauthFile }
@@ -92,7 +93,7 @@ enum AnthropicOAuth {
URLQueryItem(name: "scope", value: self.scopes),
URLQueryItem(name: "code_challenge", value: pkce.challenge),
URLQueryItem(name: "code_challenge_method", value: "S256"),
// Match Pi: state is the verifier.
// Match legacy flow: state is the verifier.
URLQueryItem(name: "state", value: pkce.verifier),
]
return components.url!
@@ -140,7 +141,7 @@ enum AnthropicOAuth {
])
}
// Match Pi: expiresAt = now + expires_in - 5 minutes.
// Match legacy flow: expiresAt = now + expires_in - 5 minutes.
let expiresAtMs = Int64(Date().timeIntervalSince1970 * 1000)
+ Int64(expiresIn * 1000)
- Int64(5 * 60 * 1000)
@@ -150,10 +151,11 @@ enum AnthropicOAuth {
}
}
enum PiOAuthStore {
enum ClawdisOAuthStore {
static let oauthFilename = "oauth.json"
private static let providerKey = "anthropic"
private static let piAgentDirEnv = "PI_CODING_AGENT_DIR"
private static let clawdisOAuthDirEnv = "CLAWDIS_OAUTH_DIR"
private static let legacyPiDirEnv = "PI_CODING_AGENT_DIR"
enum AnthropicOAuthStatus: Equatable {
case missingFile
@@ -170,18 +172,18 @@ enum PiOAuthStore {
var shortDescription: String {
switch self {
case .missingFile: "Pi OAuth token file not found"
case .unreadableFile: "Pi OAuth token file not readable"
case .invalidJSON: "Pi OAuth token file invalid"
case .missingProviderEntry: "No Anthropic entry in Pi OAuth token file"
case .missingFile: "Clawdis OAuth token file not found"
case .unreadableFile: "Clawdis OAuth token file not readable"
case .invalidJSON: "Clawdis OAuth token file invalid"
case .missingProviderEntry: "No Anthropic entry in Clawdis OAuth token file"
case .missingTokens: "Anthropic entry missing tokens"
case .connected: "Pi OAuth credentials found"
case .connected: "Clawdis OAuth credentials found"
}
}
}
static func oauthDir() -> URL {
if let override = ProcessInfo.processInfo.environment[self.piAgentDirEnv]?
if let override = ProcessInfo.processInfo.environment[self.clawdisOAuthDirEnv]?
.trimmingCharacters(in: .whitespacesAndNewlines),
!override.isEmpty
{
@@ -190,14 +192,58 @@ enum PiOAuthStore {
}
return FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".pi", isDirectory: true)
.appendingPathComponent("agent", isDirectory: true)
.appendingPathComponent(".clawdis", isDirectory: true)
.appendingPathComponent("credentials", isDirectory: true)
}
static func oauthURL() -> URL {
self.oauthDir().appendingPathComponent(self.oauthFilename)
}
static func legacyOAuthURLs() -> [URL] {
var urls: [URL] = []
let env = ProcessInfo.processInfo.environment
if let override = env[self.legacyPiDirEnv]?.trimmingCharacters(in: .whitespacesAndNewlines),
!override.isEmpty
{
let expanded = NSString(string: override).expandingTildeInPath
urls.append(URL(fileURLWithPath: expanded, isDirectory: true).appendingPathComponent(self.oauthFilename))
}
let home = FileManager.default.homeDirectoryForCurrentUser
urls.append(home.appendingPathComponent(".pi/agent/\(self.oauthFilename)"))
urls.append(home.appendingPathComponent(".claude/\(self.oauthFilename)"))
urls.append(home.appendingPathComponent(".config/claude/\(self.oauthFilename)"))
urls.append(home.appendingPathComponent(".config/anthropic/\(self.oauthFilename)"))
var seen = Set<String>()
return urls.filter { url in
let path = url.standardizedFileURL.path
if seen.contains(path) { return false }
seen.insert(path)
return true
}
}
static func importLegacyAnthropicOAuthIfNeeded() -> URL? {
let dest = self.oauthURL()
guard !FileManager.default.fileExists(atPath: dest.path) else { return nil }
for url in self.legacyOAuthURLs() {
guard FileManager.default.fileExists(atPath: url.path) else { continue }
guard self.anthropicOAuthStatus(at: url).isConnected else { continue }
guard let storage = self.loadStorage(at: url) else { continue }
do {
try self.saveStorage(storage)
return url
} catch {
continue
}
}
return nil
}
static func anthropicOAuthStatus() -> AnthropicOAuthStatus {
self.anthropicOAuthStatus(at: self.oauthURL())
}
@@ -240,17 +286,15 @@ enum PiOAuthStore {
return nil
}
private static func loadStorage(at url: URL) -> [String: Any]? {
guard let data = try? Data(contentsOf: url) else { return nil }
guard let json = try? JSONSerialization.jsonObject(with: data, options: []) else { return nil }
return json as? [String: Any]
}
static func saveAnthropicOAuth(_ creds: AnthropicOAuthCredentials) throws {
let url = self.oauthURL()
let existing: [String: Any] = if FileManager.default.fileExists(atPath: url.path),
let data = try? Data(contentsOf: url),
let json = try? JSONSerialization.jsonObject(with: data, options: []),
let dict = json as? [String: Any]
{
dict
} else {
[:]
}
let existing: [String: Any] = self.loadStorage(at: url) ?? [:]
var updated = existing
updated[self.providerKey] = [

View File

@@ -152,7 +152,7 @@ struct ConfigSettings: View {
}
private var anthropicAuthHelpText: String {
"Determined from Pi OAuth token file (~/.pi/agent/oauth.json) " +
"Determined from Clawdis OAuth token file (~/.clawdis/credentials/oauth.json) " +
"or environment variables (ANTHROPIC_OAUTH_TOKEN / ANTHROPIC_API_KEY)."
}

View File

@@ -62,7 +62,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 anthropicAuthDetectedStatus: ClawdisOAuthStore.AnthropicOAuthStatus = .missingFile
@State private var anthropicAuthAutoDetectClipboard = true
@State private var anthropicAuthAutoConnectClipboard = true
@State private var anthropicAuthLastPasteboardChangeCount = NSPasteboard.general.changeCount
@@ -530,7 +530,7 @@ struct OnboardingView: View {
.multilineTextAlignment(.center)
.frame(maxWidth: 540)
.fixedSize(horizontal: false, vertical: true)
Text("Pi supports any model — we strongly recommend Opus 4.5 for the best experience.")
Text("Clawdis supports any model — we strongly recommend Opus 4.5 for the best experience.")
.font(.callout)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
@@ -555,14 +555,14 @@ struct OnboardingView: View {
}
Text(
"This lets Pi use Claude immediately. Credentials are stored at " +
"`~/.pi/agent/oauth.json` (owner-only). You can redo this anytime.")
"This lets Clawdis use Claude immediately. Credentials are stored at " +
"`~/.clawdis/credentials/oauth.json` (owner-only). You can redo this anytime.")
.font(.subheadline)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
HStack(spacing: 12) {
Text(PiOAuthStore.oauthURL().path)
Text(ClawdisOAuthStore.oauthURL().path)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
@@ -571,7 +571,7 @@ struct OnboardingView: View {
Spacer()
Button("Reveal") {
NSWorkspace.shared.activateFileViewerSelecting([PiOAuthStore.oauthURL()])
NSWorkspace.shared.activateFileViewerSelecting([ClawdisOAuthStore.oauthURL()])
}
.buttonStyle(.bordered)
@@ -683,9 +683,9 @@ struct OnboardingView: View {
code: parsed.code,
state: parsed.state,
verifier: pkce.verifier)
try PiOAuthStore.saveAnthropicOAuth(creds)
try ClawdisOAuthStore.saveAnthropicOAuth(creds)
self.refreshAnthropicOAuthStatus()
self.anthropicAuthStatus = "Connected. Pi can now use Claude."
self.anthropicAuthStatus = "Connected. Clawdis can now use Claude."
} catch {
self.anthropicAuthStatus = "OAuth failed: \(error.localizedDescription)"
}
@@ -717,7 +717,8 @@ struct OnboardingView: View {
}
private func refreshAnthropicOAuthStatus() {
let status = PiOAuthStore.anthropicOAuthStatus()
_ = ClawdisOAuthStore.importLegacyAnthropicOAuthIfNeeded()
let status = ClawdisOAuthStore.anthropicOAuthStatus()
self.anthropicAuthDetectedStatus = status
self.anthropicAuthConnected = status.isConnected
}
@@ -947,8 +948,8 @@ struct OnboardingView: View {
self.featureRow(
title: "Remote gateway checklist",
subtitle: """
On your gateway host: install/update the `clawdis` package and make sure Pi has credentials
(typically `~/.pi/agent/oauth.json`). Then connect again if needed.
On your gateway host: install/update the `clawdis` package and make sure credentials exist
(typically `~/.clawdis/credentials/oauth.json`). Then connect again if needed.
""",
systemImage: "network")
Divider()

View File

@@ -83,8 +83,6 @@ enum SessionActions {
}
let home = FileManager.default.homeDirectoryForCurrentUser
urls.append(home.appendingPathComponent(".clawdis/sessions/\(sessionId).jsonl"))
urls.append(home.appendingPathComponent(".pi/agent/sessions/\(sessionId).jsonl"))
urls.append(home.appendingPathComponent(".tau/agent/sessions/clawdis/\(sessionId).jsonl"))
return urls
}()

View File

@@ -6,7 +6,7 @@ import Testing
struct AnthropicAuthResolverTests {
@Test
func prefersOAuthFileOverEnv() throws {
let key = "PI_CODING_AGENT_DIR"
let key = "CLAWDIS_OAUTH_DIR"
let previous = ProcessInfo.processInfo.environment[key]
defer {
if let previous {
@@ -61,4 +61,3 @@ struct AnthropicAuthResolverTests {
#expect(mode == .missing)
}
}

View File

@@ -3,18 +3,18 @@ import Testing
@testable import Clawdis
@Suite
struct PiOAuthStoreTests {
struct ClawdisOAuthStoreTests {
@Test
func returnsMissingWhenFileAbsent() {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("clawdis-oauth-\(UUID().uuidString)")
.appendingPathComponent("oauth.json")
#expect(PiOAuthStore.anthropicOAuthStatus(at: url) == .missingFile)
#expect(ClawdisOAuthStore.anthropicOAuthStatus(at: url) == .missingFile)
}
@Test
func usesEnvOverrideForPiAgentDir() throws {
let key = "PI_CODING_AGENT_DIR"
func usesEnvOverrideForClawdisOAuthDir() throws {
let key = "CLAWDIS_OAUTH_DIR"
let previous = ProcessInfo.processInfo.environment[key]
defer {
if let previous {
@@ -25,10 +25,10 @@ struct PiOAuthStoreTests {
}
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("clawdis-pi-agent-\(UUID().uuidString)", isDirectory: true)
.appendingPathComponent("clawdis-oauth-\(UUID().uuidString)", isDirectory: true)
setenv(key, dir.path, 1)
#expect(PiOAuthStore.oauthDir().standardizedFileURL == dir.standardizedFileURL)
#expect(ClawdisOAuthStore.oauthDir().standardizedFileURL == dir.standardizedFileURL)
}
@Test
@@ -42,7 +42,7 @@ struct PiOAuthStoreTests {
],
])
#expect(PiOAuthStore.anthropicOAuthStatus(at: url).isConnected)
#expect(ClawdisOAuthStore.anthropicOAuthStatus(at: url).isConnected)
}
@Test
@@ -55,7 +55,7 @@ struct PiOAuthStoreTests {
],
])
#expect(PiOAuthStore.anthropicOAuthStatus(at: url).isConnected)
#expect(ClawdisOAuthStore.anthropicOAuthStatus(at: url).isConnected)
}
@Test
@@ -68,7 +68,7 @@ struct PiOAuthStoreTests {
],
])
#expect(PiOAuthStore.anthropicOAuthStatus(at: url) == .missingProviderEntry)
#expect(ClawdisOAuthStore.anthropicOAuthStatus(at: url) == .missingProviderEntry)
}
@Test
@@ -81,7 +81,7 @@ struct PiOAuthStoreTests {
],
])
#expect(PiOAuthStore.anthropicOAuthStatus(at: url) == .missingTokens)
#expect(ClawdisOAuthStore.anthropicOAuthStatus(at: url) == .missingTokens)
}
private func writeOAuthFile(_ json: [String: Any]) throws -> URL {