mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +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 providers: [HermesProviderInfo] = []
|
||||||
@State private var oauthStarted: Bool = false
|
@State private var oauthStarted: Bool = false
|
||||||
@State private var authCode: String = ""
|
@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 var catalog: ModelCatalogService { ModelCatalogService(context: viewModel.context) }
|
||||||
|
|
||||||
|
private func oauthGate(for rawID: String) -> CredentialPoolsOAuthGate {
|
||||||
|
CredentialPoolsOAuthGate.resolve(providerID: rawID, catalog: catalog)
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
Text("Add Credential")
|
Text("Add Credential")
|
||||||
@@ -240,6 +249,17 @@ private struct AddCredentialSheet: View {
|
|||||||
onDismiss()
|
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
|
// MARK: - Step 1: provider + type + label + optional API key
|
||||||
@@ -290,11 +310,57 @@ private struct AddCredentialSheet: View {
|
|||||||
.font(.system(.caption, design: .monospaced))
|
.font(.system(.caption, design: .monospaced))
|
||||||
}
|
}
|
||||||
} else {
|
} 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
|
/// Brief explanation shown before the user clicks "Start OAuth". Sets
|
||||||
/// expectations about the embedded-terminal flow so the browser window
|
/// expectations about the embedded-terminal flow so the browser window
|
||||||
/// and code-paste step aren't surprises.
|
/// and code-paste step aren't surprises.
|
||||||
@@ -476,14 +542,38 @@ private struct AddCredentialSheet: View {
|
|||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.borderedProminent)
|
||||||
.disabled(providerID.trimmingCharacters(in: .whitespaces).isEmpty || apiKey.trimmingCharacters(in: .whitespaces).isEmpty)
|
.disabled(providerID.trimmingCharacters(in: .whitespaces).isEmpty || apiKey.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||||
} else {
|
} else {
|
||||||
Button("Start OAuth") {
|
oauthActionButton
|
||||||
viewModel.startOAuth(provider: providerID, label: label)
|
|
||||||
oauthStarted = true
|
|
||||||
}
|
|
||||||
.buttonStyle(.borderedProminent)
|
|
||||||
.disabled(providerID.trimmingCharacters(in: .whitespaces).isEmpty)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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.
|
// appear; stays in-memory for the life of the sheet.
|
||||||
@State private var subscription: NousSubscriptionState = .absent
|
@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
|
@Environment(\.serverContext) private var serverContext
|
||||||
private var catalog: ModelCatalogService { ModelCatalogService(context: serverContext) }
|
private var catalog: ModelCatalogService { ModelCatalogService(context: serverContext) }
|
||||||
private var subscriptionService: NousSubscriptionService { NousSubscriptionService(context: serverContext) }
|
private var subscriptionService: NousSubscriptionService { NousSubscriptionService(context: serverContext) }
|
||||||
@@ -63,6 +67,14 @@ struct ModelPickerSheet: View {
|
|||||||
subscription = subscriptionService.loadState()
|
subscription = subscriptionService.loadState()
|
||||||
loadModelsForSelection()
|
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 {
|
private var header: some View {
|
||||||
@@ -242,11 +254,21 @@ struct ModelPickerSheet: View {
|
|||||||
Text("Signed in to Nous, but another provider is active.")
|
Text("Signed in to Nous, but another provider is active.")
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
} else {
|
} else {
|
||||||
Text("Not signed in. Run `hermes auth` and select Nous Portal.")
|
Text("Not signed in yet.")
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.font(.callout)
|
.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
|
@Environment(\.serverContext) private var serverContext
|
||||||
@State private var subscription: NousSubscriptionState = .absent
|
@State private var subscription: NousSubscriptionState = .absent
|
||||||
|
@State private var showNousSignIn: Bool = false
|
||||||
|
|
||||||
// Keyed by the config path name — matches `auxiliary.<task>.*` in config.yaml.
|
// Keyed by the config path name — matches `auxiliary.<task>.*` in config.yaml.
|
||||||
private let tasks: [(key: String, title: LocalizedStringKey, icon: String)] = [
|
private let tasks: [(key: String, title: LocalizedStringKey, icon: String)] = [
|
||||||
@@ -41,6 +42,11 @@ struct AuxiliaryTab: View {
|
|||||||
.onAppear {
|
.onAppear {
|
||||||
subscription = NousSubscriptionService(context: serverContext).loadState()
|
subscription = NousSubscriptionService(context: serverContext).loadState()
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $showNousSignIn) {
|
||||||
|
NousSignInSheet {
|
||||||
|
subscription = NousSubscriptionService(context: serverContext).loadState()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
@@ -64,11 +70,17 @@ struct AuxiliaryTab: View {
|
|||||||
viewModel.setAuxiliary(key, field: "provider", value: wantsOn ? "nous" : "auto")
|
viewModel.setAuxiliary(key, field: "provider", value: wantsOn ? "nous" : "auto")
|
||||||
}
|
}
|
||||||
if !subscription.present && !isOn {
|
if !subscription.present && !isOn {
|
||||||
Text("Requires an active Nous Portal subscription. Run `hermes auth` to sign in.")
|
HStack(spacing: 8) {
|
||||||
.font(.caption2)
|
Text("Requires an active Nous Portal subscription.")
|
||||||
.foregroundStyle(.tertiary)
|
.font(.caption2)
|
||||||
.padding(.horizontal, 12)
|
.foregroundStyle(.tertiary)
|
||||||
.padding(.bottom, 4)
|
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