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:
Brooklyn Nicholson 2026-07-25 21:52:51 -05:00
parent 36e2228a4c
commit 75456183b6

View file

@ -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) : ''