feat(nous): in-app sign-in + credential pools auth-type gating

The Tool Gateway feature shipped the Nous Portal provider in Scarf's
picker, a subscription-state detector, and a per-task aux toggle — but
there was no way to actually sign in. `hermes auth` in a terminal took
six steps, and Credential Pools' "Start OAuth" button silently stalled
for `nous` because it tried to run the PKCE flow against a device-code
provider.

Changes:

- NousAuthFlow: new @Observable MainActor service that spawns
  `hermes auth add nous --no-browser`, parses the device-code block
  (verification_uri_complete + user_code) with two line-anchored
  regexes, opens the verification URL via NSWorkspace.shared.open,
  and confirms success by re-reading auth.json via
  NousSubscriptionService. Detects the `subscription_required`
  failure and extracts the billing URL so the UI can offer a
  Subscribe link.
- NousSignInSheet: four-state sheet (starting / waitingForApproval /
  success / failure). Shows the user code in a large monospaced
  badge with Copy + re-open-browser affordances, auto-dismisses
  1.2s after success, Subscribe + Try again + Copy error buttons
  on failure.
- Wired three entry points (per user-approved plan):
    1. ModelPickerSheet's Nous Portal subscription summary — replaces
       the stale "Run hermes auth" caption with a primary
       "Sign in to Nous Portal" button.
    2. AuxiliaryTab's per-task Nous toggle — inline "Sign in first"
       button when not subscribed, instead of a dead-end caption.
    3. Credential Pools "Add Credential" sheet — when provider is
       `nous`, replaces the broken Start OAuth button with
       "Sign in to Nous Portal".
- CredentialPoolsOAuthGate: testable helper that routes provider IDs
  to the right OAuth flow based on the overlay table. Closes the
  silent-fail dead-end for openai-codex, qwen-oauth,
  google-gemini-cli, and copilot-acp too — disables the generic
  button with an inline "run hermes auth add <provider> in a
  terminal" hint. PKCE providers (anthropic, etc.) and unknown
  providers still pass through as `.ok` — this gate is strictly
  additive.

Tests: 14 new tests across two suites (NousAuthFlowParserTests,
CredentialPoolsGatingTests). Full suite 120/120 green on top of
v2.3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-04-24 02:49:08 +02:00
parent 115bc16b14
commit 257772e2d1
8 changed files with 868 additions and 13 deletions
@@ -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)
}
}
@@ -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
/// <provider>` 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
}
}
}
@@ -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)
}
}
}
@@ -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)
}
}
}
@@ -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<Void, Never>?
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)
}
}
@@ -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.<task>.*` 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)
}
}
@@ -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)
}
}
+109
View File
@@ -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))
}
}