diff --git a/apps/desktop/electron/backend-start-failure.test.ts b/apps/desktop/electron/backend-start-failure.test.ts index ea2261db16..8a991101ba 100644 --- a/apps/desktop/electron/backend-start-failure.test.ts +++ b/apps/desktop/electron/backend-start-failure.test.ts @@ -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.' diff --git a/apps/desktop/electron/backend-start-failure.ts b/apps/desktop/electron/backend-start-failure.ts index 5134290d59..873837ed1d 100644 --- a/apps/desktop/electron/backend-start-failure.ts +++ b/apps/desktop/electron/backend-start-failure.ts @@ -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 { diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 5363f72310..0fd0f2d050 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -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