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:
committed by
Austin Pickett
parent
4dc7a23690
commit
130b8f2c5d
60
apps/desktop/electron/cloud-boot-cascade.test.ts
Normal file
60
apps/desktop/electron/cloud-boot-cascade.test.ts
Normal 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)
|
||||
})
|
||||
57
apps/desktop/electron/cloud-boot-cascade.ts
Normal file
57
apps/desktop/electron/cloud-boot-cascade.ts
Normal 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)
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user