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:
@@ -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.'
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user