mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 02:26:37 +00:00
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:
@@ -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 {
|
||||
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)
|
||||
.disabled(providerID.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
}
|
||||
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,9 +70,15 @@ 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.")
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user