feat(macos): show Anthropic auth mode + OAuth connect

main
Peter Steinberger 2025-12-17 19:14:54 +00:00
parent a0c4b1e061
commit 1a4540d386
8 changed files with 556 additions and 210 deletions

View File

@ -0,0 +1,152 @@
import AppKit
import SwiftUI
@MainActor
struct AnthropicAuthControls: View {
let connectionMode: AppState.ConnectionMode
@State private var oauthStatus: PiOAuthStore.AnthropicOAuthStatus = PiOAuthStore.anthropicOAuthStatus()
@State private var pkce: AnthropicOAuth.PKCE?
@State private var code: String = ""
@State private var busy = false
@State private var statusText: String?
var body: some View {
VStack(alignment: .leading, spacing: 10) {
if self.connectionMode == .remote {
Text("Gateway runs remotely; OAuth must be created on the gateway host where Pi runs.")
.font(.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
HStack(spacing: 10) {
Circle()
.fill(self.oauthStatus.isConnected ? Color.green : Color.orange)
.frame(width: 8, height: 8)
Text(self.oauthStatus.shortDescription)
.font(.footnote.weight(.semibold))
.foregroundStyle(.secondary)
Spacer()
Button("Reveal") {
NSWorkspace.shared.activateFileViewerSelecting([PiOAuthStore.oauthURL()])
}
.buttonStyle(.bordered)
.disabled(!FileManager.default.fileExists(atPath: PiOAuthStore.oauthURL().path))
Button("Refresh") {
self.refresh()
}
.buttonStyle(.bordered)
}
Text(PiOAuthStore.oauthURL().path)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
.textSelection(.enabled)
HStack(spacing: 12) {
Button {
self.startOAuth()
} label: {
if self.busy {
ProgressView().controlSize(.small)
} else {
Text(self.oauthStatus.isConnected ? "Re-auth (OAuth)" : "Open sign-in (OAuth)")
}
}
.buttonStyle(.borderedProminent)
.disabled(self.connectionMode == .remote || self.busy)
if self.pkce != nil {
Button("Cancel") {
self.pkce = nil
self.code = ""
self.statusText = nil
}
.buttonStyle(.bordered)
.disabled(self.busy)
}
}
if self.pkce != nil {
VStack(alignment: .leading, spacing: 8) {
Text("Paste `code#state`")
.font(.footnote.weight(.semibold))
.foregroundStyle(.secondary)
TextField("code#state", text: self.$code)
.textFieldStyle(.roundedBorder)
.disabled(self.busy)
Button("Connect") {
Task { await self.finishOAuth() }
}
.buttonStyle(.bordered)
.disabled(self.busy || self.connectionMode == .remote || self.code
.trimmingCharacters(in: .whitespacesAndNewlines)
.isEmpty)
}
}
if let statusText, !statusText.isEmpty {
Text(statusText)
.font(.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
.onAppear {
self.refresh()
}
}
private func refresh() {
self.oauthStatus = PiOAuthStore.anthropicOAuthStatus()
}
private func startOAuth() {
guard self.connectionMode == .local else { return }
guard !self.busy else { return }
self.busy = true
defer { self.busy = false }
do {
let pkce = try AnthropicOAuth.generatePKCE()
self.pkce = pkce
let url = AnthropicOAuth.buildAuthorizeURL(pkce: pkce)
NSWorkspace.shared.open(url)
self.statusText = "Browser opened. After approving, paste the `code#state` value here."
} catch {
self.statusText = "Failed to start OAuth: \(error.localizedDescription)"
}
}
@MainActor
private func finishOAuth() async {
guard self.connectionMode == .local else { return }
guard !self.busy else { return }
guard let pkce = self.pkce else { return }
self.busy = true
defer { self.busy = false }
let trimmed = self.code.trimmingCharacters(in: .whitespacesAndNewlines)
let splits = trimmed.split(separator: "#", maxSplits: 1).map(String.init)
let code = splits.first ?? ""
let state = splits.count > 1 ? splits[1] : ""
do {
let creds = try await AnthropicOAuth.exchangeCode(code: code, state: state, verifier: pkce.verifier)
try PiOAuthStore.saveAnthropicOAuth(creds)
self.refresh()
self.pkce = nil
self.code = ""
self.statusText = "Connected. Pi can now use Claude via OAuth."
} catch {
self.statusText = "OAuth failed: \(error.localizedDescription)"
}
}
}

View File

@ -10,6 +10,52 @@ struct AnthropicOAuthCredentials: Codable {
let expires: Int64
}
enum AnthropicAuthMode: Equatable {
case oauthFile
case oauthEnv
case apiKeyEnv
case missing
var shortLabel: String {
switch self {
case .oauthFile: "OAuth (Pi token file)"
case .oauthEnv: "OAuth (env var)"
case .apiKeyEnv: "API key (env var)"
case .missing: "Missing credentials"
}
}
var isConfigured: Bool {
switch self {
case .missing: false
case .oauthFile, .oauthEnv, .apiKeyEnv: true
}
}
}
enum AnthropicAuthResolver {
static func resolve(
environment: [String: String] = ProcessInfo.processInfo.environment,
oauthStatus: PiOAuthStore.AnthropicOAuthStatus = PiOAuthStore.anthropicOAuthStatus()) -> AnthropicAuthMode
{
if oauthStatus.isConnected { return .oauthFile }
if let token = environment["ANTHROPIC_OAUTH_TOKEN"]?.trimmingCharacters(in: .whitespacesAndNewlines),
!token.isEmpty
{
return .oauthEnv
}
if let key = environment["ANTHROPIC_API_KEY"]?.trimmingCharacters(in: .whitespacesAndNewlines),
!key.isEmpty
{
return .apiKeyEnv
}
return .missing
}
}
enum AnthropicOAuth {
private static let logger = Logger(subsystem: "com.steipete.clawdis", category: "anthropic-oauth")
@ -107,6 +153,7 @@ enum AnthropicOAuth {
enum PiOAuthStore {
static let oauthFilename = "oauth.json"
private static let providerKey = "anthropic"
private static let piAgentDirEnv = "PI_CODING_AGENT_DIR"
enum AnthropicOAuthStatus: Equatable {
case missingFile
@ -123,18 +170,26 @@ enum PiOAuthStore {
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"
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 .missingTokens: "Anthropic entry missing tokens"
case .connected: "Pi OAuth credentials found"
}
}
}
static func oauthDir() -> URL {
FileManager.default.homeDirectoryForCurrentUser
if let override = ProcessInfo.processInfo.environment[self.piAgentDirEnv]?
.trimmingCharacters(in: .whitespacesAndNewlines),
!override.isEmpty
{
let expanded = NSString(string: override).expandingTildeInPath
return URL(fileURLWithPath: expanded, isDirectory: true)
}
return FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".pi", isDirectory: true)
.appendingPathComponent("agent", isDirectory: true)
}

View File

@ -3,6 +3,7 @@ import SwiftUI
@MainActor
struct ConfigSettings: View {
private let isPreview = ProcessInfo.processInfo.isPreview
private let state = AppStateStore.shared
private let labelColumnWidth: CGFloat = 120
private static let browserAttachOnlyHelp =
"When enabled, the browser server will only connect if the clawd browser is already running."
@ -31,19 +32,64 @@ struct ConfigSettings: View {
@State private var browserAttachOnly: Bool = false
var body: some View {
ScrollView {
ScrollView { self.content }
.onChange(of: self.modelCatalogPath) { _, _ in
Task { await self.loadModels() }
}
.onChange(of: self.modelCatalogReloadBump) { _, _ in
Task { await self.loadModels() }
}
.task {
guard !self.hasLoaded else { return }
guard !self.isPreview else { return }
self.hasLoaded = true
self.loadConfig()
await self.loadModels()
self.allowAutosave = true
}
}
private var content: some View {
VStack(alignment: .leading, spacing: 14) {
self.header
self.agentSection
self.heartbeatSection
self.webChatSection
self.browserSection
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 24)
.padding(.vertical, 18)
.groupBoxStyle(PlainSettingsGroupBoxStyle())
}
@ViewBuilder
private var header: some View {
Text("Clawdis CLI config")
.font(.title3.weight(.semibold))
Text("Edit ~/.clawdis/clawdis.json (inbound.agent / inbound.session).")
.font(.callout)
.foregroundStyle(.secondary)
}
private var agentSection: some View {
GroupBox("Agent") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
GridRow {
self.gridLabel("Model")
VStack(alignment: .leading, spacing: 6) {
self.modelPicker
self.customModelField
self.modelMetaLabels
}
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private var modelPicker: some View {
Picker("Model", selection: self.$configModel) {
ForEach(self.models) { choice in
Text("\(choice.name)\(choice.provider.uppercased())")
@ -57,7 +103,10 @@ struct ConfigSettings: View {
.onChange(of: self.configModel) { _, _ in
self.autosaveConfig()
}
}
@ViewBuilder
private var customModelField: some View {
if self.configModel == "__custom__" {
TextField("Enter model ID", text: self.$customModel)
.textFieldStyle(.roundedBorder)
@ -67,24 +116,43 @@ struct ConfigSettings: View {
self.autosaveConfig()
}
}
}
@ViewBuilder
private var modelMetaLabels: some View {
if let contextLabel = self.selectedContextLabel {
Text(contextLabel)
.font(.footnote)
.foregroundStyle(.secondary)
}
if let authMode = self.selectedAnthropicAuthMode {
HStack(spacing: 8) {
Circle()
.fill(authMode.isConfigured ? Color.green : Color.orange)
.frame(width: 8, height: 8)
Text("Anthropic auth: \(authMode.shortLabel)")
}
.font(.footnote)
.foregroundStyle(authMode.isConfigured ? Color.secondary : Color.orange)
.help(self.anthropicAuthHelpText)
AnthropicAuthControls(connectionMode: self.state.connectionMode)
}
if let modelError {
Text(modelError)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
private var anthropicAuthHelpText: String {
"Determined from Pi OAuth token file (~/.pi/agent/oauth.json) " +
"or environment variables (ANTHROPIC_OAUTH_TOKEN / ANTHROPIC_API_KEY)."
}
private var heartbeatSection: some View {
GroupBox("Heartbeat") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
GridRow {
@ -118,7 +186,9 @@ struct ConfigSettings: View {
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private var webChatSection: some View {
GroupBox("Web Chat") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
GridRow {
@ -149,7 +219,9 @@ struct ConfigSettings: View {
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private var browserSection: some View {
GroupBox("Browser (clawd)") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
GridRow {
@ -221,28 +293,6 @@ struct ConfigSettings: View {
}
}
.frame(maxWidth: .infinity, alignment: .leading)
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 24)
.padding(.vertical, 18)
.groupBoxStyle(PlainSettingsGroupBoxStyle())
}
.onChange(of: self.modelCatalogPath) { _, _ in
Task { await self.loadModels() }
}
.onChange(of: self.modelCatalogReloadBump) { _, _ in
Task { await self.loadModels() }
}
.task {
guard !self.hasLoaded else { return }
guard !self.isPreview else { return }
self.hasLoaded = true
self.loadConfig()
await self.loadModels()
self.allowAutosave = true
}
}
private func gridLabel(_ text: String) -> some View {
@ -424,6 +474,13 @@ struct ConfigSettings: View {
return "Context window: \(human) tokens"
}
private var selectedAnthropicAuthMode: AnthropicAuthMode? {
let chosenId = (self.configModel == "__custom__") ? self.customModel : self.configModel
guard !chosenId.isEmpty, let choice = self.models.first(where: { $0.id == chosenId }) else { return nil }
guard choice.provider.lowercased() == "anthropic" else { return nil }
return AnthropicAuthResolver.resolve()
}
private struct PlainSettingsGroupBoxStyle: GroupBoxStyle {
func makeBody(configuration: Configuration) -> some View {
VStack(alignment: .leading, spacing: 10) {

View File

@ -201,7 +201,7 @@ final class NodePairingApprovalPrompter {
private func endActiveAlert() {
guard let alert = self.activeAlert else { return }
if let parent = alert.window.sheetParent {
parent.endSheet(alert.window, returnCode: .abortModalResponse)
parent.endSheet(alert.window, returnCode: .abort)
}
self.activeAlert = nil
self.activeRequestId = nil

View File

@ -325,7 +325,7 @@ struct OnboardingView: View {
private func anthropicAuthPage() -> some View {
self.onboardingPage {
Text("Sign in to Claude")
Text("Connect Claude")
.font(.largeTitle.weight(.semibold))
Text("Give your model the token it needs!")
.font(.body)
@ -422,7 +422,7 @@ struct OnboardingView: View {
.font(.headline)
Text(
"You can also use an Anthropic API key, but this UI is instructions-only for now " +
"(GUI apps dont automatically inherit your shell env vars).")
"(GUI apps dont automatically inherit your shell env vars like `ANTHROPIC_API_KEY`).")
.font(.subheadline)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)

View File

@ -0,0 +1,64 @@
import Foundation
import Testing
@testable import Clawdis
@Suite
struct AnthropicAuthResolverTests {
@Test
func prefersOAuthFileOverEnv() throws {
let key = "PI_CODING_AGENT_DIR"
let previous = ProcessInfo.processInfo.environment[key]
defer {
if let previous {
setenv(key, previous, 1)
} else {
unsetenv(key)
}
}
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("clawdis-oauth-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
setenv(key, dir.path, 1)
let oauthFile = dir.appendingPathComponent("oauth.json")
let payload = [
"anthropic": [
"type": "oauth",
"refresh": "r1",
"access": "a1",
"expires": 1_234_567_890,
],
]
let data = try JSONSerialization.data(withJSONObject: payload, options: [.prettyPrinted, .sortedKeys])
try data.write(to: oauthFile, options: [.atomic])
let mode = AnthropicAuthResolver.resolve(environment: [
"ANTHROPIC_API_KEY": "sk-ant-ignored",
])
#expect(mode == .oauthFile)
}
@Test
func reportsOAuthEnvWhenPresent() {
let mode = AnthropicAuthResolver.resolve(environment: [
"ANTHROPIC_OAUTH_TOKEN": "token",
], oauthStatus: .missingFile)
#expect(mode == .oauthEnv)
}
@Test
func reportsAPIKeyEnvWhenPresent() {
let mode = AnthropicAuthResolver.resolve(environment: [
"ANTHROPIC_API_KEY": "sk-ant-key",
], oauthStatus: .missingFile)
#expect(mode == .apiKeyEnv)
}
@Test
func reportsMissingWhenNothingConfigured() {
let mode = AnthropicAuthResolver.resolve(environment: [:], oauthStatus: .missingFile)
#expect(mode == .missing)
}
}

View File

@ -22,7 +22,7 @@ struct CanvasWindowSmokeTests {
controller.applyPreferredPlacement(CanvasPlacement(x: 120, y: 200, width: 520, height: 680))
controller.showCanvas(path: "/")
_ = await controller.eval(javaScript: "1 + 1")
_ = try await controller.eval(javaScript: "1 + 1")
controller.windowDidMove(Notification(name: NSWindow.didMoveNotification))
controller.windowDidEndLiveResize(Notification(name: NSWindow.didEndLiveResizeNotification))
controller.hideCanvas()
@ -45,4 +45,3 @@ struct CanvasWindowSmokeTests {
controller.close()
}
}

View File

@ -12,6 +12,25 @@ struct PiOAuthStoreTests {
#expect(PiOAuthStore.anthropicOAuthStatus(at: url) == .missingFile)
}
@Test
func usesEnvOverrideForPiAgentDir() throws {
let key = "PI_CODING_AGENT_DIR"
let previous = ProcessInfo.processInfo.environment[key]
defer {
if let previous {
setenv(key, previous, 1)
} else {
unsetenv(key)
}
}
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("clawdis-pi-agent-\(UUID().uuidString)", isDirectory: true)
setenv(key, dir.path, 1)
#expect(PiOAuthStore.oauthDir().standardizedFileURL == dir.standardizedFileURL)
}
@Test
func acceptsPiFormatTokens() throws {
let url = try self.writeOAuthFile([