diff --git a/scarf/scarf/Core/Services/NousAuthFlow.swift b/scarf/scarf/Core/Services/NousAuthFlow.swift new file mode 100644 index 0000000..640bd60 --- /dev/null +++ b/scarf/scarf/Core/Services/NousAuthFlow.swift @@ -0,0 +1,249 @@ +import Foundation +import AppKit +import os + +/// Drives `hermes auth add nous --no-browser` for Nous Portal sign-in. +/// +/// Nous uses OAuth 2.0 device-code flow, not PKCE. Hermes prints the +/// verification URL + user code to stdout, then long-polls the token +/// endpoint every ~1s until the user approves in their browser (or the +/// device code expires, currently 15 minutes). +/// +/// The controller: +/// +/// 1. Spawns hermes via `context.makeTransport().makeProcess(...)`. +/// 2. Streams stdout, regex-extracts the `verification_uri_complete` and +/// `user_code` from the lines hermes prints (auth.py:3282-3286). +/// 3. Auto-opens the verification URL in the default browser and +/// transitions to `.waitingForApproval` so the sheet can show the code. +/// 4. On subprocess exit, confirms success by re-reading `auth.json` via +/// `NousSubscriptionService` — hermes exit 0 alone isn't enough, we want +/// to see `providers.nous.access_token` actually landed. +/// 5. Detects the `subscription_required` failure (auth.py:3347-3356) and +/// surfaces the billing URL so the sheet can offer a Subscribe link. +/// +/// The parser functions are `nonisolated static` so tests can feed fixture +/// buffers without standing up a real subprocess. +@Observable +@MainActor +final class NousAuthFlow { + enum State: Equatable { + case idle + case starting + case waitingForApproval(userCode: String, verificationURL: URL) + case success + case failure(reason: String, billingURL: URL?) + } + + private(set) var state: State = .idle + /// Accumulated subprocess output. Surfaced in the failure UI so the user + /// can copy the tail for bug reports. + private(set) var output: String = "" + + let context: ServerContext + private let subscriptionService: NousSubscriptionService + private let logger = Logger(subsystem: "com.scarf", category: "NousAuthFlow") + + private var process: Process? + private var stdoutPipe: Pipe? + + init(context: ServerContext = .local) { + self.context = context + self.subscriptionService = NousSubscriptionService(context: context) + } + + // MARK: - Lifecycle + + /// Start the sign-in flow. Any in-flight subprocess is terminated first. + /// Safe to call repeatedly (e.g. user hits "Try again"). + func start() { + cancel() + output = "" + state = .starting + + let proc = context.makeTransport().makeProcess( + executable: context.paths.hermesBinary, + args: ["auth", "add", "nous", "--no-browser"] + ) + if !context.isRemote { + // Only enrich env locally — remote ssh gets the remote login env + // naturally, and exporting our local keys into it would be wrong. + proc.environment = HermesFileService.enrichedEnvironment() + } + + let outPipe = Pipe() + // Merge stderr into stdout — hermes prints the device-code block to + // stdout but may emit diagnostics on stderr; we want them interleaved + // in display order so the failure-tail UI reads naturally. + proc.standardOutput = outPipe + proc.standardError = outPipe + + outPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in + let data = handle.availableData + if data.isEmpty { + handle.readabilityHandler = nil + return + } + let chunk = String(data: data, encoding: .utf8) ?? "" + Task { @MainActor [weak self] in + self?.handleOutputChunk(chunk) + } + } + + proc.terminationHandler = { [weak self] p in + let code = p.terminationStatus + Task { @MainActor [weak self] in + outPipe.fileHandleForReading.readabilityHandler = nil + self?.handleTermination(exitCode: code) + } + } + + do { + try proc.run() + process = proc + stdoutPipe = outPipe + } catch { + logger.error("failed to spawn hermes: \(error.localizedDescription, privacy: .public)") + state = .failure( + reason: "Failed to start hermes: \(error.localizedDescription)", + billingURL: nil + ) + } + } + + /// Terminate the in-flight subprocess. Idempotent. Does NOT clear state — + /// the sheet dismisses on cancel via its own binding, and re-opening + /// calls `start()` which does a fresh reset. + func cancel() { + stdoutPipe?.fileHandleForReading.readabilityHandler = nil + process?.terminate() + process = nil + stdoutPipe = nil + } + + // MARK: - Output handling + + private func handleOutputChunk(_ chunk: String) { + output += chunk + // Only transition into waiting while we're still in .starting — once + // we've already emitted the URL + code, subsequent "Waiting for + // approval..." noise shouldn't re-fire NSWorkspace.open. + guard case .starting = state else { return } + if let result = Self.parseDeviceCode(from: output) { + state = .waitingForApproval( + userCode: result.userCode, + verificationURL: result.verificationURL + ) + NSWorkspace.shared.open(result.verificationURL) + } + } + + private func handleTermination(exitCode: Int32) { + // Subscription-required is a specific failure path that hermes + // signals both via an exit code and a unique billing-URL message. + // It overrides other checks because we want the Subscribe affordance + // in the UI regardless of exit code. + if let billing = Self.parseSubscriptionRequired(from: output) { + state = .failure( + reason: "Your Nous Portal account does not have an active subscription.", + billingURL: billing + ) + return + } + if exitCode == 0 { + // Hermes claims success. Confirm by reading auth.json — the + // authoritative signal is that providers.nous has an access token + // AND active_provider flipped to nous. Anything short of that is + // a silent failure on the hermes side. + let sub = subscriptionService.loadState() + if sub.subscribed { + state = .success + } else if sub.present { + state = .failure( + reason: "Signed in, but Nous isn't the active provider yet. Run `hermes model` and pick Nous Portal.", + billingURL: nil + ) + } else { + state = .failure( + reason: "Sign-in finished without writing credentials. Try again, or run `hermes auth add nous` in a terminal to see full diagnostics.", + billingURL: nil + ) + } + } else { + let tail = Self.lastLines(of: output, count: 8) + state = .failure( + reason: tail.isEmpty + ? "hermes exited with code \(exitCode)" + : tail, + billingURL: nil + ) + } + } + + // MARK: - Parsers (pure, testable) + + struct DeviceCodeResult: Equatable { + let verificationURL: URL + let userCode: String + } + + /// Extract the device-code verification URL and user code from hermes's + /// output. Anchored on the exact shape hermes prints (auth.py:3282-3286): + /// + /// To continue: + /// 1. Open: https://portal.nousresearch.com/device/XXXX-XXXX + /// 2. If prompted, enter code: XXXX-XXXX + /// + /// Returns nil when either line is missing — the sheet stays on the + /// `.starting` spinner until both are captured. + nonisolated static func parseDeviceCode(from text: String) -> DeviceCodeResult? { + let urlPattern = #"^\s*1\.\s*Open:\s*(https?://\S+)\s*$"# + let codePattern = #"^\s*2\.\s*If prompted, enter code:\s*(\S+)\s*$"# + guard + let urlString = firstCapture(in: text, pattern: urlPattern), + let userCode = firstCapture(in: text, pattern: codePattern), + let url = URL(string: urlString) + else { + return nil + } + return DeviceCodeResult(verificationURL: url, userCode: userCode) + } + + /// Detect the subscription-required failure and extract the billing URL + /// hermes prints (auth.py:3347-3356). Scarf shows a "Subscribe" button + /// linking to this URL so the user can resolve the blocker without + /// hunting through logs. + nonisolated static func parseSubscriptionRequired(from text: String) -> URL? { + guard text.contains("Your Nous Portal account does not have an active subscription") else { + return nil + } + guard + let raw = firstCapture(in: text, pattern: #"Subscribe here:\s*(https?://\S+)"#), + let url = URL(string: raw) + else { + return nil + } + return url + } + + private nonisolated static func firstCapture(in text: String, pattern: String) -> String? { + guard let regex = try? NSRegularExpression(pattern: pattern, options: [.anchorsMatchLines]) else { + return nil + } + let range = NSRange(text.startIndex..., in: text) + guard + let match = regex.firstMatch(in: text, range: range), + match.numberOfRanges >= 2, + let r = Range(match.range(at: 1), in: text) + else { + return nil + } + return String(text[r]) + } + + private nonisolated static func lastLines(of text: String, count: Int) -> String { + let lines = text.split(separator: "\n", omittingEmptySubsequences: false) + return lines.suffix(count).joined(separator: "\n") + .trimmingCharacters(in: .whitespacesAndNewlines) + } +} diff --git a/scarf/scarf/Features/CredentialPools/ViewModels/CredentialPoolsOAuthGate.swift b/scarf/scarf/Features/CredentialPools/ViewModels/CredentialPoolsOAuthGate.swift new file mode 100644 index 0000000..1e79b52 --- /dev/null +++ b/scarf/scarf/Features/CredentialPools/ViewModels/CredentialPoolsOAuthGate.swift @@ -0,0 +1,52 @@ +import Foundation + +/// Describes whether Credential Pools' generic OAuth flow +/// (``OAuthFlowController``) can handle a given provider. +/// +/// Hermes supports four OAuth styles, and only **PKCE** is driven by the +/// generic controller: +/// +/// | Style | Works via `OAuthFlowController`? | Example providers | +/// |---|---|---| +/// | PKCE | ✅ Yes | anthropic, github-copilot | +/// | Device-code | ❌ No — stalls silently | nous | +/// | External OAuth | ❌ No — needs a terminal | openai-codex, qwen-oauth, google-gemini-cli | +/// | External process | ❌ No — uses an agent bridge | copilot-acp | +/// +/// Routing a non-PKCE provider through the generic controller silently +/// fails: the PKCE URL regex in ``OAuthFlowController/extractAuthURL`` only +/// matches `client_id=…&redirect_uri=…` -shaped strings, and nothing else +/// hermes prints for the other styles matches that. This gate closes the +/// dead end by steering the user to the right flow for each style. +/// +/// `.ok` is the default for unknown providers so existing PKCE-based +/// flows (anthropic, etc.) keep working — this gate is strictly additive. +enum CredentialPoolsOAuthGate: Equatable { + /// The standard PKCE flow works for this provider — show the normal + /// "Start OAuth" button and let ``OAuthFlowController`` handle it. + case ok + /// User hasn't typed a provider ID yet. Disable the button. + case providerEmpty + /// Route Nous Portal through ``NousSignInSheet`` instead of the + /// generic flow, since Nous uses device-code. + case useNousSignIn + /// Hermes knows how to sign in to this provider but Scarf doesn't yet + /// have a dedicated UI for it. Point the user to `hermes auth add + /// ` in a terminal. + case useCLI(provider: String) + + /// Compute the gate for a typed provider ID. Consults the Hermes + /// overlay table via ``ModelCatalogService/overlayMetadata(for:)`` to + /// decide which OAuth style applies. + static func resolve(providerID rawID: String, catalog: ModelCatalogService) -> CredentialPoolsOAuthGate { + let id = rawID.trimmingCharacters(in: .whitespaces).lowercased() + guard !id.isEmpty else { return .providerEmpty } + if id == "nous" { return .useNousSignIn } + switch catalog.overlayMetadata(for: id)?.authType { + case .oauthDeviceCode, .oauthExternal, .externalProcess: + return .useCLI(provider: id) + default: + return .ok + } + } +} diff --git a/scarf/scarf/Features/CredentialPools/Views/CredentialPoolsView.swift b/scarf/scarf/Features/CredentialPools/Views/CredentialPoolsView.swift index 6449fe1..f2f3f7f 100644 --- a/scarf/scarf/Features/CredentialPools/Views/CredentialPoolsView.swift +++ b/scarf/scarf/Features/CredentialPools/Views/CredentialPoolsView.swift @@ -210,9 +210,18 @@ private struct AddCredentialSheet: View { @State private var providers: [HermesProviderInfo] = [] @State private var oauthStarted: Bool = false @State private var authCode: String = "" + /// Drives presentation of the dedicated Nous sign-in sheet from inside + /// this add-credential sheet. Nous uses device-code, not PKCE — the + /// regular `OAuthFlowController` silently stalls, so we route Nous + /// through ``NousSignInSheet`` instead. + @State private var showNousSignIn: Bool = false private var catalog: ModelCatalogService { ModelCatalogService(context: viewModel.context) } + private func oauthGate(for rawID: String) -> CredentialPoolsOAuthGate { + CredentialPoolsOAuthGate.resolve(providerID: rawID, catalog: catalog) + } + var body: some View { VStack(alignment: .leading, spacing: 16) { Text("Add Credential") @@ -240,6 +249,17 @@ private struct AddCredentialSheet: View { onDismiss() } } + // Nous sign-in is a parallel flow that bypasses OAuthFlowController. + // When it completes, the parent list refreshes from auth.json just + // like it does after a regular OAuth add — so we dismiss the + // AddCredentialSheet after a short delay. + .sheet(isPresented: $showNousSignIn) { + NousSignInSheet { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { + onDismiss() + } + } + } } // MARK: - Step 1: provider + type + label + optional API key @@ -290,11 +310,57 @@ private struct AddCredentialSheet: View { .font(.system(.caption, design: .monospaced)) } } else { - oauthPreamble + oauthGuidance } } } + /// Renders either the standard PKCE preamble, the Nous-specific + /// "sign in with the dedicated sheet" affordance, or a CLI fallback — + /// whichever matches the provider the user has typed. + @ViewBuilder + private var oauthGuidance: some View { + switch oauthGate(for: providerID) { + case .ok, .providerEmpty: + oauthPreamble + case .useNousSignIn: + nousSignInPreamble + case .useCLI(let provider): + cliFallbackPreamble(for: provider) + } + } + + private var nousSignInPreamble: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 6) { + Image(systemName: "sparkles") + .foregroundStyle(.tint) + Text("Nous Portal uses a dedicated sign-in flow.") + .font(.caption) + } + Text("We'll open the Nous Portal approval page in your browser and show the device code here. No code-paste step.") + .font(.caption2) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + + private func cliFallbackPreamble(for provider: String) -> some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 6) { + Image(systemName: "terminal") + .foregroundStyle(.secondary) + Text("`\(provider)` uses a different sign-in flow.") + .font(.caption) + } + Text("Run `hermes auth add \(provider)` in a terminal to finish sign-in. In-app support for this provider is coming in a follow-up.") + .font(.caption2) + .foregroundStyle(.secondary) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + } + } + /// Brief explanation shown before the user clicks "Start OAuth". Sets /// expectations about the embedded-terminal flow so the browser window /// and code-paste step aren't surprises. @@ -476,14 +542,38 @@ private struct AddCredentialSheet: View { .buttonStyle(.borderedProminent) .disabled(providerID.trimmingCharacters(in: .whitespaces).isEmpty || apiKey.trimmingCharacters(in: .whitespaces).isEmpty) } else { - Button("Start OAuth") { - viewModel.startOAuth(provider: providerID, label: label) - oauthStarted = true - } - .buttonStyle(.borderedProminent) - .disabled(providerID.trimmingCharacters(in: .whitespaces).isEmpty) + oauthActionButton } } } } + + /// Gate-aware OAuth primary action. For PKCE providers it's the + /// unchanged "Start OAuth" button; for Nous it's "Sign in to Nous + /// Portal" (opens ``NousSignInSheet``); for other device-code / + /// external providers it's a disabled button with a CLI hint inline. + @ViewBuilder + private var oauthActionButton: some View { + switch oauthGate(for: providerID) { + case .providerEmpty: + Button("Start OAuth") {} + .buttonStyle(.borderedProminent) + .disabled(true) + case .ok: + Button("Start OAuth") { + viewModel.startOAuth(provider: providerID, label: label) + oauthStarted = true + } + .buttonStyle(.borderedProminent) + case .useNousSignIn: + Button("Sign in to Nous Portal") { + showNousSignIn = true + } + .buttonStyle(.borderedProminent) + case .useCLI: + Button("Start OAuth") {} + .buttonStyle(.borderedProminent) + .disabled(true) + } + } } diff --git a/scarf/scarf/Features/Settings/Views/Components/ModelPickerSheet.swift b/scarf/scarf/Features/Settings/Views/Components/ModelPickerSheet.swift index 0bdd93c..206b8ce 100644 --- a/scarf/scarf/Features/Settings/Views/Components/ModelPickerSheet.swift +++ b/scarf/scarf/Features/Settings/Views/Components/ModelPickerSheet.swift @@ -35,6 +35,10 @@ struct ModelPickerSheet: View { // appear; stays in-memory for the life of the sheet. @State private var subscription: NousSubscriptionState = .absent + /// Drives presentation of the Nous sign-in sheet. Bound to the + /// "Sign in to Nous Portal" button in the subscription summary. + @State private var showNousSignIn: Bool = false + @Environment(\.serverContext) private var serverContext private var catalog: ModelCatalogService { ModelCatalogService(context: serverContext) } private var subscriptionService: NousSubscriptionService { NousSubscriptionService(context: serverContext) } @@ -63,6 +67,14 @@ struct ModelPickerSheet: View { subscription = subscriptionService.loadState() loadModelsForSelection() } + .sheet(isPresented: $showNousSignIn) { + NousSignInSheet { + // Refresh subscription immediately so the right-column + // status row flips to "active" without waiting for the + // picker to be re-opened. + subscription = subscriptionService.loadState() + } + } } private var header: some View { @@ -242,11 +254,21 @@ struct ModelPickerSheet: View { Text("Signed in to Nous, but another provider is active.") .foregroundStyle(.secondary) } else { - Text("Not signed in. Run `hermes auth` and select Nous Portal.") + Text("Not signed in yet.") .foregroundStyle(.secondary) } } .font(.callout) + + if !subscription.subscribed { + Button { + showNousSignIn = true + } label: { + Label("Sign in to Nous Portal", systemImage: "person.badge.key.fill") + } + .buttonStyle(.borderedProminent) + .controlSize(.regular) + } } } diff --git a/scarf/scarf/Features/Settings/Views/Components/NousSignInSheet.swift b/scarf/scarf/Features/Settings/Views/Components/NousSignInSheet.swift new file mode 100644 index 0000000..3c5a2b9 --- /dev/null +++ b/scarf/scarf/Features/Settings/Views/Components/NousSignInSheet.swift @@ -0,0 +1,237 @@ +import SwiftUI +import AppKit + +/// In-app sign-in sheet for Nous Portal — hosts a ``NousAuthFlow`` and +/// renders one of four sub-views keyed on `flow.state`. Reached from the +/// model picker's Nous Portal row, the Auxiliary tab's per-task toggle, +/// and Credential Pools when the selected provider is `nous`. +/// +/// UX contract with the caller: +/// +/// - Sheet is presented via `.sheet(isPresented:)` from the caller. +/// - Parent owns the `isPresented` binding and a `@State var` for the +/// dismiss trigger. +/// - `onSignedIn` fires on success so the caller can refresh subscription +/// state (e.g. re-query ``NousSubscriptionService``) before the sheet +/// auto-dismisses ~1.2s later. +struct NousSignInSheet: View { + @Environment(\.serverContext) private var serverContext + @Environment(\.dismiss) private var dismiss + + /// Fires on `.success`. Callers use this to refresh their cached + /// ``NousSubscriptionState`` so the new "Subscription active" chip + /// shows immediately without waiting for a full view reload. + var onSignedIn: () -> Void = {} + + @State private var flow: NousAuthFlow? + @State private var successDismissTask: Task? + + var body: some View { + VStack(spacing: 16) { + header + Divider() + content + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .padding(20) + .frame(minWidth: 440, idealWidth: 440, minHeight: 340) + .onAppear { + if flow == nil { + let f = NousAuthFlow(context: serverContext) + flow = f + f.start() + } + } + .onDisappear { + successDismissTask?.cancel() + flow?.cancel() + } + .onChange(of: flowState) { _, newValue in + if case .success = newValue { + onSignedIn() + successDismissTask?.cancel() + successDismissTask = Task { @MainActor in + try? await Task.sleep(nanoseconds: 1_200_000_000) + if !Task.isCancelled { dismiss() } + } + } + } + } + + private var flowState: NousAuthFlow.State { + flow?.state ?? .idle + } + + // MARK: - Header + + private var header: some View { + HStack(spacing: 8) { + Image(systemName: "person.badge.key.fill") + .foregroundStyle(.tint) + Text("Sign in to Nous Portal") + .font(.headline) + Spacer() + if case .waitingForApproval = flowState { + Button("Cancel") { dismiss() } + .controlSize(.small) + } else if case .starting = flowState { + Button("Cancel") { dismiss() } + .controlSize(.small) + } else { + Button("Close") { dismiss() } + .controlSize(.small) + } + } + } + + // MARK: - State-keyed content + + @ViewBuilder + private var content: some View { + switch flowState { + case .idle, .starting: + startingView + case .waitingForApproval(let code, let url): + waitingView(userCode: code, verificationURL: url) + case .success: + successView + case .failure(let reason, let billingURL): + failureView(reason: reason, billingURL: billingURL) + } + } + + // MARK: - .starting + + private var startingView: some View { + VStack(spacing: 12) { + ProgressView() + .controlSize(.large) + Text("Contacting Nous Portal…") + .font(.callout) + .foregroundStyle(.secondary) + Text("This may take a few seconds.") + .font(.caption2) + .foregroundStyle(.tertiary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + // MARK: - .waitingForApproval + + @ViewBuilder + private func waitingView(userCode: String, verificationURL: URL) -> some View { + VStack(alignment: .leading, spacing: 14) { + VStack(alignment: .leading, spacing: 4) { + Text("Approve in your browser") + .font(.headline) + Text("We opened the Nous Portal approval page. Confirm this code matches what it shows, then approve.") + .font(.callout) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + userCodeBadge(userCode) + + HStack(spacing: 12) { + Button { + NSWorkspace.shared.open(verificationURL) + } label: { + Label("Open approval page again", systemImage: "safari") + } + .controlSize(.small) + Spacer() + HStack(spacing: 6) { + ProgressView().controlSize(.small) + Text("Waiting for approval…") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + .padding(.vertical, 4) + } + + private func userCodeBadge(_ code: String) -> some View { + HStack(spacing: 10) { + Text(code) + .font(.system(size: 28, weight: .semibold, design: .monospaced)) + .textSelection(.enabled) + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 8)) + Button { + copyToPasteboard(code) + } label: { + Label("Copy", systemImage: "doc.on.doc") + } + .controlSize(.small) + } + } + + // MARK: - .success + + private var successView: some View { + VStack(spacing: 12) { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + .font(.system(size: 48)) + Text("Signed in to Nous Portal") + .font(.headline) + Text("Your tools will now route through your subscription.") + .font(.callout) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + // MARK: - .failure + + @ViewBuilder + private func failureView(reason: String, billingURL: URL?) -> some View { + VStack(alignment: .leading, spacing: 14) { + HStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + Text(billingURL == nil ? "Sign-in didn't complete" : "Subscription required") + .font(.headline) + } + + Text(reason) + .font(.callout) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + .textSelection(.enabled) + + if let billingURL { + Button { + NSWorkspace.shared.open(billingURL) + } label: { + Label("Subscribe", systemImage: "creditcard") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + } + + HStack(spacing: 10) { + Button("Try again") { flow?.start() } + .buttonStyle(.bordered) + Button("Copy error") { + let payload = (flow?.output.isEmpty == false) ? flow!.output : reason + copyToPasteboard(payload) + } + .buttonStyle(.bordered) + Spacer() + Button("Close") { dismiss() } + } + } + } + + // MARK: - Helpers + + private func copyToPasteboard(_ value: String) { + let pb = NSPasteboard.general + pb.clearContents() + pb.setString(value, forType: .string) + } +} diff --git a/scarf/scarf/Features/Settings/Views/Tabs/AuxiliaryTab.swift b/scarf/scarf/Features/Settings/Views/Tabs/AuxiliaryTab.swift index 5aaff53..7fac5d8 100644 --- a/scarf/scarf/Features/Settings/Views/Tabs/AuxiliaryTab.swift +++ b/scarf/scarf/Features/Settings/Views/Tabs/AuxiliaryTab.swift @@ -13,6 +13,7 @@ struct AuxiliaryTab: View { @Environment(\.serverContext) private var serverContext @State private var subscription: NousSubscriptionState = .absent + @State private var showNousSignIn: Bool = false // Keyed by the config path name — matches `auxiliary..*` in config.yaml. private let tasks: [(key: String, title: LocalizedStringKey, icon: String)] = [ @@ -41,6 +42,11 @@ struct AuxiliaryTab: View { .onAppear { subscription = NousSubscriptionService(context: serverContext).loadState() } + .sheet(isPresented: $showNousSignIn) { + NousSignInSheet { + subscription = NousSubscriptionService(context: serverContext).loadState() + } + } } @ViewBuilder @@ -64,11 +70,17 @@ struct AuxiliaryTab: View { viewModel.setAuxiliary(key, field: "provider", value: wantsOn ? "nous" : "auto") } if !subscription.present && !isOn { - Text("Requires an active Nous Portal subscription. Run `hermes auth` to sign in.") - .font(.caption2) - .foregroundStyle(.tertiary) - .padding(.horizontal, 12) - .padding(.bottom, 4) + HStack(spacing: 8) { + Text("Requires an active Nous Portal subscription.") + .font(.caption2) + .foregroundStyle(.tertiary) + Button("Sign in first") { showNousSignIn = true } + .controlSize(.mini) + .buttonStyle(.borderedProminent) + Spacer() + } + .padding(.horizontal, 12) + .padding(.bottom, 4) } } diff --git a/scarf/scarfTests/CredentialPoolsGatingTests.swift b/scarf/scarfTests/CredentialPoolsGatingTests.swift new file mode 100644 index 0000000..29ccc59 --- /dev/null +++ b/scarf/scarfTests/CredentialPoolsGatingTests.swift @@ -0,0 +1,84 @@ +import Testing +import Foundation +@testable import scarf + +/// Tests that ``CredentialPoolsOAuthGate`` steers each known provider to +/// the right OAuth flow. The regression this prevents: a user hitting the +/// "Start OAuth" button for nous / openai-codex / qwen-oauth / +/// google-gemini-cli / copilot-acp and watching the UI stall silently. +@Suite struct CredentialPoolsGatingTests { + + /// Synthesize a ModelCatalogService over a minimal fixture cache so + /// tests don't depend on the live `~/.hermes/models_dev_cache.json`. + private func makeCatalog() throws -> ModelCatalogService { + let dir = FileManager.default.temporaryDirectory.appendingPathComponent("scarf-cpgate-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + let path = dir.appendingPathComponent("models_dev_cache.json").path + // Include anthropic so the .ok path has a recognizable provider. + let json = """ + { + "anthropic": { + "name": "Anthropic", + "models": { "claude-sonnet-4-5": { "name": "Claude Sonnet 4.5" } } + } + } + """ + try json.write(toFile: path, atomically: true, encoding: .utf8) + return ModelCatalogService(path: path) + } + + @Test func nousRoutesToDedicatedSignInFlow() throws { + let catalog = try makeCatalog() + #expect(CredentialPoolsOAuthGate.resolve(providerID: "nous", catalog: catalog) == .useNousSignIn) + // Whitespace + case insensitivity should also work — users who type + // "Nous " shouldn't fall through to the generic flow. + #expect(CredentialPoolsOAuthGate.resolve(providerID: " Nous ", catalog: catalog) == .useNousSignIn) + } + + @Test func deviceCodeAndExternalProvidersRouteToCLI() throws { + let catalog = try makeCatalog() + // `openai-codex` is .oauthExternal in the overlay table. + if case .useCLI(let provider) = CredentialPoolsOAuthGate.resolve(providerID: "openai-codex", catalog: catalog) { + #expect(provider == "openai-codex") + } else { + Issue.record("openai-codex should route to .useCLI") + } + // `qwen-oauth` is .oauthExternal. + if case .useCLI = CredentialPoolsOAuthGate.resolve(providerID: "qwen-oauth", catalog: catalog) { + // ok + } else { + Issue.record("qwen-oauth should route to .useCLI") + } + // `google-gemini-cli` is .oauthExternal. + if case .useCLI = CredentialPoolsOAuthGate.resolve(providerID: "google-gemini-cli", catalog: catalog) { + // ok + } else { + Issue.record("google-gemini-cli should route to .useCLI") + } + // `copilot-acp` is .externalProcess. + if case .useCLI = CredentialPoolsOAuthGate.resolve(providerID: "copilot-acp", catalog: catalog) { + // ok + } else { + Issue.record("copilot-acp should route to .useCLI") + } + } + + @Test func pkceProvidersPassThroughAsOK() throws { + let catalog = try makeCatalog() + // Anthropic is a standard PKCE provider in Hermes — must not be gated. + #expect(CredentialPoolsOAuthGate.resolve(providerID: "anthropic", catalog: catalog) == .ok) + } + + @Test func unknownProvidersDefaultToOK() throws { + let catalog = try makeCatalog() + // Providers we don't know about shouldn't be blocked — users with + // custom setups need the escape hatch. + #expect(CredentialPoolsOAuthGate.resolve(providerID: "custom-provider-xyz", catalog: catalog) == .ok) + } + + @Test func emptyProviderReturnsProviderEmpty() throws { + let catalog = try makeCatalog() + #expect(CredentialPoolsOAuthGate.resolve(providerID: "", catalog: catalog) == .providerEmpty) + #expect(CredentialPoolsOAuthGate.resolve(providerID: " ", catalog: catalog) == .providerEmpty) + } +} diff --git a/scarf/scarfTests/NousAuthFlowTests.swift b/scarf/scarfTests/NousAuthFlowTests.swift new file mode 100644 index 0000000..1cf0b66 --- /dev/null +++ b/scarf/scarfTests/NousAuthFlowTests.swift @@ -0,0 +1,109 @@ +import Testing +import Foundation +@testable import scarf + +/// Unit tests for the pure parsers in ``NousAuthFlow``. The subprocess side +/// of the flow is covered by manual end-to-end testing against a live +/// hermes install — parser behavior is what we can pin here. +@Suite struct NousAuthFlowParserTests { + + // MARK: - Device-code block + + @Test func parsesVerificationURLAndUserCode() throws { + let text = """ + Requesting device code from Nous Portal... + + To continue: + 1. Open: https://portal.nousresearch.com/device/ABCD-EFGH + 2. If prompted, enter code: ABCD-EFGH + Waiting for approval (polling every 1s)... + """ + let result = try #require(NousAuthFlow.parseDeviceCode(from: text)) + #expect(result.verificationURL.absoluteString == "https://portal.nousresearch.com/device/ABCD-EFGH") + #expect(result.userCode == "ABCD-EFGH") + } + + @Test func ignoresNoiseBetweenExpectedLines() throws { + // Hermes may log unrelated diagnostics between or after the two + // expected lines. The parser anchors on line-start regex so noise + // above, below, or even intermixed shouldn't block it. + let text = """ + [DEBUG] some internal log line + To continue: + 1. Open: https://portal.nousresearch.com/device/WXYZ-1234 + [DEBUG] another log line + 2. If prompted, enter code: WXYZ-1234 + extra trailing noise + """ + let result = try #require(NousAuthFlow.parseDeviceCode(from: text)) + #expect(result.userCode == "WXYZ-1234") + } + + @Test func returnsNilWhenUserCodeLineMissing() { + let text = """ + To continue: + 1. Open: https://portal.nousresearch.com/device/AAAA-AAAA + Waiting for approval... + """ + #expect(NousAuthFlow.parseDeviceCode(from: text) == nil) + } + + @Test func returnsNilWhenURLLineMissing() { + let text = """ + To continue: + 2. If prompted, enter code: BBBB-BBBB + """ + #expect(NousAuthFlow.parseDeviceCode(from: text) == nil) + } + + @Test func returnsNilOnEmptyInput() { + #expect(NousAuthFlow.parseDeviceCode(from: "") == nil) + } + + // MARK: - Subscription-required failure + + @Test func parsesSubscriptionRequiredBillingURL() throws { + let text = """ + Login successful! + + Your Nous Portal account does not have an active subscription. + Subscribe here: https://portal.nousresearch.com/billing + + After subscribing, run `hermes model` again to finish setup. + """ + let url = try #require(NousAuthFlow.parseSubscriptionRequired(from: text)) + #expect(url.absoluteString == "https://portal.nousresearch.com/billing") + } + + @Test func subscriptionRequiredReturnsNilWithoutMarker() { + let text = """ + hermes: something else went wrong + Subscribe here: https://example.com/billing + """ + // The "Subscribe here:" URL alone isn't enough — we require the + // specific subscription-required sentinel so we don't misclassify + // unrelated errors as subscription failures. + #expect(NousAuthFlow.parseSubscriptionRequired(from: text) == nil) + } + + @Test func subscriptionRequiredReturnsNilWhenBillingURLMissing() { + let text = """ + Your Nous Portal account does not have an active subscription. + (no subscribe here line) + """ + #expect(NousAuthFlow.parseSubscriptionRequired(from: text) == nil) + } + + // MARK: - State equality + + @Test func stateEnumEquatableDistinguishesCases() { + let u = URL(string: "https://example.com")! + let a: NousAuthFlow.State = .waitingForApproval(userCode: "X", verificationURL: u) + let b: NousAuthFlow.State = .waitingForApproval(userCode: "X", verificationURL: u) + let c: NousAuthFlow.State = .waitingForApproval(userCode: "Y", verificationURL: u) + #expect(a == b) + #expect(a != c) + #expect(NousAuthFlow.State.idle != NousAuthFlow.State.starting) + #expect(NousAuthFlow.State.success != NousAuthFlow.State.failure(reason: "", billingURL: nil)) + } +}