fix(credentials): recognize OAuth providers; warn on project-shadowed Hermes

Three related fixes for the "I authed Nous but Scarf doesn't see it" bug:

1. `hasAnyAICredential()` (HermesFileService) only probed the
   `credential_pool.<provider>` shape in auth.json. OAuth-authed providers
   land under `providers.<name>.access_token` instead — Nous, Spotify, GH
   Copilot ACP, Qwen, Gemini all use that path. The chat banner kept
   showing "No AI provider credentials" even after a successful Nous
   sign-in. Now we probe both shapes; refresh-only entries (pre-mint
   OAuth flows) also count.

2. `CredentialPoolsViewModel` decoded only `credential_pool.*` and
   ignored `providers.*` entirely. New `oauthProviders` array surfaces
   them in a parallel "OAuth providers" section above the rotation
   pools — read-only, with token tail, expiry badge, portal URL, and
   "managed by `hermes auth add`" footnote so users know where the
   write path lives.

3. New `ProjectHermesShadowDetector` (ScarfCore) probes each registered
   project for a `<project>/.hermes/` directory. Hermes' CLI binds to
   the closest `.hermes/` as `$HERMES_HOME` when run from inside such a
   project — `hermes auth add nous` lands in the project's auth.json
   instead of `~/.hermes/auth.json` and Scarf's global probes never
   see it. Surfaced as a yellow Dashboard banner listing affected
   projects with badges for `auth.json` / `state.db` presence and a
   "Copy fix command" button that emits a one-liner consolidating
   auth.json into the global home. Read-only — no auto-migration; the
   user decides what to keep.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-04-27 22:48:20 +02:00
parent 4ffd353835
commit 389620059c
7 changed files with 503 additions and 12 deletions
@@ -0,0 +1,114 @@
import Foundation
#if canImport(os)
import os
#endif
/// Detects when a registered project directory contains its own `.hermes/`
/// subdirectory. Hermes' CLI uses the closest `.hermes/` as `$HERMES_HOME`
/// when invoked from inside such a directory, which **shadows** the user's
/// global Hermes home credentials, config, sessions, skills, memories
/// all bind to the project-local copy without warning.
///
/// This causes confusing failure modes: the user runs `hermes auth add nous`
/// during setup expecting a global registration, but if their cwd happens to
/// be inside a project that already has a `.hermes/` (e.g. seeded by a
/// previous workflow, copied from another machine, or checked into git),
/// Hermes writes the credentials to the project-local `.hermes/auth.json`.
/// Scarf then reads the global path on every dashboard tick and shows
/// "missing provider" warnings even though the user did sign in successfully.
///
/// The detector enumerates the registered projects on a given server and
/// reports which ones carry a shadowing `.hermes/`. Views surface a yellow
/// banner so the user can consolidate.
public struct ProjectHermesShadowDetector: Sendable {
public struct Shadow: Sendable, Hashable, Identifiable {
public var id: String { projectPath }
/// Project name from the registry (`ProjectEntry.name`).
public let projectName: String
/// Absolute path to the project on the target server.
public let projectPath: String
/// Absolute path to the shadowing `.hermes/` directory.
public let shadowPath: String
/// `true` when the shadow `.hermes/auth.json` exists. Strong signal
/// that user credentials are landing in the wrong place.
public let hasAuthJSON: Bool
/// `true` when the shadow `.hermes/state.db` exists. Hermes wrote
/// session state to the project-local home the user's chat
/// history is invisible to Scarf's global Dashboard for this slice.
public let hasStateDB: Bool
public init(
projectName: String,
projectPath: String,
shadowPath: String,
hasAuthJSON: Bool,
hasStateDB: Bool
) {
self.projectName = projectName
self.projectPath = projectPath
self.shadowPath = shadowPath
self.hasAuthJSON = hasAuthJSON
self.hasStateDB = hasStateDB
}
}
#if canImport(os)
private static let logger = Logger(subsystem: "com.scarf", category: "ProjectHermesShadowDetector")
#endif
private let context: ServerContext
private let transport: any ServerTransport
public init(context: ServerContext) {
self.context = context
self.transport = context.makeTransport()
}
/// Probe every project in `projects` for a shadowing `.hermes/`. Skips
/// archived projects and projects whose absolute path equals the
/// resolved Hermes home (rare but possible a project literally
/// rooted at `~/.hermes` shouldn't trigger a self-warning).
public func detect(in projects: [ProjectEntry]) async -> [Shadow] {
let hermesHome = await context.resolvedUserHome() + "/.hermes"
var found: [Shadow] = []
for project in projects where !project.archived {
// A project nested inside the Hermes home itself is a weird
// edge case (someone made `~/.hermes/notes` a Scarf project).
// The project is BELOW the Hermes home, so its `.hermes` is
// the same dir as `~/.hermes/.hermes` almost certainly not
// present and definitely not a shadow.
if project.path.hasPrefix(hermesHome) { continue }
let shadowPath = project.path + "/.hermes"
guard transport.fileExists(shadowPath) else { continue }
// It's only a shadow if the path is a directory; a stray
// `.hermes` file would be filtered out here.
guard transport.stat(shadowPath)?.isDirectory == true else { continue }
let hasAuth = transport.fileExists(shadowPath + "/auth.json")
let hasDB = transport.fileExists(shadowPath + "/state.db")
#if canImport(os)
Self.logger.warning(
"Detected shadow Hermes home at \(shadowPath, privacy: .public) (auth: \(hasAuth), state.db: \(hasDB))"
)
#endif
found.append(Shadow(
projectName: project.name,
projectPath: project.path,
shadowPath: shadowPath,
hasAuthJSON: hasAuth,
hasStateDB: hasDB
))
}
return found
}
/// Suggested shell command the user can copy-paste / run on the remote
/// to consolidate a shadow's auth.json into their global Hermes home.
/// Skips state.db / sessions / skills migration intentionally those
/// require Hermes to be quiesced and risk data loss; the user should
/// decide what to keep on a case-by-case basis. We give them the
/// load-bearing one-liner (auth) and let them handle the rest.
public static func consolidationCommand(for shadow: Shadow, hermesHome: String) -> String? {
guard shadow.hasAuthJSON else { return nil }
return "cp \(shadow.shadowPath)/auth.json \(hermesHome)/auth.json && chmod 600 \(hermesHome)/auth.json"
}
}