mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +00:00
fix(chat): surface OAuth refresh-revoked errors with in-app re-auth
When an OAuth provider's refresh token was revoked, Hermes printed "Refresh session has been revoked. Run `hermes model` to re-authenticate." to stderr but Scarf swallowed it — the user saw a typing indicator that silently disappeared with no banner, no system message, no actionable hint. The error classifier had no pattern for OAuth revocation. - `ACPErrorHint.classify` now returns a `Classification` struct carrying the hint plus an optional `oauthProvider` name. New patterns match "Refresh session has been revoked", "re-authenticate", and 401-with-OAuth-provider-name (whole-word so `anthropicapi` doesn't false-match `anthropic`). Provider extraction lets the UI dispatch the right re-auth flow. - Chat error banner ([ChatView.swift]) gains a "Re-authenticate" button when an OAuth provider was identified — sets `AppCoordinator.pendingOAuthReauth` and routes to Credential Pools. - Credential Pools view consumes the hand-off slot to auto-present AddCredentialSheet seeded with the affected provider, AND adds a per-row "Re-authenticate" button on every OAuth provider so users who go straight there don't have to retype the provider name. - `AddCredentialSheet` accepts an optional `initialProvider` that pre-fills providerID + authType=.oauth; the existing Nous-vs-PKCE- vs-CLI gate dispatches re-auth identically to first-time setup — reuses the same `OAuthFlowController` / `NousSignInSheet` plumbing, no new flow code. Verification: ScarfCore 221/221 (incl. new errorHintsClassifyOAuthRefreshRevoked covering the four patterns + word-boundary guard); Mac app builds clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -593,7 +593,30 @@ public enum ACPClientError: Error, LocalizedError {
|
|||||||
/// human-readable hint for the chat UI. Pattern-matches the most common
|
/// human-readable hint for the chat UI. Pattern-matches the most common
|
||||||
/// fresh-install failure modes. Returns nil when no known pattern matches.
|
/// fresh-install failure modes. Returns nil when no known pattern matches.
|
||||||
public enum ACPErrorHint {
|
public enum ACPErrorHint {
|
||||||
public static func classify(errorMessage: String, stderrTail: String) -> String? {
|
/// Result of a classifier hit. `hint` is the user-facing copy; when
|
||||||
|
/// the failure is an OAuth refresh-revocation, `oauthProvider` names
|
||||||
|
/// the affected provider (lowercase, matching `auth.json` keys) so
|
||||||
|
/// the UI can offer a one-click re-authenticate affordance. `nil`
|
||||||
|
/// `oauthProvider` means "we matched a non-OAuth failure mode, or
|
||||||
|
/// we matched OAuth but couldn't identify which provider."
|
||||||
|
public struct Classification: Sendable, Equatable {
|
||||||
|
public let hint: String
|
||||||
|
public let oauthProvider: String?
|
||||||
|
|
||||||
|
public init(hint: String, oauthProvider: String? = nil) {
|
||||||
|
self.hint = hint
|
||||||
|
self.oauthProvider = oauthProvider
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Known OAuth-authed providers Hermes ships. Listed lowercase to
|
||||||
|
/// match `auth.json.providers.<key>` and the values
|
||||||
|
/// `OAuthFlowController.start(provider:)` accepts.
|
||||||
|
private static let oauthProviders = [
|
||||||
|
"nous", "claude", "anthropic", "qwen", "gemini", "google", "copilot", "github",
|
||||||
|
]
|
||||||
|
|
||||||
|
public static func classify(errorMessage: String, stderrTail: String) -> Classification? {
|
||||||
let haystack = errorMessage + "\n" + stderrTail
|
let haystack = errorMessage + "\n" + stderrTail
|
||||||
|
|
||||||
// SSH-level failures come first — they apply only to remote
|
// SSH-level failures come first — they apply only to remote
|
||||||
@@ -603,30 +626,55 @@ public enum ACPErrorHint {
|
|||||||
// all surface as opaque "ACP process terminated" / "request
|
// all surface as opaque "ACP process terminated" / "request
|
||||||
// timed out", and the user has no idea where to look.
|
// timed out", and the user has no idea where to look.
|
||||||
if haystack.contains("Connection refused") {
|
if haystack.contains("Connection refused") {
|
||||||
return "Couldn't reach the remote host — the SSH port is closed or the droplet is down. Check the host is running and reachable."
|
return Classification(hint: "Couldn't reach the remote host — the SSH port is closed or the droplet is down. Check the host is running and reachable.")
|
||||||
}
|
}
|
||||||
if haystack.localizedCaseInsensitiveContains("Operation timed out")
|
if haystack.localizedCaseInsensitiveContains("Operation timed out")
|
||||||
|| haystack.localizedCaseInsensitiveContains("Connection timed out")
|
|| haystack.localizedCaseInsensitiveContains("Connection timed out")
|
||||||
|| haystack.contains("Network is unreachable")
|
|| haystack.contains("Network is unreachable")
|
||||||
|| haystack.contains("No route to host") {
|
|| haystack.contains("No route to host") {
|
||||||
return "Couldn't reach the remote host — the network connection timed out. Check the host is running and your network is up."
|
return Classification(hint: "Couldn't reach the remote host — the network connection timed out. Check the host is running and your network is up.")
|
||||||
}
|
}
|
||||||
if haystack.contains("Permission denied (publickey")
|
if haystack.contains("Permission denied (publickey")
|
||||||
|| haystack.contains("Permission denied, please try again") {
|
|| haystack.contains("Permission denied, please try again") {
|
||||||
return "SSH rejected the key. Make sure the right identity file is selected and that ssh-agent has the key loaded — open Terminal and run `ssh-add -l`."
|
return Classification(hint: "SSH rejected the key. Make sure the right identity file is selected and that ssh-agent has the key loaded — open Terminal and run `ssh-add -l`.")
|
||||||
}
|
}
|
||||||
if haystack.contains("Host key verification failed")
|
if haystack.contains("Host key verification failed")
|
||||||
|| haystack.contains("REMOTE HOST IDENTIFICATION HAS CHANGED") {
|
|| haystack.contains("REMOTE HOST IDENTIFICATION HAS CHANGED") {
|
||||||
return "The remote host's SSH key changed. If you just rebuilt the droplet, remove the old entry with `ssh-keygen -R <host>`, then try again."
|
return Classification(hint: "The remote host's SSH key changed. If you just rebuilt the droplet, remove the old entry with `ssh-keygen -R <host>`, then try again.")
|
||||||
}
|
}
|
||||||
if haystack.contains("Could not resolve hostname")
|
if haystack.contains("Could not resolve hostname")
|
||||||
|| haystack.contains("Name or service not known") {
|
|| haystack.contains("Name or service not known") {
|
||||||
return "Couldn't resolve the host name. Check the host in this server's settings."
|
return Classification(hint: "Couldn't resolve the host name. Check the host in this server's settings.")
|
||||||
}
|
}
|
||||||
if haystack.localizedCaseInsensitiveContains("command not found")
|
if haystack.localizedCaseInsensitiveContains("command not found")
|
||||||
|| haystack.contains("hermes: not found")
|
|| haystack.contains("hermes: not found")
|
||||||
|| haystack.contains("exit 127") {
|
|| haystack.contains("exit 127") {
|
||||||
return "The remote shell couldn't find `hermes`. Either install Hermes on the remote (`pipx install hermes-agent`) or set an absolute binary path in this server's settings."
|
return Classification(hint: "The remote shell couldn't find `hermes`. Either install Hermes on the remote (`pipx install hermes-agent`) or set an absolute binary path in this server's settings.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// OAuth refresh-token revocation. Hermes prints
|
||||||
|
// "Refresh session has been revoked. Run `hermes model` to
|
||||||
|
// re-authenticate." to stderr/stdout when an OAuth-authed
|
||||||
|
// provider's refresh token can no longer mint access tokens
|
||||||
|
// (user revoked, server rotated keys, etc.). We can't drive
|
||||||
|
// `hermes model` interactively, but `hermes auth add <provider>
|
||||||
|
// --type oauth` is the same code path Scarf already drives via
|
||||||
|
// `OAuthFlowController` for first-time setup, so we surface a
|
||||||
|
// re-authenticate affordance instead. Checked BEFORE the
|
||||||
|
// generic "no credentials found" path because the message
|
||||||
|
// contains the word "credentials" via the surrounding context.
|
||||||
|
if haystack.localizedCaseInsensitiveContains("refresh session has been revoked")
|
||||||
|
|| haystack.range(of: #"refresh.*revoked"#, options: [.regularExpression, .caseInsensitive]) != nil
|
||||||
|
|| haystack.localizedCaseInsensitiveContains("re-authenticate")
|
||||||
|
|| haystack.localizedCaseInsensitiveContains("reauthenticate")
|
||||||
|
|| (haystack.contains("401") && oauthProvider(in: haystack) != nil)
|
||||||
|
|| (haystack.localizedCaseInsensitiveContains("unauthorized") && oauthProvider(in: haystack) != nil) {
|
||||||
|
let provider = oauthProvider(in: haystack)
|
||||||
|
let suffix = provider.map { " (affected provider: \($0))." } ?? "."
|
||||||
|
return Classification(
|
||||||
|
hint: "Your OAuth session has expired or been revoked\(suffix) Click Re-authenticate below to sign in again.",
|
||||||
|
oauthProvider: provider
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if haystack.range(of: #"No\s+(Anthropic|OpenAI|OpenRouter|Gemini|Google|Groq|Mistral|XAI)?\s*credentials\s+found"#,
|
if haystack.range(of: #"No\s+(Anthropic|OpenAI|OpenRouter|Gemini|Google|Groq|Mistral|XAI)?\s*credentials\s+found"#,
|
||||||
@@ -635,7 +683,7 @@ public enum ACPErrorHint {
|
|||||||
|| haystack.contains("ANTHROPIC_TOKEN")
|
|| haystack.contains("ANTHROPIC_TOKEN")
|
||||||
|| haystack.contains("claude setup-token")
|
|| haystack.contains("claude setup-token")
|
||||||
|| haystack.contains("claude /login") {
|
|| haystack.contains("claude /login") {
|
||||||
return "Hermes can't find your AI provider credentials. Set `ANTHROPIC_API_KEY` (or similar) in `~/.hermes/.env` or your shell profile, then restart Scarf."
|
return Classification(hint: "Hermes can't find your AI provider credentials. Set `ANTHROPIC_API_KEY` (or similar) in `~/.hermes/.env` or your shell profile, then restart Scarf.")
|
||||||
}
|
}
|
||||||
if let match = haystack.range(of: #"No such file or directory:\s*'([^']+)'"#,
|
if let match = haystack.range(of: #"No such file or directory:\s*'([^']+)'"#,
|
||||||
options: .regularExpression) {
|
options: .regularExpression) {
|
||||||
@@ -643,13 +691,31 @@ public enum ACPErrorHint {
|
|||||||
if let nameStart = matched.range(of: "'"),
|
if let nameStart = matched.range(of: "'"),
|
||||||
let nameEnd = matched.range(of: "'", range: nameStart.upperBound..<matched.endIndex) {
|
let nameEnd = matched.range(of: "'", range: nameStart.upperBound..<matched.endIndex) {
|
||||||
let name = String(matched[nameStart.upperBound..<nameEnd.lowerBound])
|
let name = String(matched[nameStart.upperBound..<nameEnd.lowerBound])
|
||||||
return "Hermes couldn't find `\(name)` on PATH. If you use nvm/asdf/mise, make sure it's exported in `~/.zprofile` (not only `~/.zshrc`), then restart Scarf."
|
return Classification(hint: "Hermes couldn't find `\(name)` on PATH. If you use nvm/asdf/mise, make sure it's exported in `~/.zprofile` (not only `~/.zshrc`), then restart Scarf.")
|
||||||
}
|
}
|
||||||
return "Hermes couldn't find a required binary on PATH. Check that your shell's PATH is exported in `~/.zprofile`, then restart Scarf."
|
return Classification(hint: "Hermes couldn't find a required binary on PATH. Check that your shell's PATH is exported in `~/.zprofile`, then restart Scarf.")
|
||||||
}
|
}
|
||||||
if haystack.localizedCaseInsensitiveContains("rate limit")
|
if haystack.localizedCaseInsensitiveContains("rate limit")
|
||||||
|| haystack.localizedCaseInsensitiveContains("429") {
|
|| haystack.localizedCaseInsensitiveContains("429") {
|
||||||
return "Your AI provider returned a rate-limit error. Try again in a moment."
|
return Classification(hint: "Your AI provider returned a rate-limit error. Try again in a moment.")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Best-effort extraction of an OAuth provider name from raw error
|
||||||
|
/// text. Returns the lowercase provider key (`"nous"`, `"claude"`,
|
||||||
|
/// etc.) when one of the known OAuth providers appears as a whole
|
||||||
|
/// word. The first match wins — Hermes typically logs the active
|
||||||
|
/// provider name once, near the failure.
|
||||||
|
private static func oauthProvider(in haystack: String) -> String? {
|
||||||
|
let lowered = haystack.lowercased()
|
||||||
|
for provider in oauthProviders {
|
||||||
|
// Whole-word match so substrings like "anthropicapi" don't
|
||||||
|
// false-trigger on "anthropic".
|
||||||
|
let pattern = "\\b" + NSRegularExpression.escapedPattern(for: provider) + "\\b"
|
||||||
|
if lowered.range(of: pattern, options: .regularExpression) != nil {
|
||||||
|
return provider
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,6 +120,12 @@ public final class RichChatViewModel {
|
|||||||
/// users can copy-paste the raw output into a bug report.
|
/// users can copy-paste the raw output into a bug report.
|
||||||
public var acpErrorDetails: String?
|
public var acpErrorDetails: String?
|
||||||
|
|
||||||
|
/// Lowercase OAuth provider name (`"nous"`, `"claude"`, …) when the
|
||||||
|
/// most recent failure was an OAuth refresh-revocation Hermes asked
|
||||||
|
/// the user to fix via re-authentication. Drives the chat banner's
|
||||||
|
/// "Re-authenticate" button. Nil for any other failure mode.
|
||||||
|
public var acpErrorOAuthProvider: String?
|
||||||
|
|
||||||
/// Optional stderr-tail provider the controller can hook up when it
|
/// Optional stderr-tail provider the controller can hook up when it
|
||||||
/// creates the ACPClient. Used by `handlePromptComplete` to enrich
|
/// creates the ACPClient. Used by `handlePromptComplete` to enrich
|
||||||
/// the error banner on non-retryable stopReasons. The closure is
|
/// the error banner on non-retryable stopReasons. The closure is
|
||||||
@@ -134,6 +140,7 @@ public final class RichChatViewModel {
|
|||||||
acpError = nil
|
acpError = nil
|
||||||
acpErrorHint = nil
|
acpErrorHint = nil
|
||||||
acpErrorDetails = nil
|
acpErrorDetails = nil
|
||||||
|
acpErrorOAuthProvider = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Populate the error triplet from a thrown Error + the ACPClient
|
/// Populate the error triplet from a thrown Error + the ACPClient
|
||||||
@@ -154,10 +161,11 @@ public final class RichChatViewModel {
|
|||||||
}
|
}
|
||||||
let msg = error.localizedDescription
|
let msg = error.localizedDescription
|
||||||
let stderrTail = await client?.recentStderr ?? ""
|
let stderrTail = await client?.recentStderr ?? ""
|
||||||
let hint = ACPErrorHint.classify(errorMessage: msg, stderrTail: stderrTail)
|
let cls = ACPErrorHint.classify(errorMessage: msg, stderrTail: stderrTail)
|
||||||
acpError = msg
|
acpError = msg
|
||||||
acpErrorHint = hint
|
acpErrorHint = cls?.hint
|
||||||
acpErrorDetails = stderrTail.isEmpty ? nil : stderrTail
|
acpErrorDetails = stderrTail.isEmpty ? nil : stderrTail
|
||||||
|
acpErrorOAuthProvider = cls?.oauthProvider
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Populate the error triplet when `handlePromptComplete` sees a
|
/// Populate the error triplet when `handlePromptComplete` sees a
|
||||||
@@ -168,11 +176,11 @@ public final class RichChatViewModel {
|
|||||||
public func recordPromptStopFailure(stopReason: String, client: ACPClient?) async {
|
public func recordPromptStopFailure(stopReason: String, client: ACPClient?) async {
|
||||||
let msg = "Prompt ended without a response (stopReason: \(stopReason))."
|
let msg = "Prompt ended without a response (stopReason: \(stopReason))."
|
||||||
let stderrTail = await client?.recentStderr ?? ""
|
let stderrTail = await client?.recentStderr ?? ""
|
||||||
let hint = ACPErrorHint.classify(errorMessage: msg, stderrTail: stderrTail)
|
let cls = ACPErrorHint.classify(errorMessage: msg, stderrTail: stderrTail)
|
||||||
?? Self.fallbackHint(for: stopReason)
|
|
||||||
acpError = msg
|
acpError = msg
|
||||||
acpErrorHint = hint
|
acpErrorHint = cls?.hint ?? Self.fallbackHint(for: stopReason)
|
||||||
acpErrorDetails = stderrTail.isEmpty ? nil : stderrTail
|
acpErrorDetails = stderrTail.isEmpty ? nil : stderrTail
|
||||||
|
acpErrorOAuthProvider = cls?.oauthProvider
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Same as `recordPromptStopFailure` but pulls stderr from the
|
/// Same as `recordPromptStopFailure` but pulls stderr from the
|
||||||
@@ -182,11 +190,11 @@ public final class RichChatViewModel {
|
|||||||
private func recordPromptStopFailureUsingProvider(stopReason: String) async {
|
private func recordPromptStopFailureUsingProvider(stopReason: String) async {
|
||||||
let msg = "Prompt ended without a response (stopReason: \(stopReason))."
|
let msg = "Prompt ended without a response (stopReason: \(stopReason))."
|
||||||
let stderrTail = await acpStderrProvider?() ?? ""
|
let stderrTail = await acpStderrProvider?() ?? ""
|
||||||
let hint = ACPErrorHint.classify(errorMessage: msg, stderrTail: stderrTail)
|
let cls = ACPErrorHint.classify(errorMessage: msg, stderrTail: stderrTail)
|
||||||
?? Self.fallbackHint(for: stopReason)
|
|
||||||
acpError = msg
|
acpError = msg
|
||||||
acpErrorHint = hint
|
acpErrorHint = cls?.hint ?? Self.fallbackHint(for: stopReason)
|
||||||
acpErrorDetails = stderrTail.isEmpty ? nil : stderrTail
|
acpErrorDetails = stderrTail.isEmpty ? nil : stderrTail
|
||||||
|
acpErrorOAuthProvider = cls?.oauthProvider
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func fallbackHint(for stopReason: String) -> String? {
|
private static func fallbackHint(for stopReason: String) -> String? {
|
||||||
|
|||||||
@@ -265,19 +265,20 @@ import Foundation
|
|||||||
errorMessage: "No Anthropic credentials found",
|
errorMessage: "No Anthropic credentials found",
|
||||||
stderrTail: ""
|
stderrTail: ""
|
||||||
)
|
)
|
||||||
#expect(noCreds?.contains("ANTHROPIC_API_KEY") == true)
|
#expect(noCreds?.hint.contains("ANTHROPIC_API_KEY") == true)
|
||||||
|
#expect(noCreds?.oauthProvider == nil)
|
||||||
|
|
||||||
let missingBinary = ACPErrorHint.classify(
|
let missingBinary = ACPErrorHint.classify(
|
||||||
errorMessage: "",
|
errorMessage: "",
|
||||||
stderrTail: "No such file or directory: 'npx'"
|
stderrTail: "No such file or directory: 'npx'"
|
||||||
)
|
)
|
||||||
#expect(missingBinary?.contains("npx") == true)
|
#expect(missingBinary?.hint.contains("npx") == true)
|
||||||
|
|
||||||
let rateLimit = ACPErrorHint.classify(
|
let rateLimit = ACPErrorHint.classify(
|
||||||
errorMessage: "",
|
errorMessage: "",
|
||||||
stderrTail: "HTTP 429 Too Many Requests: rate limit"
|
stderrTail: "HTTP 429 Too Many Requests: rate limit"
|
||||||
)
|
)
|
||||||
#expect(rateLimit?.contains("rate-limit") == true)
|
#expect(rateLimit?.hint.contains("rate-limit") == true)
|
||||||
|
|
||||||
let unknown = ACPErrorHint.classify(
|
let unknown = ACPErrorHint.classify(
|
||||||
errorMessage: "weird thing",
|
errorMessage: "weird thing",
|
||||||
@@ -286,6 +287,53 @@ import Foundation
|
|||||||
#expect(unknown == nil)
|
#expect(unknown == nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test func errorHintsClassifyOAuthRefreshRevoked() {
|
||||||
|
// Primary trigger — Hermes's verbatim message when an OAuth
|
||||||
|
// refresh token can't mint a new access token. Provider name
|
||||||
|
// appears alongside; classifier should extract it.
|
||||||
|
let revoked = ACPErrorHint.classify(
|
||||||
|
errorMessage: "",
|
||||||
|
stderrTail: "Refresh session has been revoked. Run `hermes model` to re-authenticate."
|
||||||
|
)
|
||||||
|
#expect(revoked?.hint.contains("Re-authenticate") == true)
|
||||||
|
|
||||||
|
// With provider context — surfaces the affected provider name
|
||||||
|
// so the chat banner can offer a one-click re-auth that targets
|
||||||
|
// the right OAuth flow.
|
||||||
|
let revokedWithProvider = ACPErrorHint.classify(
|
||||||
|
errorMessage: "",
|
||||||
|
stderrTail: "Provider claude: Refresh session has been revoked. Run `hermes model` to re-authenticate."
|
||||||
|
)
|
||||||
|
#expect(revokedWithProvider?.oauthProvider == "claude")
|
||||||
|
|
||||||
|
// 401 + OAuth provider name — broader catchall for providers
|
||||||
|
// that don't print the verbatim "revoked" string.
|
||||||
|
let unauthorized = ACPErrorHint.classify(
|
||||||
|
errorMessage: "",
|
||||||
|
stderrTail: "HTTP 401 Unauthorized from nous portal"
|
||||||
|
)
|
||||||
|
#expect(unauthorized?.oauthProvider == "nous")
|
||||||
|
#expect(unauthorized?.hint.contains("OAuth") == true)
|
||||||
|
|
||||||
|
// Unauthorized on a non-OAuth provider (API-key based) should
|
||||||
|
// NOT classify as OAuth revocation — no `oauthProvider` known
|
||||||
|
// to dispatch the re-auth flow against.
|
||||||
|
let unauthorizedNonOAuth = ACPErrorHint.classify(
|
||||||
|
errorMessage: "",
|
||||||
|
stderrTail: "HTTP 401 Unauthorized for groq"
|
||||||
|
)
|
||||||
|
#expect(unauthorizedNonOAuth?.oauthProvider == nil)
|
||||||
|
|
||||||
|
// Word-boundary check — "anthropicapi" must not false-trigger
|
||||||
|
// on "anthropic". Without word boundaries this catches the
|
||||||
|
// wrong cases.
|
||||||
|
let substringNoMatch = ACPErrorHint.classify(
|
||||||
|
errorMessage: "",
|
||||||
|
stderrTail: "401 unauthorized: anthropicapi.example.com"
|
||||||
|
)
|
||||||
|
#expect(substringNoMatch?.oauthProvider != "anthropic")
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Helpers
|
// MARK: - Helpers
|
||||||
|
|
||||||
/// Poll `predicate` every ~20ms up to `timeout` seconds. Fails if
|
/// Poll `predicate` every ~20ms up to `timeout` seconds. Fails if
|
||||||
|
|||||||
@@ -139,6 +139,10 @@ final class ChatViewModel {
|
|||||||
get { richChatViewModel.acpErrorDetails }
|
get { richChatViewModel.acpErrorDetails }
|
||||||
set { richChatViewModel.acpErrorDetails = newValue }
|
set { richChatViewModel.acpErrorDetails = newValue }
|
||||||
}
|
}
|
||||||
|
var acpErrorOAuthProvider: String? {
|
||||||
|
get { richChatViewModel.acpErrorOAuthProvider }
|
||||||
|
set { richChatViewModel.acpErrorOAuthProvider = newValue }
|
||||||
|
}
|
||||||
/// True when `hasAnyAICredential()` returned false at last preflight.
|
/// True when `hasAnyAICredential()` returned false at last preflight.
|
||||||
var missingCredentials: Bool = false
|
var missingCredentials: Bool = false
|
||||||
|
|
||||||
|
|||||||
@@ -116,6 +116,15 @@ struct ChatView: View {
|
|||||||
.lineLimit(showErrorDetails ? nil : 2)
|
.lineLimit(showErrorDetails ? nil : 2)
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
|
if let provider = viewModel.acpErrorOAuthProvider {
|
||||||
|
Button("Re-authenticate") {
|
||||||
|
coordinator.pendingOAuthReauth = provider
|
||||||
|
coordinator.selectedSection = .credentialPools
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.controlSize(.small)
|
||||||
|
.help("Open Credential Pools and re-authenticate \(provider).")
|
||||||
|
}
|
||||||
if viewModel.acpErrorDetails != nil {
|
if viewModel.acpErrorDetails != nil {
|
||||||
Button(showErrorDetails ? "Hide details" : "Show details") {
|
Button(showErrorDetails ? "Hide details" : "Show details") {
|
||||||
showErrorDetails.toggle()
|
showErrorDetails.toggle()
|
||||||
|
|||||||
@@ -6,6 +6,14 @@ struct CredentialPoolsView: View {
|
|||||||
@State private var viewModel: CredentialPoolsViewModel
|
@State private var viewModel: CredentialPoolsViewModel
|
||||||
@State private var showAddSheet = false
|
@State private var showAddSheet = false
|
||||||
@State private var pendingRemove: HermesCredential?
|
@State private var pendingRemove: HermesCredential?
|
||||||
|
/// When non-nil, `AddCredentialSheet` opens pre-seeded with this
|
||||||
|
/// provider name + OAuth type — driven by the chat banner's
|
||||||
|
/// "Re-authenticate" button via `AppCoordinator.pendingOAuthReauth`,
|
||||||
|
/// or by clicking the per-row "Re-authenticate" button in this
|
||||||
|
/// view. Reset to nil when the sheet dismisses so the next plain
|
||||||
|
/// "Add Credential" press doesn't accidentally inherit it.
|
||||||
|
@State private var reauthInitialProvider: String?
|
||||||
|
@Environment(AppCoordinator.self) private var coordinator
|
||||||
|
|
||||||
init(context: ServerContext) {
|
init(context: ServerContext) {
|
||||||
_viewModel = State(initialValue: CredentialPoolsViewModel(context: context))
|
_viewModel = State(initialValue: CredentialPoolsViewModel(context: context))
|
||||||
@@ -42,9 +50,15 @@ struct CredentialPoolsView: View {
|
|||||||
label: "Loading credentials…",
|
label: "Loading credentials…",
|
||||||
isEmpty: viewModel.pools.isEmpty && viewModel.oauthProviders.isEmpty
|
isEmpty: viewModel.pools.isEmpty && viewModel.oauthProviders.isEmpty
|
||||||
)
|
)
|
||||||
.onAppear { viewModel.load() }
|
.onAppear {
|
||||||
.sheet(isPresented: $showAddSheet) {
|
viewModel.load()
|
||||||
AddCredentialSheet(viewModel: viewModel) {
|
consumePendingReauth()
|
||||||
|
}
|
||||||
|
.onChange(of: coordinator.pendingOAuthReauth) { _, _ in
|
||||||
|
consumePendingReauth()
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showAddSheet, onDismiss: { reauthInitialProvider = nil }) {
|
||||||
|
AddCredentialSheet(viewModel: viewModel, initialProvider: reauthInitialProvider) {
|
||||||
showAddSheet = false
|
showAddSheet = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -64,6 +78,19 @@ struct CredentialPoolsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Drain any pending re-auth hand-off from the chat banner: the
|
||||||
|
/// banner's "Re-authenticate" button writes to
|
||||||
|
/// `coordinator.pendingOAuthReauth` and switches to this view; we
|
||||||
|
/// pick the value up here, seed the sheet's initial provider, and
|
||||||
|
/// clear the slot so navigating back to this view doesn't re-open
|
||||||
|
/// the sheet.
|
||||||
|
private func consumePendingReauth() {
|
||||||
|
guard let pending = coordinator.pendingOAuthReauth else { return }
|
||||||
|
reauthInitialProvider = pending
|
||||||
|
showAddSheet = true
|
||||||
|
coordinator.pendingOAuthReauth = nil
|
||||||
|
}
|
||||||
|
|
||||||
private var header: some View {
|
private var header: some View {
|
||||||
ScarfPageHeader(
|
ScarfPageHeader(
|
||||||
"Credential Pools",
|
"Credential Pools",
|
||||||
@@ -166,13 +193,19 @@ struct CredentialPoolsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
|
Button("Re-authenticate") {
|
||||||
|
reauthInitialProvider = provider.provider
|
||||||
|
showAddSheet = true
|
||||||
|
}
|
||||||
|
.controlSize(.small)
|
||||||
|
.help("Run `hermes auth add \(provider.provider) --type oauth` again to refresh this provider's tokens.")
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
.background(.quaternary.opacity(0.3))
|
.background(.quaternary.opacity(0.3))
|
||||||
}
|
}
|
||||||
HStack {
|
HStack {
|
||||||
Text("Managed by `hermes auth add <provider>` — Scarf is read-only here.")
|
Text("Click Re-authenticate to refresh tokens. Removing or rotating providers is still done via `hermes auth …` in a terminal.")
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundStyle(.tertiary)
|
.foregroundStyle(.tertiary)
|
||||||
Spacer()
|
Spacer()
|
||||||
@@ -337,8 +370,25 @@ struct CredentialPoolsView: View {
|
|||||||
/// OAuth flow so the user can paste the authorization code back.
|
/// OAuth flow so the user can paste the authorization code back.
|
||||||
private struct AddCredentialSheet: View {
|
private struct AddCredentialSheet: View {
|
||||||
@Bindable var viewModel: CredentialPoolsViewModel
|
@Bindable var viewModel: CredentialPoolsViewModel
|
||||||
|
/// Optional pre-fill from the re-auth path. When non-nil, the sheet
|
||||||
|
/// opens with this provider name + OAuth selected, mirroring the
|
||||||
|
/// state the user would otherwise have to type. Plain "Add
|
||||||
|
/// Credential" presses leave it nil.
|
||||||
|
let initialProvider: String?
|
||||||
let onDismiss: () -> Void
|
let onDismiss: () -> Void
|
||||||
|
|
||||||
|
init(
|
||||||
|
viewModel: CredentialPoolsViewModel,
|
||||||
|
initialProvider: String? = nil,
|
||||||
|
onDismiss: @escaping () -> Void
|
||||||
|
) {
|
||||||
|
self.viewModel = viewModel
|
||||||
|
self.initialProvider = initialProvider
|
||||||
|
self.onDismiss = onDismiss
|
||||||
|
_providerID = State(initialValue: initialProvider ?? "")
|
||||||
|
_authType = State(initialValue: initialProvider == nil ? .apiKey : .oauth)
|
||||||
|
}
|
||||||
|
|
||||||
enum AuthType: String, CaseIterable, Identifiable {
|
enum AuthType: String, CaseIterable, Identifiable {
|
||||||
case apiKey = "API Key"
|
case apiKey = "API Key"
|
||||||
case oauth = "OAuth"
|
case oauth = "OAuth"
|
||||||
@@ -352,8 +402,8 @@ private struct AddCredentialSheet: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@State private var providerID: String = ""
|
@State private var providerID: String
|
||||||
@State private var authType: AuthType = .apiKey
|
@State private var authType: AuthType
|
||||||
@State private var apiKey: String = ""
|
@State private var apiKey: String = ""
|
||||||
@State private var label: String = ""
|
@State private var label: String = ""
|
||||||
@State private var providers: [HermesProviderInfo] = []
|
@State private var providers: [HermesProviderInfo] = []
|
||||||
|
|||||||
@@ -108,4 +108,12 @@ final class AppCoordinator {
|
|||||||
/// session) — a new session needs a cwd override Scarf doesn't
|
/// session) — a new session needs a cwd override Scarf doesn't
|
||||||
/// yet have an id for.
|
/// yet have an id for.
|
||||||
var pendingProjectChat: String?
|
var pendingProjectChat: String?
|
||||||
|
|
||||||
|
/// Lowercase OAuth provider name to re-authenticate. Set by the
|
||||||
|
/// chat error banner's "Re-authenticate" button, consumed by
|
||||||
|
/// CredentialPoolsView, which auto-presents the OAuth sheet seeded
|
||||||
|
/// to this provider. Cleared by the consumer once handled. Sister
|
||||||
|
/// of `pendingProjectChat` — a hand-off slot, not a long-lived
|
||||||
|
/// state value.
|
||||||
|
var pendingOAuthReauth: String?
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user