fix(desktop): run cloud agent silent sign-in at boot

A Hermes Cloud agent authenticates through the silent per-agent cascade
(cloudAgentSilentSignIn), which was only ever invoked from the settings
"Use gateway" button. Boot went straight to waitForHermes, so once the
agent's session cookie expired the ticket mint answered 401, the app
reported "not signed in" and latched reauth, even though the portal
session it needed to recover was still live. Every relaunch needed a
manual click.

Boot now runs the cascade once and retries once, only for remoteKind
cloud + authMode oauth on the terminal reauth error and only with a live
portal session. Everything else surfaces unchanged.
This commit is contained in:
samuelpatro
2026-09-13 10:37:40 +02:00
committed by Austin Pickett
parent 4dc7a23690
commit 130b8f2c5d
3 changed files with 154 additions and 3 deletions

View File

@@ -0,0 +1,60 @@
/**
* Regression tests for electron/cloud-boot-cascade.ts.
*
* A Hermes Cloud agent whose session cookie had expired failed at boot with
* "Remote Hermes gateway uses OAuth, but you are not signed in" and latched,
* although the portal session needed to silently re-mint it was live. These
* pin the exact conditions under which boot is allowed to self-heal.
*
* Run via the vitest `electron` project (electron/**\/*.test.ts).
*/
import assert from 'node:assert/strict'
import { test } from 'vitest'
import { makeReauthRequiredError, makeUnsignedOauthError } from './backend-health'
import { shouldAttemptCloudBootCascade } from './cloud-boot-cascade'
const cloudOauth = { remoteKind: 'cloud', authMode: 'oauth' }
test('cloud oauth agent + unsigned reauth error => run the cascade', () => {
assert.equal(shouldAttemptCloudBootCascade(cloudOauth, makeUnsignedOauthError()), true)
})
test('cloud oauth agent + expired-session reauth error => run the cascade', () => {
assert.equal(shouldAttemptCloudBootCascade(cloudOauth, makeReauthRequiredError('ticket 401')), true)
})
test('a plain url remote never triggers the cascade, even with the same error', () => {
assert.equal(shouldAttemptCloudBootCascade({ remoteKind: 'url', authMode: 'oauth' }, makeUnsignedOauthError()), false)
})
test('a token-auth cloud connection has no cookie to mint, so no cascade', () => {
assert.equal(shouldAttemptCloudBootCascade({ remoteKind: 'cloud', authMode: 'token' }, makeUnsignedOauthError()), false)
})
test('transport and server errors keep their existing handling', () => {
assert.equal(shouldAttemptCloudBootCascade(cloudOauth, new Error('fetch failed')), false)
const serverSide = new Error('503: upstream unavailable') as any
serverSide.statusCode = 503
assert.equal(shouldAttemptCloudBootCascade(cloudOauth, serverSide), false)
})
test('a bare needsOauthLogin hint without the reauth latch does not qualify', () => {
// The IPC-shaped hint only drives Sign in copy; only the terminal latch
// means the ticket mint actually rejected the session.
const hint = new Error('sign in') as any
hint.needsOauthLogin = true
assert.equal(shouldAttemptCloudBootCascade(cloudOauth, hint), false)
})
test('missing or malformed connection objects fail closed', () => {
assert.equal(shouldAttemptCloudBootCascade(null, makeUnsignedOauthError()), false)
assert.equal(shouldAttemptCloudBootCascade(undefined, makeUnsignedOauthError()), false)
assert.equal(shouldAttemptCloudBootCascade('cloud' as any, makeUnsignedOauthError()), false)
assert.equal(shouldAttemptCloudBootCascade({}, makeUnsignedOauthError()), false)
})

View File

@@ -0,0 +1,57 @@
/**
* cloud-boot-cascade.ts
*
* Pure decision seam for self-healing a Hermes Cloud agent connection at boot.
*
* A `cloud` connection authenticates to its agent through the silent per-agent
* cascade (main.ts `cloudAgentSilentSignIn`): open the agent's protected root in
* the shared OAuth partition, let the portal auto-approve, and the agent's own
* session cookie lands with no prompt. That cascade was only ever driven by the
* settings UI ("Use gateway"). The boot path went straight to `waitForHermes`,
* so once the agent cookie expired the WS-ticket mint answered 401, the app
* reported "not signed in" and latched reauth, even though the portal session
* it needed to recover was still live. Every relaunch needed a manual click.
*
* This module decides when the boot path may run the cascade once and retry.
* Kept free of `electron` imports so it unit-tests in the electron vitest
* project; main.ts owns the side effects.
*/
import { isReauthRequiredError } from './backend-health'
export interface CloudBootCascadeCandidate {
remoteKind?: unknown
authMode?: unknown
}
/**
* True when a failed `waitForHermes` for `remote` should be followed by one
* silent per-agent sign-in and a single retry, rather than surfacing the
* reauth error immediately. Requires all of:
*
* - the connection is a Hermes Cloud agent (`remoteKind: 'cloud'`);
* - it authenticates with cookies (`authMode: 'oauth'`), which is the only
* mode the cascade can mint a session for;
* - the failure is the terminal reauth error (`isReauthRequired`), i.e. the
* ticket mint rejected the session. Transport errors, server-side 5xx and
* anything else keep their existing handling.
*
* The caller must additionally confirm a live portal session before running
* the cascade; without one the cascade cannot succeed and would only add a
* hidden window and a delay in front of the same error.
*/
export function shouldAttemptCloudBootCascade(remote: CloudBootCascadeCandidate | null | undefined, error: unknown): boolean {
if (!remote || typeof remote !== 'object') {
return false
}
if (remote.remoteKind !== 'cloud') {
return false
}
if (remote.authMode !== 'oauth') {
return false
}
return isReauthRequiredError(error)
}

View File

@@ -105,6 +105,7 @@ import {
import { detectBundleSkew } from './bundle-skew'
import { detectBundleSwap } from './bundle-swap'
import { registerChatOnboardingWindow } from './chat-onboarding-window'
import { shouldAttemptCloudBootCascade } from './cloud-boot-cascade'
import { discoverWithTeamFallback } from './cloud-discovery'
import { installCommandScreenshot } from './command-screenshot'
import { writeComposerPaste } from './composer-paste'
@@ -6888,6 +6889,39 @@ async function buildReadinessHealthProbe(baseUrl, authMode, token) {
return { probeHealth: fetchPublicJson, probeIsCredentialed: false }
}
// Boot-time readiness for a remote connection object. For a Hermes Cloud agent
// whose own session cookie has expired, `waitForHermes` ends in the terminal
// reauth error even though the portal session that can silently re-mint that
// cookie is still live: the per-agent cascade (`cloudAgentSilentSignIn`) was
// only ever driven by the settings UI, never by boot, so every relaunch needed
// a manual "Use gateway" click. Run the cascade once and retry once; anything
// that is not that exact case surfaces unchanged.
async function waitForRemoteHermes(remote) {
try {
await waitForHermes(remote.baseUrl, remote.token, undefined, remote.authMode, remote.headers)
} catch (error) {
if (!shouldAttemptCloudBootCascade(remote, error)) {
throw error
}
if (!(await hasLivePortalSession())) {
throw error
}
rememberLog('[cloud] boot: agent session rejected but portal session is live, running silent sign-in')
try {
await cloudAgentSilentSignIn(remote.baseUrl)
} catch (cascadeError) {
rememberLog(`[cloud] boot: silent sign-in did not complete: ${cascadeError?.message || cascadeError}`)
throw error
}
await waitForHermes(remote.baseUrl, remote.token, undefined, remote.authMode, remote.headers)
}
}
async function waitForHermes(baseUrl, token, signal?, authMode?, headers = {}) {
const { probeHealth, probeIsCredentialed } = await buildReadinessHealthProbe(baseUrl, authMode, token)
@@ -11870,7 +11904,7 @@ async function connectRegistryBackend(
source.headers
)
await waitForHermes(connection.baseUrl, connection.token, undefined, connection.authMode, connection.headers)
await waitForRemoteHermes(connection)
poolEntry.remoteBaseUrl = connection.baseUrl
// Remote/cloud backends live on another host too — disable the WSL path
@@ -12563,7 +12597,7 @@ async function runPoolBackendStart(
profileDeletionGate.assertCanStart(profile)
if (remote) {
await waitForHermes(remote.baseUrl, remote.token, undefined, remote.authMode, remote.headers)
await waitForRemoteHermes(remote)
// Recorded on the entry so revalidation can probe this descriptor without
// awaiting connectionPromise, which may still be pending for a sibling.
@@ -13356,7 +13390,7 @@ async function runHermesStart({ supervisorRecovery = false }: { supervisorRecove
backendConnectionState.assertCurrentAttempt(connectionAttempt)
await advanceBootProgress('backend.remote', `Connecting to remote Hermes backend at ${remote.baseUrl}`, 24)
await waitForHermes(remote.baseUrl, remote.token, undefined, remote.authMode, remote.headers)
await waitForRemoteHermes(remote)
// Second async boundary: the health probe itself can outlive the
// attempt. A late success here must not publish a stale descriptor.