mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-28 18:19:28 +00:00
fix(desktop): wire the credentialed readiness probe and reauth latch into boot
Connects the preceding seams to the boot path: - buildReadinessHealthProbe picks the probe credentials from the connection's authMode via resolveReadinessProbeAuth, and reports whether the probe is credentialed so waitForHermesReady can read a 401 correctly. The bearer goes through fetchJson's `bearer` option — a raw `headers` object is ignored there, which would have silently probed uncredentialed. - authMode is threaded into all three waitForHermes call sites: the primary remote connect, the profile pool backend, and the SSH tunnel (loopback forward, token auth), so an old backend behind a tunnel gets the same compatibility path. - The confirmed reauth failure latches in remoteReauthFailure and short-circuits startHermes, and is cleared on every recovery path — resetHermesConnection (soft/hard apply and reconnect), bootstrap reset, repair, and a CONFIRMED oauth sign-in. A cancelled or closed login window leaves the latch set so the overlay stays actionable. - gatewayAuthProviders reads the public /api/auth/providers (cached per base URL, failures return []) so the oauth pre-flight guard can skip its hard failure for an all-password gateway while keeping the strict guard everywhere else.
This commit is contained in:
parent
36e2228a4c
commit
75456183b6
1 changed files with 129 additions and 10 deletions
|
|
@ -34,10 +34,10 @@ import { stopBackendChild as stopBackendChildImpl } from './backend-child'
|
|||
import { dashboardFallbackArgs, sourceDeclaresServe } from './backend-command'
|
||||
import { createBackendConnectionState } from './backend-connection-state'
|
||||
import { buildDesktopBackendEnv, normalizeHermesHomeRoot } from './backend-env'
|
||||
import { waitForHermesReady } from './backend-health'
|
||||
import { isReauthRequiredError, waitForHermesReady } from './backend-health'
|
||||
import { canImportHermesCli, shouldTrustHermesOverride, verifyHermesCli } from './backend-probes'
|
||||
import { waitForDashboardPortAnnouncement } from './backend-ready'
|
||||
import { shouldLatchBackendStartFailure } from './backend-start-failure'
|
||||
import { shouldLatchBackendStartFailure, shouldLatchRemoteReauthFailure } from './backend-start-failure'
|
||||
import { detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment } from './bootstrap-platform'
|
||||
import { runBootstrap } from './bootstrap-runner'
|
||||
import { applyConnectionChange, resolveTerminalConnection } from './connection-apply'
|
||||
|
|
@ -119,7 +119,13 @@ import {
|
|||
} from './hardening'
|
||||
import { createLinkTitleWindow, guardLinkTitleSession, readLinkTitleWindowTitle } from './link-title-window'
|
||||
import { ensureMainWindow } from './main-window-lifecycle'
|
||||
import { oauthSessionIsLive, resolveJsonBody, resolveOauthRestAuth } from './native-auth-decisions'
|
||||
import {
|
||||
oauthGuardMayHardFail,
|
||||
oauthSessionIsLive,
|
||||
resolveJsonBody,
|
||||
resolveOauthRestAuth,
|
||||
resolveReadinessProbeAuth
|
||||
} from './native-auth-decisions'
|
||||
import {
|
||||
nativeRefreshUrl,
|
||||
type NativeTokenSet,
|
||||
|
|
@ -1016,6 +1022,13 @@ let bootstrapFailure = null
|
|||
// Latched non-bootstrap backend spawn failure — stops getConnection() from
|
||||
// respawning hermes serve backend children in a tight loop while boot is broken.
|
||||
let backendStartFailure = null
|
||||
// Latched CONFIRMED remote reauth failure. Remote failures deliberately do not
|
||||
// latch via backendStartFailure (they're usually transient and must stay
|
||||
// retryable), but a rejected session cannot self-heal — and the non-latching
|
||||
// path actively breaks recovery: each retry re-emits running:true and hides
|
||||
// the boot-failure overlay, so the "Sign in" button flickers away before it
|
||||
// can be clicked. Cleared on every recovery path and on a confirmed sign-in.
|
||||
let remoteReauthFailure = null
|
||||
// Active first-launch install, so the renderer's Cancel button (and app quit)
|
||||
// can abort the in-flight install.sh/ps1 instead of leaving it running.
|
||||
let bootstrapAbortController = null
|
||||
|
|
@ -4815,12 +4828,87 @@ function closePreviewWatchers() {
|
|||
}
|
||||
}
|
||||
|
||||
async function waitForHermes(baseUrl, token, signal?) {
|
||||
// Best-effort read of a gateway's advertised auth providers, cached per base
|
||||
// URL for the life of the process. Used by the oauth pre-flight guard to tell
|
||||
// a password-provider gateway (which cannot satisfy the bearer/cookie checks
|
||||
// by design) from a real OAuth one. Any failure returns [] so callers keep the
|
||||
// strict guard — backends predating /api/auth/providers are unaffected.
|
||||
const gatewayAuthProvidersCache = new Map<string, any[]>()
|
||||
|
||||
async function gatewayAuthProviders(baseUrl) {
|
||||
const cached = gatewayAuthProvidersCache.get(baseUrl)
|
||||
|
||||
if (cached) {
|
||||
return cached
|
||||
}
|
||||
|
||||
let providers = []
|
||||
|
||||
try {
|
||||
const body = (await fetchPublicJson(`${baseUrl}/api/auth/providers`, { timeoutMs: 8_000 })) as any
|
||||
|
||||
if (Array.isArray(body?.providers)) {
|
||||
providers = body.providers
|
||||
.filter(p => p && typeof p === 'object')
|
||||
.map(p => ({ name: String(p.name || ''), supportsPassword: Boolean(p.supports_password) }))
|
||||
.filter(p => p.name)
|
||||
}
|
||||
} catch {
|
||||
// Optional metadata — an unreadable list keeps the strict guard.
|
||||
}
|
||||
|
||||
gatewayAuthProvidersCache.set(baseUrl, providers)
|
||||
|
||||
return providers
|
||||
}
|
||||
|
||||
// Build the readiness probe for a connection's auth mode. A gated gateway
|
||||
// must be probed with the SAME credentials the rest of the connection uses:
|
||||
// an anonymous probe 401s forever against a live session, and it can never
|
||||
// see the 404 that identifies a backend predating /api/health (the auth gate
|
||||
// answers before the SPA catch-all). `probeIsCredentialed` tells
|
||||
// waitForHermesReady how to read a 401 — rejected session vs gated route.
|
||||
async function buildReadinessHealthProbe(baseUrl, authMode, token) {
|
||||
const nativeAt = authMode === 'oauth' ? await ensureNativeAccessToken(baseUrl).catch(() => null) : null
|
||||
const probeAuth = resolveReadinessProbeAuth(authMode, nativeAt, token)
|
||||
|
||||
if (probeAuth.kind === 'bearer') {
|
||||
return {
|
||||
// fetchJson takes the bearer via `options.bearer` — a raw `headers`
|
||||
// option is ignored, so passing one here would silently probe
|
||||
// uncredentialed and reintroduce the 401 loop.
|
||||
probeHealth: (url, options: any = {}) => fetchJson(url, null, { ...options, bearer: probeAuth.token }),
|
||||
probeIsCredentialed: true
|
||||
}
|
||||
}
|
||||
|
||||
if (probeAuth.kind === 'cookie') {
|
||||
return {
|
||||
probeHealth: (url, options: any = {}) => fetchJsonViaOauthSession(url, options),
|
||||
probeIsCredentialed: true
|
||||
}
|
||||
}
|
||||
|
||||
if (probeAuth.kind === 'token' && probeAuth.token) {
|
||||
return {
|
||||
probeHealth: (url, options: any = {}) => fetchJson(url, probeAuth.token, options),
|
||||
probeIsCredentialed: true
|
||||
}
|
||||
}
|
||||
|
||||
return { probeHealth: fetchPublicJson, probeIsCredentialed: false }
|
||||
}
|
||||
|
||||
async function waitForHermes(baseUrl, token, signal?, authMode?) {
|
||||
const { probeHealth, probeIsCredentialed } = await buildReadinessHealthProbe(baseUrl, authMode, token)
|
||||
|
||||
return waitForHermesReady(baseUrl, {
|
||||
token,
|
||||
signal,
|
||||
fetchPublicJson,
|
||||
fetchJson
|
||||
fetchJson: probeIsCredentialed ? (url, _token, options) => probeHealth(url, options) : fetchJson,
|
||||
probeHealth,
|
||||
probeIsCredentialed
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -6822,7 +6910,10 @@ async function buildRemoteConnection(
|
|||
// here would reject a freshly-completed native sign-in and loop the UI back
|
||||
// into "not signed in" even though mintGatewayWsTicket would succeed with
|
||||
// the stored bearer.
|
||||
if (!oauthSessionIsLive(hasNativeSession(baseUrl), await hasLiveOauthSession(baseUrl))) {
|
||||
if (
|
||||
!oauthSessionIsLive(hasNativeSession(baseUrl), await hasLiveOauthSession(baseUrl)) &&
|
||||
oauthGuardMayHardFail(await gatewayAuthProviders(baseUrl))
|
||||
) {
|
||||
const err = new Error(
|
||||
'Remote Hermes gateway uses OAuth, but you are not signed in. ' +
|
||||
'Open Settings → Gateway and click "Sign in", or switch back to Local.'
|
||||
|
|
@ -7063,7 +7154,7 @@ async function bootstrapSshConnectionInner(profile, sshConfig, reuseToken, sourc
|
|||
forward: (localPort, remotePort) => ssh.forward(localPort, remotePort),
|
||||
cancelForward: (localPort, remotePort) => ssh.cancelForward(localPort, remotePort),
|
||||
pickLocalPort,
|
||||
waitForHermes: (baseUrl, token) => waitForHermes(baseUrl, token, lease.signal),
|
||||
waitForHermes: (baseUrl, token) => waitForHermes(baseUrl, token, lease.signal, 'token'),
|
||||
probeReuseProof: sshProbeReuseProof,
|
||||
adoptServedToken: adoptServedDashboardToken,
|
||||
rememberLog: sshRememberLog,
|
||||
|
|
@ -7536,6 +7627,7 @@ function stopBackendChild(child) {
|
|||
// switch / crash recovery), which still resets boot progress + reloads.
|
||||
function resetHermesConnection({ soft = false } = {}) {
|
||||
backendStartFailure = null
|
||||
remoteReauthFailure = null
|
||||
remoteLiveness.clear()
|
||||
const hermesProcess = backendConnectionState.invalidate()
|
||||
stopBackendChild(hermesProcess)
|
||||
|
|
@ -7736,7 +7828,7 @@ async function spawnPoolBackend(profile, entry) {
|
|||
const remote = await resolveRemoteBackend(profile)
|
||||
|
||||
if (remote) {
|
||||
await waitForHermes(remote.baseUrl, remote.token)
|
||||
await waitForHermes(remote.baseUrl, remote.token, undefined, remote.authMode)
|
||||
|
||||
return {
|
||||
...remote,
|
||||
|
|
@ -7928,6 +8020,13 @@ async function startHermes() {
|
|||
throw backendStartFailure
|
||||
}
|
||||
|
||||
// A confirmed remote reauth rejection is terminal until the user signs in.
|
||||
// Short-circuiting here keeps the boot-failure overlay latched and its
|
||||
// "Sign in" button clickable, instead of re-driving boot on every retry.
|
||||
if (remoteReauthFailure) {
|
||||
throw remoteReauthFailure
|
||||
}
|
||||
|
||||
// E2E: simulate a boot failure without breaking the real backend. The boot
|
||||
// progresses a few steps, then fails with the given error message.
|
||||
if (BOOT_FAKE_ERROR) {
|
||||
|
|
@ -7954,7 +8053,7 @@ async function startHermes() {
|
|||
const connectionPromise = (async () => {
|
||||
const connectRemote = async remote => {
|
||||
await advanceBootProgress('backend.remote', `Connecting to remote Hermes backend at ${remote.baseUrl}`, 24)
|
||||
await waitForHermes(remote.baseUrl, remote.token)
|
||||
await waitForHermes(remote.baseUrl, remote.token, undefined, remote.authMode)
|
||||
updateBootProgress({
|
||||
phase: 'backend.ready',
|
||||
message: 'Remote Hermes backend is ready',
|
||||
|
|
@ -8191,6 +8290,12 @@ async function startHermes() {
|
|||
backendStartFailure = error instanceof Error ? error : new Error(message)
|
||||
}
|
||||
|
||||
// A confirmed reauth rejection latches separately: it can't self-heal, and
|
||||
// leaving it unlatched hides the overlay's "Sign in" button on every retry.
|
||||
if (shouldLatchRemoteReauthFailure({ attemptedRemote, isReauth: isReauthRequiredError(error) })) {
|
||||
remoteReauthFailure = error instanceof Error ? error : new Error(message)
|
||||
}
|
||||
|
||||
updateBootProgress(
|
||||
{
|
||||
error: message,
|
||||
|
|
@ -8994,6 +9099,7 @@ ipcMain.handle('hermes:bootstrap:reset', async () => {
|
|||
await teardownPrimaryBackendAndWait()
|
||||
bootstrapFailure = null
|
||||
backendStartFailure = null
|
||||
remoteReauthFailure = null
|
||||
getFirstRunSetupGate().resetForRetry()
|
||||
resetBootstrapSnapshot()
|
||||
|
||||
|
|
@ -9016,6 +9122,7 @@ ipcMain.handle('hermes:bootstrap:repair', async () => {
|
|||
|
||||
bootstrapFailure = null
|
||||
backendStartFailure = null
|
||||
remoteReauthFailure = null
|
||||
getFirstRunSetupGate().resetForRepair()
|
||||
resetHermesConnection()
|
||||
|
||||
|
|
@ -9125,6 +9232,9 @@ ipcMain.handle('hermes:connection-config:oauth-login', async (_event, rawUrl) =>
|
|||
})
|
||||
|
||||
_storeNativeTokens(baseUrl, tokens)
|
||||
// Confirmed sign-in — release the reauth latch so the next
|
||||
// startHermes() re-dials instead of replaying the stale rejection.
|
||||
remoteReauthFailure = null
|
||||
|
||||
return { ok: true, baseUrl, connected: true }
|
||||
} catch (error) {
|
||||
|
|
@ -9141,7 +9251,16 @@ ipcMain.handle('hermes:connection-config:oauth-login', async (_event, rawUrl) =>
|
|||
// Legacy embedded-webview cookie flow.
|
||||
await openOauthLoginWindow(baseUrl)
|
||||
|
||||
return { ok: true, baseUrl, connected: await hasOauthSessionCookie(baseUrl) }
|
||||
const connected = await hasOauthSessionCookie(baseUrl)
|
||||
|
||||
// Only a CONFIRMED sign-in releases the latch. A cancelled/closed login
|
||||
// window must leave it set, or the overlay's "Sign in" button starts
|
||||
// flickering again on the next retry.
|
||||
if (connected) {
|
||||
remoteReauthFailure = null
|
||||
}
|
||||
|
||||
return { ok: true, baseUrl, connected }
|
||||
})
|
||||
ipcMain.handle('hermes:connection-config:oauth-logout', async (_event, rawUrl) => {
|
||||
const baseUrl = rawUrl ? normalizeRemoteBaseUrl(rawUrl) : ''
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue