From 1a4540d386a9633b5146c92cebea50a7513e1a8e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 17 Dec 2025 19:14:54 +0000 Subject: [PATCH] feat(macos): show Anthropic auth mode + OAuth connect --- .../Clawdis/AnthropicAuthControls.swift | 152 ++++++ .../Sources/Clawdis/AnthropicOAuth.swift | 69 ++- .../Sources/Clawdis/ConfigSettings.swift | 453 ++++++++++-------- .../Clawdis/NodePairingApprovalPrompter.swift | 2 +- apps/macos/Sources/Clawdis/Onboarding.swift | 4 +- .../AnthropicAuthResolverTests.swift | 64 +++ .../CanvasWindowSmokeTests.swift | 3 +- .../ClawdisIPCTests/PiOAuthStoreTests.swift | 19 + 8 files changed, 556 insertions(+), 210 deletions(-) create mode 100644 apps/macos/Sources/Clawdis/AnthropicAuthControls.swift create mode 100644 apps/macos/Tests/ClawdisIPCTests/AnthropicAuthResolverTests.swift diff --git a/apps/macos/Sources/Clawdis/AnthropicAuthControls.swift b/apps/macos/Sources/Clawdis/AnthropicAuthControls.swift new file mode 100644 index 000000000..62722dcf8 --- /dev/null +++ b/apps/macos/Sources/Clawdis/AnthropicAuthControls.swift @@ -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)" + } + } +} + diff --git a/apps/macos/Sources/Clawdis/AnthropicOAuth.swift b/apps/macos/Sources/Clawdis/AnthropicOAuth.swift index eae266b53..b9e0919fe 100644 --- a/apps/macos/Sources/Clawdis/AnthropicOAuth.swift +++ b/apps/macos/Sources/Clawdis/AnthropicOAuth.swift @@ -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) } diff --git a/apps/macos/Sources/Clawdis/ConfigSettings.swift b/apps/macos/Sources/Clawdis/ConfigSettings.swift index 92b4b186c..829655b3e 100644 --- a/apps/macos/Sources/Clawdis/ConfigSettings.swift +++ b/apps/macos/Sources/Clawdis/ConfigSettings.swift @@ -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,204 +32,7 @@ struct ConfigSettings: View { @State private var browserAttachOnly: Bool = false var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 14) { - Text("Clawdis CLI config") - .font(.title3.weight(.semibold)) - Text("Edit ~/.clawdis/clawdis.json (inbound.agent / inbound.session).") - .font(.callout) - .foregroundStyle(.secondary) - - GroupBox("Agent") { - Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) { - GridRow { - self.gridLabel("Model") - VStack(alignment: .leading, spacing: 6) { - Picker("Model", selection: self.$configModel) { - ForEach(self.models) { choice in - Text("\(choice.name) — \(choice.provider.uppercased())") - .tag(choice.id) - } - Text("Manual entry…").tag("__custom__") - } - .labelsHidden() - .frame(maxWidth: .infinity) - .disabled(self.modelsLoading || (!self.modelError.isNilOrEmpty && self.models.isEmpty)) - .onChange(of: self.configModel) { _, _ in - self.autosaveConfig() - } - - if self.configModel == "__custom__" { - TextField("Enter model ID", text: self.$customModel) - .textFieldStyle(.roundedBorder) - .frame(maxWidth: .infinity) - .onChange(of: self.customModel) { _, newValue in - self.configModel = newValue - self.autosaveConfig() - } - } - - if let contextLabel = self.selectedContextLabel { - Text(contextLabel) - .font(.footnote) - .foregroundStyle(.secondary) - } - - if let modelError { - Text(modelError) - .font(.footnote) - .foregroundStyle(.secondary) - } - } - } - } - } - .frame(maxWidth: .infinity, alignment: .leading) - - GroupBox("Heartbeat") { - Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) { - GridRow { - self.gridLabel("Schedule") - VStack(alignment: .leading, spacing: 6) { - HStack(spacing: 12) { - Stepper( - value: Binding( - get: { self.heartbeatMinutes ?? 10 }, - set: { self.heartbeatMinutes = $0; self.autosaveConfig() }), - in: 0...720) - { - Text("Every \(self.heartbeatMinutes ?? 10) min") - .frame(width: 150, alignment: .leading) - } - .help("Set to 0 to disable automatic heartbeats") - - TextField("HEARTBEAT", text: self.$heartbeatBody) - .textFieldStyle(.roundedBorder) - .frame(maxWidth: .infinity) - .onChange(of: self.heartbeatBody) { _, _ in - self.autosaveConfig() - } - .help("Message body sent on each heartbeat") - } - Text("Heartbeats keep agent sessions warm; 0 minutes disables them.") - .font(.footnote) - .foregroundStyle(.secondary) - } - } - } - } - .frame(maxWidth: .infinity, alignment: .leading) - - GroupBox("Web Chat") { - Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) { - GridRow { - self.gridLabel("Enabled") - Toggle("", isOn: self.$webChatEnabled) - .labelsHidden() - .toggleStyle(.checkbox) - } - GridRow { - self.gridLabel("Port") - TextField("18788", value: self.$webChatPort, formatter: NumberFormatter()) - .textFieldStyle(.roundedBorder) - .frame(width: 100) - .disabled(!self.webChatEnabled) - } - GridRow { - Color.clear - .frame(width: self.labelColumnWidth, height: 1) - Text( - """ - Mac app connects to the gateway’s loopback web chat on this port. - Remote mode uses SSH -L to forward it. - """) - .font(.footnote) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, alignment: .leading) - } - } - } - .frame(maxWidth: .infinity, alignment: .leading) - - GroupBox("Browser (clawd)") { - Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) { - GridRow { - self.gridLabel("Enabled") - Toggle("", isOn: self.$browserEnabled) - .labelsHidden() - .toggleStyle(.checkbox) - .onChange(of: self.browserEnabled) { _, _ in self.autosaveConfig() } - } - GridRow { - self.gridLabel("Control URL") - TextField("http://127.0.0.1:18791", text: self.$browserControlUrl) - .textFieldStyle(.roundedBorder) - .frame(maxWidth: .infinity) - .disabled(!self.browserEnabled) - .onChange(of: self.browserControlUrl) { _, _ in self.autosaveConfig() } - } - GridRow { - self.gridLabel("Browser path") - VStack(alignment: .leading, spacing: 2) { - if let label = self.browserPathLabel { - Text(label) - .font(.caption.monospaced()) - .foregroundStyle(.secondary) - .textSelection(.enabled) - .lineLimit(1) - .truncationMode(.middle) - } else { - Text("—") - .foregroundStyle(.secondary) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - } - GridRow { - self.gridLabel("Accent") - HStack(spacing: 8) { - TextField("#FF4500", text: self.$browserColorHex) - .textFieldStyle(.roundedBorder) - .frame(width: 120) - .disabled(!self.browserEnabled) - .onChange(of: self.browserColorHex) { _, _ in self.autosaveConfig() } - Circle() - .fill(self.browserColor) - .frame(width: 12, height: 12) - .overlay(Circle().stroke(Color.secondary.opacity(0.25), lineWidth: 1)) - Text("lobster-orange") - .font(.footnote) - .foregroundStyle(.secondary) - } - } - GridRow { - self.gridLabel("Attach only") - Toggle("", isOn: self.$browserAttachOnly) - .labelsHidden() - .toggleStyle(.checkbox) - .disabled(!self.browserEnabled) - .onChange(of: self.browserAttachOnly) { _, _ in self.autosaveConfig() } - .help(Self.browserAttachOnlyHelp) - } - GridRow { - Color.clear - .frame(width: self.labelColumnWidth, height: 1) - Text(Self.browserProfileNote) - .font(.footnote) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, alignment: .leading) - } - } - } - .frame(maxWidth: .infinity, alignment: .leading) - - Spacer(minLength: 0) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 24) - .padding(.vertical, 18) - .groupBoxStyle(PlainSettingsGroupBoxStyle()) - } + ScrollView { self.content } .onChange(of: self.modelCatalogPath) { _, _ in Task { await self.loadModels() } } @@ -245,6 +49,252 @@ struct ConfigSettings: View { } } + 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())") + .tag(choice.id) + } + Text("Manual entry…").tag("__custom__") + } + .labelsHidden() + .frame(maxWidth: .infinity) + .disabled(self.modelsLoading || (!self.modelError.isNilOrEmpty && self.models.isEmpty)) + .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) + .frame(maxWidth: .infinity) + .onChange(of: self.customModel) { _, newValue in + self.configModel = newValue + 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) + } + } + + 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 { + self.gridLabel("Schedule") + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 12) { + Stepper( + value: Binding( + get: { self.heartbeatMinutes ?? 10 }, + set: { self.heartbeatMinutes = $0; self.autosaveConfig() }), + in: 0...720) + { + Text("Every \(self.heartbeatMinutes ?? 10) min") + .frame(width: 150, alignment: .leading) + } + .help("Set to 0 to disable automatic heartbeats") + + TextField("HEARTBEAT", text: self.$heartbeatBody) + .textFieldStyle(.roundedBorder) + .frame(maxWidth: .infinity) + .onChange(of: self.heartbeatBody) { _, _ in + self.autosaveConfig() + } + .help("Message body sent on each heartbeat") + } + Text("Heartbeats keep agent sessions warm; 0 minutes disables them.") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var webChatSection: some View { + GroupBox("Web Chat") { + Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) { + GridRow { + self.gridLabel("Enabled") + Toggle("", isOn: self.$webChatEnabled) + .labelsHidden() + .toggleStyle(.checkbox) + } + GridRow { + self.gridLabel("Port") + TextField("18788", value: self.$webChatPort, formatter: NumberFormatter()) + .textFieldStyle(.roundedBorder) + .frame(width: 100) + .disabled(!self.webChatEnabled) + } + GridRow { + Color.clear + .frame(width: self.labelColumnWidth, height: 1) + Text( + """ + Mac app connects to the gateway’s loopback web chat on this port. + Remote mode uses SSH -L to forward it. + """) + .font(.footnote) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var browserSection: some View { + GroupBox("Browser (clawd)") { + Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) { + GridRow { + self.gridLabel("Enabled") + Toggle("", isOn: self.$browserEnabled) + .labelsHidden() + .toggleStyle(.checkbox) + .onChange(of: self.browserEnabled) { _, _ in self.autosaveConfig() } + } + GridRow { + self.gridLabel("Control URL") + TextField("http://127.0.0.1:18791", text: self.$browserControlUrl) + .textFieldStyle(.roundedBorder) + .frame(maxWidth: .infinity) + .disabled(!self.browserEnabled) + .onChange(of: self.browserControlUrl) { _, _ in self.autosaveConfig() } + } + GridRow { + self.gridLabel("Browser path") + VStack(alignment: .leading, spacing: 2) { + if let label = self.browserPathLabel { + Text(label) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .textSelection(.enabled) + .lineLimit(1) + .truncationMode(.middle) + } else { + Text("—") + .foregroundStyle(.secondary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + GridRow { + self.gridLabel("Accent") + HStack(spacing: 8) { + TextField("#FF4500", text: self.$browserColorHex) + .textFieldStyle(.roundedBorder) + .frame(width: 120) + .disabled(!self.browserEnabled) + .onChange(of: self.browserColorHex) { _, _ in self.autosaveConfig() } + Circle() + .fill(self.browserColor) + .frame(width: 12, height: 12) + .overlay(Circle().stroke(Color.secondary.opacity(0.25), lineWidth: 1)) + Text("lobster-orange") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + GridRow { + self.gridLabel("Attach only") + Toggle("", isOn: self.$browserAttachOnly) + .labelsHidden() + .toggleStyle(.checkbox) + .disabled(!self.browserEnabled) + .onChange(of: self.browserAttachOnly) { _, _ in self.autosaveConfig() } + .help(Self.browserAttachOnlyHelp) + } + GridRow { + Color.clear + .frame(width: self.labelColumnWidth, height: 1) + Text(Self.browserProfileNote) + .font(.footnote) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + private func gridLabel(_ text: String) -> some View { Text(text) .foregroundStyle(.secondary) @@ -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) { diff --git a/apps/macos/Sources/Clawdis/NodePairingApprovalPrompter.swift b/apps/macos/Sources/Clawdis/NodePairingApprovalPrompter.swift index 5d7c849b6..7cbc415fc 100644 --- a/apps/macos/Sources/Clawdis/NodePairingApprovalPrompter.swift +++ b/apps/macos/Sources/Clawdis/NodePairingApprovalPrompter.swift @@ -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 diff --git a/apps/macos/Sources/Clawdis/Onboarding.swift b/apps/macos/Sources/Clawdis/Onboarding.swift index 0f6abc6ba..d4062a34f 100644 --- a/apps/macos/Sources/Clawdis/Onboarding.swift +++ b/apps/macos/Sources/Clawdis/Onboarding.swift @@ -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 don’t automatically inherit your shell env vars).") + "(GUI apps don’t automatically inherit your shell env vars like `ANTHROPIC_API_KEY`).") .font(.subheadline) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) diff --git a/apps/macos/Tests/ClawdisIPCTests/AnthropicAuthResolverTests.swift b/apps/macos/Tests/ClawdisIPCTests/AnthropicAuthResolverTests.swift new file mode 100644 index 000000000..20a4eeebc --- /dev/null +++ b/apps/macos/Tests/ClawdisIPCTests/AnthropicAuthResolverTests.swift @@ -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) + } +} + diff --git a/apps/macos/Tests/ClawdisIPCTests/CanvasWindowSmokeTests.swift b/apps/macos/Tests/ClawdisIPCTests/CanvasWindowSmokeTests.swift index c00a1aa23..6dd3b4dc5 100644 --- a/apps/macos/Tests/ClawdisIPCTests/CanvasWindowSmokeTests.swift +++ b/apps/macos/Tests/ClawdisIPCTests/CanvasWindowSmokeTests.swift @@ -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() } } - diff --git a/apps/macos/Tests/ClawdisIPCTests/PiOAuthStoreTests.swift b/apps/macos/Tests/ClawdisIPCTests/PiOAuthStoreTests.swift index 5ccae416d..278d7aa7f 100644 --- a/apps/macos/Tests/ClawdisIPCTests/PiOAuthStoreTests.swift +++ b/apps/macos/Tests/ClawdisIPCTests/PiOAuthStoreTests.swift @@ -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([