fix(desktop): latch SSH auth failures so the boot overlay stays clickable

An SSH auth-failed boot error carried none of the local, host-key or
reauth latch tags, so it stayed retryable: every getConnection/api call
re-ran startHermes, re-emitted running: true and hid the boot-failure
overlay before its Gateway settings button could be clicked. Classify
the rejection (kind/sshError tag, or the message once stringified),
latch it like a host-key change, and keep it out of the renderer's
auto-retry loop. reset/repair/apply-config still release the latch.

Co-authored-by: x7peeps <xtpeeps@qq.com>
This commit is contained in:
Hermes Agent
2026-09-24 23:17:40 -05:00
committed by brooklyn!
parent 085d9ee608
commit 22a7acd810
3 changed files with 97 additions and 9 deletions

View File

@@ -6,11 +6,14 @@ import { isReauthRequiredError, makeUnsignedOauthError } from './backend-health'
import {
isHostKeyChangedBootFailure,
isRetryableRemoteBootFailure,
isSshAuthFailedBootFailure,
shouldHoldBootProgressForReauth,
shouldLatchBackendStartFailure,
shouldLatchHostKeyChangedFailure,
shouldLatchRemoteReauthFailure
shouldLatchRemoteReauthFailure,
shouldLatchSshAuthFailure
} from './backend-start-failure'
import { SshConnection } from './ssh-connection'
test('latches a LOCAL backend failure so the install-retry loop is broken', () => {
assert.equal(shouldLatchBackendStartFailure({ attemptedRemote: false }), true)
@@ -152,6 +155,41 @@ test('every remote failure picks exactly one path: retry, reauth latch, or host-
}
})
test('FIX #72698: a rejected SSH key latches the boot failure and is never auto-retried', () => {
// The error exactly as SshConnection.open() rejects it, and as the SSH
// bootstrap re-wraps a lifecycle failure (message + sshError tag).
const fromOpen = new SshConnection({ host: '127.0.0.1', user: 'me' }, {})._fail(
'me@127.0.0.1: Permission denied (publickey,password,keyboard-interactive).'
)
const rewrapped = Object.assign(new Error(fromOpen.message), { sshError: fromOpen.kind, isSshBootstrap: true })
for (const error of [fromOpen, rewrapped, new Error(fromOpen.message)]) {
const isSshAuthFailed = isSshAuthFailedBootFailure(error)
const context = { attemptedRemote: true, isReauth: false, isHostKeyChanged: false, isSshAuthFailed }
assert.equal(shouldLatchSshAuthFailure(context), true)
assert.equal(isRetryableRemoteBootFailure(context), false)
}
// Connectivity faults keep self-healing; local boots use the local latch.
const unreachable = new SshConnection({ host: '127.0.0.1', user: 'me' }, {})._fail(
'ssh: connect to host 127.0.0.1 port 22: Connection refused'
)
const transient = {
attemptedRemote: true,
isReauth: false,
isSshAuthFailed: isSshAuthFailedBootFailure(unreachable)
}
assert.equal(shouldLatchSshAuthFailure(transient), false)
assert.equal(isRetryableRemoteBootFailure(transient), true)
assert.equal(shouldLatchSshAuthFailure({ attemptedRemote: false, isReauth: false, isSshAuthFailed: true }), false)
// A remote lifecycle's filesystem "Permission denied" is not a credential rejection.
assert.equal(isSshAuthFailedBootFailure(new Error('mkdir: /opt/hermes: Permission denied')), false)
})
test('FIX #95701: while a reauth rejection is latched, only re-emits of that failure reach the renderer', () => {
const latched = 'Your remote gateway session has expired. Sign in again.'

View File

@@ -96,6 +96,12 @@ export interface RemoteBootRetryContext {
* is terminal like a reauth rejection — not connectivity.
*/
isHostKeyChanged?: boolean
/**
* True when SSH rejected the credentials (`auth-failed`). Desktop runs ssh
* in BatchMode, so nothing changes until the user loads the key into
* ssh-agent or fixes the connection settings: terminal, not connectivity.
*/
isSshAuthFailed?: boolean
}
/**
@@ -117,6 +123,36 @@ export function isHostKeyChangedBootFailure(error: unknown): boolean {
)
}
/**
* An SSH credential rejection is identifiable by the `auth-failed` kind that
* classifySshError puts on the error (`kind` from `SshConnection.open`,
* `sshError` once the bootstrap re-wraps a lifecycle failure) and, for errors
* that crossed a stringifying boundary, by our own message or ssh's banner.
*/
export function isSshAuthFailedBootFailure(error: unknown): boolean {
const tagged = error as { kind?: string; sshError?: string } | null | undefined
if (tagged?.kind === 'auth-failed' || tagged?.sshError === 'auth-failed') {
return true
}
const message = error instanceof Error ? error.message : String(error ?? '')
return /SSH authentication to .+ failed|Permission denied \((?:publickey|password|keyboard-interactive)/i.test(message)
}
/**
* Whether a failed remote boot should latch (into `backendStartFailure`)
* because SSH rejected the credentials (#72698). Unlatched, every
* `getConnection`/api call re-runs startHermes, re-emits `running: true` and
* hides the boot-failure overlay, so its Gateway settings button — the only
* way to fix the key — ignores clicks. Released by reset/repair/apply-config
* like the host-key latch.
*/
export function shouldLatchSshAuthFailure(context: RemoteBootRetryContext): boolean {
return context.attemptedRemote && context.isSshAuthFailed === true
}
/**
* Whether a failed remote boot should latch (into `backendStartFailure`)
* because the host key changed. Same rationale as the reauth latch: the
@@ -141,12 +177,14 @@ export function shouldLatchHostKeyChangedFailure(context: RemoteBootRetryContext
* only arms after a completed boot, so the app sat on "Desktop boot failed"
* until the user manually re-entered the same connection details (which just
* forced a fresh bootstrap). A missing capability differs from a transient
* failure: confirmed reauth rejections, host-key changes, and local failures
* stay out of the retry path; everything else remote is connectivity and
* failure: confirmed reauth rejections, host-key changes, SSH credential
* rejections, and local failures stay out of the retry path; everything else remote is connectivity and
* should retry.
*/
export function isRetryableRemoteBootFailure(context: RemoteBootRetryContext): boolean {
return context.attemptedRemote && !context.isReauth && context.isHostKeyChanged !== true
return (
context.attemptedRemote && !context.isReauth && context.isHostKeyChanged !== true && context.isSshAuthFailed !== true
)
}
export interface BootProgressUpdateLike {

View File

@@ -82,11 +82,13 @@ import { isPidAliveWindows, waitForBackendRelease } from './backend-release-gate
import { createBackendServeSupportResolver } from './backend-serve-support'
import {
isHostKeyChangedBootFailure,
isSshAuthFailedBootFailure,
isRetryableRemoteBootFailure,
shouldHoldBootProgressForReauth,
shouldLatchBackendStartFailure,
shouldLatchHostKeyChangedFailure,
shouldLatchRemoteReauthFailure
shouldLatchRemoteReauthFailure,
shouldLatchSshAuthFailure
} from './backend-start-failure'
import { describeBootstrapFailure } from './bootstrap-failure-copy'
import {
@@ -13050,6 +13052,7 @@ async function runHermesStart({ supervisorRecovery = false }: { supervisorRecove
const message = error instanceof Error ? error.message : String(error)
const hostKeyChanged = isHostKeyChangedBootFailure(error)
const sshAuthFailed = isSshAuthFailedBootFailure(error)
// Carry structured Cloud-down metadata through the boot-progress / IPC
// boundary when present, so the renderer overlay can key on it rather than
@@ -13083,6 +13086,14 @@ async function runHermesStart({ supervisorRecovery = false }: { supervisorRecove
backendStartFailure = error instanceof Error ? error : new Error(message)
}
// Rejected SSH credentials are just as terminal (#72698): BatchMode ssh
// keeps failing until the user loads the key or edits the connection, and
// an unlatched failure lets every api call re-drive boot and hide the
// overlay out from under its Gateway settings button.
if (shouldLatchSshAuthFailure({ attemptedRemote, isReauth: false, isSshAuthFailed: sshAuthFailed })) {
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) })) {
@@ -13098,13 +13109,14 @@ async function runHermesStart({ supervisorRecovery = false }: { supervisorRecove
// Renderer contract for the self-heal loop (#82679): a transient
// REMOTE failure (dropped SSH/HTTP registered connection, mint
// timeout) is retryable — the renderer re-attempts the boot with
// bounded backoff. Local failures, confirmed reauth rejections, and
// host-key changes are not: those end in the recovery overlay /
// sign-in affordance.
// bounded backoff. Local failures, confirmed reauth rejections,
// host-key changes, and rejected SSH credentials are not: those end in
// the recovery overlay / sign-in affordance.
retryable: isRetryableRemoteBootFailure({
attemptedRemote,
isReauth: isReauthRequiredError(error),
isHostKeyChanged: hostKeyChanged
isHostKeyChanged: hostKeyChanged,
isSshAuthFailed: sshAuthFailed
}),
running: false,
statusCode: Number.isInteger(statusCode) ? statusCode : undefined