mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +00:00
257772e2d1
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>
110 lines
4.1 KiB
Swift
110 lines
4.1 KiB
Swift
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))
|
|
}
|
|
}
|