fix: Chat tab false-positive "no credentials" warning before session pick

The orange "No AI provider credentials detected" banner was firing on the
Chat tab whenever no session was selected, even for users whose
credentials were configured and working. The banner only disappeared
when a session started — not because credentials were actually found,
but because the banner's `!hasActiveProcess` gate flipped to false once
ACP launched.

Root cause: `HermesFileService.hasAnyAICredential()` inspected only the
shell environment and `~/.hermes/.env`, while Hermes itself resolves
credentials from two additional places Scarf had never learned about:

  - `~/.hermes/auth.json` — the Credential Pools file written by the
    Configure → Credential Pools UI (the blessed v1.6 flow)
  - `~/.hermes/config.yaml` — embedded `api_key:` under auxiliary.<task>
    and delegation

The preflight now checks all four locations. For auth.json we parse the
JSON and look for any `credential_pool.<provider>[*].access_token` that
is non-empty. For config.yaml we line-scan for `api_key:` leaves with a
non-empty value, matching the defensive style of the existing .env
scanner (no YAML parser needed in a nonisolated function).

Also updated the banner subtitle to point users at Credential Pools
before .env, since the former is the blessed in-app flow.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-04-17 17:10:51 -07:00
parent da88c98c7a
commit 8d3fe70e2c
2 changed files with 41 additions and 5 deletions
@@ -1356,10 +1356,16 @@ struct HermesFileService: Sendable {
return env return env
} }
/// True if any known AI-provider credential is reachable either already /// True if any known AI-provider credential is reachable. Hermes itself
/// in the current process env, present in the login-shell env we queried, /// resolves credentials from four locations at runtime, so the preflight
/// or present in `~/.hermes/.env`. Used by Chat to warn the user before /// mirrors that set to avoid false "no credentials" warnings:
/// `hermes acp` fails on send with "No Anthropic credentials found". /// 1. Current process env + login-shell env (queried once at startup)
/// 2. `~/.hermes/.env`
/// 3. `~/.hermes/auth.json` Credential Pools (v1.6+ blessed flow)
/// 4. `~/.hermes/config.yaml` embedded `api_key:` for auxiliary /
/// delegation tasks
/// Used by Chat to warn the user before `hermes acp` fails on send with
/// "No Anthropic credentials found".
nonisolated static func hasAnyAICredential() -> Bool { nonisolated static func hasAnyAICredential() -> Bool {
let credentialKeys = shellEnvKeys.filter { $0 != "PATH" && $0 != "ANTHROPIC_BASE_URL" && $0 != "OPENAI_BASE_URL" } let credentialKeys = shellEnvKeys.filter { $0 != "PATH" && $0 != "ANTHROPIC_BASE_URL" && $0 != "OPENAI_BASE_URL" }
let env = enrichedEnvironment() let env = enrichedEnvironment()
@@ -1386,6 +1392,36 @@ struct HermesFileService: Sendable {
} }
} }
} }
// Scan ~/.hermes/auth.json the Credential Pools file written by the
// Configure Credential Pools UI. Schema is
// { "credential_pool": { "<provider>": [ { "access_token": "...", ... }, ... ] } }
// Defensive parse: any malformed input falls through to the next check.
let authPath = HermesPaths.home + "/auth.json"
if let data = try? Data(contentsOf: URL(fileURLWithPath: authPath)),
let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let pool = root["credential_pool"] as? [String: Any] {
for (_, entries) in pool {
guard let list = entries as? [[String: Any]] else { continue }
for cred in list {
if let token = cred["access_token"] as? String, !token.isEmpty {
return true
}
}
}
}
// Scan ~/.hermes/config.yaml for `api_key:` lines with a non-empty
// value. Covers both `auxiliary.<task>.api_key` and `delegation.api_key`
// without needing to parse the YAML structure any leaf `api_key: ...`
// with a value means Hermes has a credential to fall back on.
if let text = try? String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8) {
for line in text.split(separator: "\n") {
let trimmed = line.trimmingCharacters(in: .whitespaces)
guard trimmed.hasPrefix("api_key:") else { continue }
let value = trimmed.dropFirst("api_key:".count)
.trimmingCharacters(in: CharacterSet(charactersIn: "\"' "))
if !value.isEmpty { return true }
}
}
return false return false
} }
@@ -96,7 +96,7 @@ struct ChatView: View {
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text("No AI provider credentials detected") Text("No AI provider credentials detected")
.font(.callout) .font(.callout)
Text("Add `ANTHROPIC_API_KEY` (or similar) to `~/.hermes/.env` or your shell profile, then restart Scarf.") Text("Add credentials in **Configure → Credential Pools**, set `ANTHROPIC_API_KEY` (or similar) in `~/.hermes/.env`, or export it in your shell profile, then restart Scarf.")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }