From 75456183b6f48aacf12cfd70a283ed63dac83012 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 25 Jul 2026 21:52:51 -0500 Subject: [PATCH] fix(desktop): wire the credentialed readiness probe and reauth latch into boot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/desktop/electron/main.ts | 139 +++++++++++++++++++++++++++++++--- 1 file changed, 129 insertions(+), 10 deletions(-) diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 89b350f1b4b9..f967aa24c3b8 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -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() + +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) : ''