Files
orca/src/main/ssh/ssh-connection.ts
T
Neil 019417bd86 refactor(ssh): import the system-ssh and ssh -G modules directly, not through re-export shims (#30255)
Deletes ssh-system-fallback.ts and the re-export lines in ssh-config-parser.ts, ssh-connection.ts,
system-ssh-file-binary-transfer.ts and ssh-remote-powershell.ts; importers name the real modules.
SshConnection tests mock each real module, keeping the parser stub that holds ~/.ssh/config out.
2026-10-18 02:34:30 -07:00

1617 lines
62 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* eslint-disable max-lines -- Why: SSH connection lifecycle, credential retries, reconnect policy, and transport fallback are intentionally co-located so state transitions stay auditable in one file. */
import { Client as SshClient } from 'ssh2'
import {
createSshFileUploadSession,
downloadSshFile,
uploadSshDirectory,
writeSshBuffer,
writeSshFile,
type SshFileTransferHost,
type SshRemoteFileOptions
} from './ssh-connection-file-transfers'
import { trackSshConnectionChannelLifetime } from './ssh-connection-channel-lifetime'
import type { ChildProcess } from 'node:child_process'
import type {
ClientChannel,
ConnectConfig,
KeyboardInteractiveCallback,
Prompt,
PseudoTtyOptions,
SFTPWrapper,
ShellOptions
} from 'ssh2'
import type { SshTarget, SshConnectionState, SshConnectionStatus } from '../../shared/ssh-types'
import {
getOrcaControlSocketPath,
isOpenSshConfigBackedTarget,
type SystemSshBuildArgsOptions
} from './system-ssh-args'
import { spawnSystemSshCommand } from './system-ssh-command'
import { removeControlSocketPath } from './ssh-control-socket'
import {
INITIAL_RETRY_ATTEMPTS,
INITIAL_RETRY_DELAY_MS,
RECONNECT_BACKOFF_MS,
CONNECT_TIMEOUT_MS,
SSH_CREDENTIAL_TIMEOUT_MS,
isTransientError,
isAuthError,
isAgentFallbackError,
isSystemSshFallbackError,
isGssapiSystemSshFallbackCandidate,
isPassphraseError,
sleep,
buildConnectConfig,
wrapRemoteCommandForPosixShell,
createSshOperationAbortError,
type SshExecOptions,
type SshConnectionCallbacks,
type SshCredentialKind
} from './ssh-connection-utils'
import { collectKeyboardInteractiveResponses } from './ssh-keyboard-interactive'
import { resolveEffectiveProxy, spawnProxyCommand } from './ssh-proxy-command'
import {
createHostKeyVerifier,
DEFAULT_SERVER_HOST_KEY_ALGORITHMS,
orderServerHostKeyAlgorithms
} from './ssh-host-key-verifier'
import { HostKeyVerificationError, isHostKeyVerificationError } from './ssh-host-key-decision'
import {
resolveWithSshG,
siteConfigMayRestrictHostKeys,
sshGArgsForHost,
type SshResolvedConfig
} from './ssh-g-config-resolution'
import {
boundSshHostKeyStoreFile,
loadTrustedHostKeys,
matchTrustedHostKeys,
storedKeyTypesForEndpoint,
trustHostKey
} from './ssh-host-key-store'
import {
loadKnownHostsEvidence,
resolveKnownHostsFiles,
resolveKnownHostsLookupHost
} from './ssh-known-hosts-source'
import {
createCancelledConnectAttemptError,
isCancelledConnectAttemptError
} from './ssh-connect-attempt-cancellation'
import {
isDefiniteSystemSshHostFailure,
isTransientReconnectError
} from './ssh-reconnect-error-classification'
import { SshReconnectLadder } from './ssh-reconnect-ladder'
import { mayUserSshConfigClaimAlias } from './ssh-config-alias-claim'
import {
didDeferKeyboardInteractive,
didOfferKeyboardInteractive,
getPassphrasePrivateKeyPath
} from './ssh-private-key-authentication'
import {
requiresSystemSshForSecurityKey,
shouldUseSystemSshTransport
} from './ssh-transport-selection'
import type { FileUploadSession } from '../providers/filesystem-provider-contract'
import { openSshSessionChannelWithRetry, waitForSshChannelOpen } from './ssh-channel-open'
import { tagSftpHandshakeCorruption } from './sftp-handshake-corruption'
import { withTimeout } from '../../shared/promise-timeout-fallback'
import { isEphemeralRuntimeSshOwner } from '../../shared/managed-orcad-ssh-owner'
import { isSshUserDisconnected } from './ssh-user-disconnect'
/** The sign-in a connection holds in memory, handed to the connection that replaces it. */
export type SshCachedCredentials = { password: string | null; passphrase: string | null }
type HostKeyTrustSources = {
/** Whether the system-wide ssh_config could be restricting host keys; doubt reads as true. */
siteMayRestrict: boolean
knownHostsEvidence: Awaited<ReturnType<typeof loadKnownHostsEvidence>>
trustedHostKeys: Awaited<ReturnType<typeof loadTrustedHostKeys>>
}
/** Bounds the trust-source reads that run before the handshake, which nothing else times out. */
const HOST_KEY_SOURCE_READ_TIMEOUT_MS = 5_000
// Why: resume and the window's own resume/online signal land together; a handshake younger than this
// is the one the first signal just started, not one stuck on the route from before sleep.
const STALE_BACKGROUND_ATTEMPT_MS = 5_000
// Counts every INFO_REQUEST of the handshake, so it must cover each partial-success stage the auth
// queue will answer (MAX_PARTIAL_SUCCESS_STAGES) times the rounds a PAM stack spends per stage.
const SSH_KEYBOARD_INTERACTIVE_MAX_ROUNDS = 8
const SSH_KEYBOARD_INTERACTIVE_READY_TIMEOUT_MS = SSH_CREDENTIAL_TIMEOUT_MS + 5_000
const SSH_KEYBOARD_INTERACTIVE_MAX_PROMPTS = 8
function cloneResolvedConfig(config: SshResolvedConfig | null): SshResolvedConfig | null {
if (!config) {
return null
}
// Every array field needs its own copy — a shared reference lets one connection's clone mutate
// another's view of which files were consulted.
return {
...config,
identityFile: [...config.identityFile],
userKnownHostsFiles: [...config.userKnownHostsFiles],
globalKnownHostsFiles: [...config.globalKnownHostsFiles]
}
}
function isGitHubRestrictedShellProbeSuccess(
target: SshTarget,
resolvedConfig: SshResolvedConfig | null,
code: number | null,
stderr: string
): boolean {
if (code !== 1) {
return false
}
const effectiveUser = (
isOpenSshConfigBackedTarget(target) && resolvedConfig
? resolvedConfig.user?.trim() || target.username?.trim()
: target.username?.trim() || resolvedConfig?.user?.trim()
)?.toLowerCase()
if (effectiveUser !== 'git') {
return false
}
// GitHub appends git:// advisory lines after the invalid-command line (issue #6988), so match the first line only.
const firstLine = stderr.split('\n', 1)[0]?.trim()
if (firstLine !== 'Invalid command: echo ORCA-SYSTEM-SSH-OK') {
return false
}
const resolvedHost = resolvedConfig?.hostname?.trim()
const hostCandidates = resolvedHost ? [resolvedHost] : [target.host, target.configHost]
return hostCandidates.some((host) => {
const normalizedHost = host?.trim().toLowerCase()
return normalizedHost === 'github.com' || normalizedHost === 'ssh.github.com'
})
}
export class SshConnection {
private client: SshClient | null = null
private proxyProcess: ChildProcess | null = null
private systemCommandChannels = new Set<ClientChannel>()
private systemOperationAbortController = new AbortController()
private systemSshResolvedConfig: SshResolvedConfig | null = null
/** Set by attemptConnect so doSsh2Connect can build a verifier without threading it through. */
private hostKeyResolvedConfig: SshResolvedConfig | null = null
/**
* The trust sources for ONE connect attempt, keyed by its generation.
*
* doSsh2Connect runs up to five times per attemptConnect as the credential ladder advances, and
* each run re-read every known_hosts file, re-read the store, and on the -F path re-scanned the
* system config. The answer cannot change between rungs of the same attempt — nothing writes these
* while a handshake is in flight — so it is read once. Keyed by generation rather than cleared,
* because a superseded attempt must never hand its sources to the live one.
*/
private hostKeyTrustSources: {
generation: number
sources: Promise<HostKeyTrustSources>
} | null = null
private systemSshControlMasterDisabledForSession = false
private systemSshGssapiOnlyForSession = false
private useSystemSshTransport = false
private credentialAbortController = new AbortController()
private readonly pendingSsh2Clients = new Set<SshClient>()
private state: SshConnectionState
private callbacks: SshConnectionCallbacks
private target: SshTarget
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
private readonly reconnectLadder = new SshReconnectLadder()
private backgroundAttemptStartedAtMs: number | null = null
private disposed = false
private cachedPassphrase: string | null = null
private cachedPassword: string | null = null
// Only a sign-in the host accepted may be handed on; a typed or re-asked one is unproven until then.
private signInVerified = false
private keyboardInteractiveCancelled = false
private openCredentialPrompts = 0
// A rejected cached password must reach the user on the next round.
private keyboardInteractivePasswordState = { passwordAutoAnswered: false }
private hostKeyFingerprint: string | undefined
private connectGeneration = 0
constructor(
target: SshTarget,
callbacks: SshConnectionCallbacks,
credentials?: SshCachedCredentials
) {
this.target = target
this.callbacks = callbacks
this.cachedPassword = credentials?.password ?? null
this.cachedPassphrase = credentials?.passphrase ?? null
this.signInVerified = credentials !== undefined
this.state = {
targetId: target.id,
status: 'disconnected',
error: null,
reconnectAttempt: 0,
supportsFolderDownload: false
}
}
getState(): SshConnectionState {
return { ...this.state }
}
getConnectGeneration(): number {
return this.connectGeneration
}
getClient(): SshClient | null {
return this.client
}
/** Names the connection and channel kind when a tracked channel's error has no other handler. */
private reportUnhandledChannelError(kind: string): (error: Error) => void {
return (error) =>
console.warn(
`[ssh] Unhandled ${kind} channel error for ${this.target.label}: ${error.message}`
)
}
/** Work after disconnect() fails with the operation abort error. */
private assertNotDisposed(): void {
if (this.disposed) {
throw createSshOperationAbortError()
}
}
private async runAdmitted<T>(operation: () => Promise<T>): Promise<T> {
this.assertNotDisposed()
return operation()
}
usesSystemSshTransport(): boolean {
return this.useSystemSshTransport
}
canRunConcurrentExecCommands(): boolean {
if (!this.useSystemSshTransport) {
return true
}
return (
getOrcaControlSocketPath(this.target, {
...this.getSystemSshBuildArgsOptions()
}) !== null
)
}
getTarget(): SshTarget {
return { ...this.target }
}
getHostKeyFingerprint(): string | undefined {
// Why: system SSH does not expose its negotiated key; a fingerprint from a
// failed ssh2 attempt may identify a different load-balanced execution host.
return this.useSystemSshTransport ? undefined : this.hostKeyFingerprint
}
setCallbacks(callbacks: SshConnectionCallbacks): void {
this.callbacks = callbacks
}
// Why: lets ssh:needsPassphrasePrompt skip redundant passphrase prompts on reconnect when the credential is already cached in-memory.
hasCachedCredential(): boolean {
return this.cachedPassphrase != null || this.cachedPassword != null
}
/** The user is answering a prompt, or answered one this connection's own retry has yet to use. */
isAwaitingCredential(): boolean {
if (this.openCredentialPrompts > 0) {
return true
}
const retrying = this.state.status === 'connecting' || this.state.status === 'reconnecting'
return retrying && !this.signInVerified && this.hasCachedCredential()
}
/** Null unless the host accepted it, so a wrong password is asked for again, not replayed. */
getReusableCredentials(): SshCachedCredentials | null {
if (!this.signInVerified || !this.hasCachedCredential()) {
return null
}
return { password: this.cachedPassword, passphrase: this.cachedPassphrase }
}
async exec(cmd: string, options?: SshExecOptions): Promise<ClientChannel> {
this.assertNotDisposed()
if (options?.signal?.aborted) {
throw createSshOperationAbortError()
}
if (this.useSystemSshTransport) {
if (this.disposed || this.state.status !== 'connected') {
throw new Error('Not connected')
}
return this.spawnTrackedSystemSshCommand(cmd, options)
}
if (!this.client) {
throw new Error('Not connected')
}
const client = this.client
const remoteCommand = options?.wrapCommand === false ? cmd : wrapRemoteCommandForPosixShell(cmd)
return openSshSessionChannelWithRetry(() => {
this.assertNotDisposed()
return waitForSshChannelOpen(
'SSH exec channel timed out',
(callback) => client.exec(remoteCommand, callback),
(channel) => channel.close(),
options?.signal,
true,
this.reportUnhandledChannelError('exec')
)
}, options?.signal)
}
/** Interactive login shell over a session channel with pty-req; ssh2 transport only. */
async shell(pty: PseudoTtyOptions, options: ShellOptions = {}): Promise<ClientChannel> {
this.assertNotDisposed()
if (this.useSystemSshTransport) {
throw new Error('Interactive SSH shells are not available when using system SSH transport')
}
if (!this.client) {
throw new Error('Not connected')
}
const client = this.client
return openSshSessionChannelWithRetry(() => {
this.assertNotDisposed()
return waitForSshChannelOpen(
'SSH shell channel timed out',
(callback) => client.shell(pty, options, callback),
(channel) => channel.close(),
undefined,
false,
this.reportUnhandledChannelError('shell')
)
})
}
async sftp(options?: AbortSignal | { signal?: AbortSignal }): Promise<SFTPWrapper> {
this.assertNotDisposed()
// Why: relay transfers pass a signal directly, while filesystem factories use an options object.
const signal = options && 'aborted' in options ? options : options?.signal
if (signal?.aborted) {
throw createSshOperationAbortError()
}
if (this.useSystemSshTransport) {
throw new Error('SFTP is not available when using system SSH transport')
}
if (!this.client) {
throw new Error('Not connected')
}
const client = this.client
return openSshSessionChannelWithRetry(() => {
this.assertNotDisposed()
return waitForSshChannelOpen(
'SSH SFTP channel timed out',
(callback) =>
client.sftp((error, sftp) => callback(error && tagSftpHandshakeCorruption(error), sftp)),
(sftp) => sftp.end(),
signal,
false,
this.reportUnhandledChannelError('sftp')
)
}, signal)
}
uploadDirectory(
localDir: string,
remoteDir: string,
options?: SshRemoteFileOptions & { signal?: AbortSignal }
): Promise<void> {
return this.runAdmitted(() =>
uploadSshDirectory(this.fileTransferHost(), localDir, remoteDir, options)
)
}
downloadFile(
remotePath: string,
localPath: string,
options?: SshRemoteFileOptions
): Promise<void> {
return this.runAdmitted(() =>
downloadSshFile(this.fileTransferHost(), remotePath, localPath, options)
)
}
async openFileUploadSession(options?: SshRemoteFileOptions): Promise<FileUploadSession> {
const session = await this.runAdmitted(() =>
createSshFileUploadSession(this.fileTransferHost(), options)
)
return {
uploadFile: (...args) => this.runAdmitted(() => session.uploadFile(...args)),
close: () => session.close()
}
}
writeFile(
remotePath: string,
contents: string,
options?: SshRemoteFileOptions & { signal?: AbortSignal }
): Promise<void> {
return this.runAdmitted(() =>
writeSshFile(this.fileTransferHost(), remotePath, contents, options)
)
}
writeBuffer(
remotePath: string,
contents: Buffer,
options?: SshRemoteFileOptions & { append?: boolean; exclusive?: boolean }
): Promise<void> {
return this.runAdmitted(() =>
writeSshBuffer(this.fileTransferHost(), remotePath, contents, options)
)
}
private fileTransferHost(): SshFileTransferHost {
return {
target: this.target,
usesSystemSshTransport: () => this.useSystemSshTransport,
systemOperationSignal: () => this.systemOperationAbortController.signal,
systemSshBuildArgsOptions: () => this.getSystemSshBuildArgsOptions(),
sftp: (signal) => this.sftp(signal)
}
}
async connect(): Promise<void> {
if (this.disposed) {
throw new Error('Connection disposed')
}
let lastError: Error | null = null
for (let attempt = 0; attempt < INITIAL_RETRY_ATTEMPTS; attempt++) {
const connectGeneration = ++this.connectGeneration
try {
await this.attemptConnect(connectGeneration)
this.reconnectLadder.reset()
this.reconnectLadder.markConnected(Date.now())
return
} catch (err) {
lastError = err instanceof Error ? err : new Error(String(err))
// Why: a concurrent disconnect() already set 'disconnected'; a cancelled attempt's late error must not overwrite it with auth-failed/error.
if (this.disposed) {
throw lastError
}
// Why: a newer attempt owns the connection now and publishes its own outcome; retrying here would fight it.
if (
!this.isCurrentConnectAttempt(connectGeneration) ||
isCancelledConnectAttemptError(lastError)
) {
throw lastError
}
if (isAuthError(lastError) || isPassphraseError(lastError)) {
this.setState('auth-failed', lastError.message)
throw lastError
}
if (!isTransientError(lastError)) {
this.setState('error', lastError.message)
throw lastError
}
if (attempt < INITIAL_RETRY_ATTEMPTS - 1) {
await sleep(INITIAL_RETRY_DELAY_MS)
}
}
}
const finalError = lastError ?? new Error('Connection failed')
this.setState('error', finalError.message)
throw finalError
}
// Why: callers claim the generation so their catch can tell their own failure from a superseded one.
/**
* A credential prompt, unless this attempt has already been superseded.
*
* Gated here rather than at each ladder rung because every rung ends in this one call, and the
* rungs below the first are exactly where the generation check was missing: a superseded attempt
* is denied by the verifier WITHOUT a recorded decision (deliberate — a dead attempt must not
* clobber the live one's outcome), so `isHostKeyVerificationError` reads false and the fallback
* runs on. The user then gets a passphrase dialog for a connection nobody is waiting on, possibly
* racing the live attempt's own. Returning undefined drops each rung through to its throw.
*/
private async requestCredential(
kind: SshCredentialKind,
detail: string,
connectGeneration: number,
echo?: boolean
): Promise<string | null | undefined> {
if (!this.isCurrentConnectAttempt(connectGeneration)) {
return undefined
}
this.openCredentialPrompts += 1
try {
return await this.callbacks.onCredentialRequest?.(
this.target.id,
kind,
detail,
echo,
this.credentialAbortController.signal
)
} finally {
this.openCredentialPrompts -= 1
}
}
private async answerKeyboardInteractive(
hostDetail: string,
name: string,
instructions: string,
prompts: Prompt[],
connectGeneration: number,
onPromptStart: () => void
): Promise<string[] | null> {
if (prompts.length === 0 || prompts.length > SSH_KEYBOARD_INTERACTIVE_MAX_PROMPTS) {
return null
}
const heading = [name.trim(), instructions.trim()].filter(Boolean).join('\n')
const isCurrent = (): boolean => !this.disposed && connectGeneration === this.connectGeneration
const responses = await collectKeyboardInteractiveResponses(
{
targetId: this.target.id,
hostDetail,
// Preserve a missing prompter as a capability gap, not a cancellation.
requestCredential: this.callbacks.onCredentialRequest
? async (_targetId, kind, detail, echo) =>
(await this.requestCredential(kind, detail, connectGeneration, echo)) ?? null
: undefined,
getCachedPassword: () => this.cachedPassword,
setCachedPassword: (value) => {
if (isCurrent()) {
this.cachedPassword = value
this.signInVerified = false
}
},
markCachedPasswordRejected: () => {
if (isCurrent()) {
this.signInVerified = false
}
},
markCancelled: () => {
// A stale prompt must not cancel the current attempt.
if (isCurrent()) {
this.keyboardInteractiveCancelled = true
}
},
isCancelled: () => !isCurrent() || this.keyboardInteractiveCancelled,
state: this.keyboardInteractivePasswordState
},
heading,
prompts,
onPromptStart
)
return isCurrent() ? responses : null
}
private async attemptConnect(connectGeneration = ++this.connectGeneration): Promise<void> {
this.credentialAbortController.abort()
this.credentialAbortController = new AbortController()
this.setState('connecting')
this.killProxy()
this.keyboardInteractiveCancelled = false
this.keyboardInteractivePasswordState = { passwordAutoAnswered: false }
const resolved = await resolveWithSshG(this.target.configHost || this.target.label).catch(
() => null
)
this.hostKeyResolvedConfig = resolved
const usesConfiguredSystemTransport = shouldUseSystemSshTransport(this.target, resolved)
const requiresSecurityKeyTransport = usesConfiguredSystemTransport
? false
: await requiresSystemSshForSecurityKey(this.target, resolved)
if (!this.isCurrentConnectAttempt(connectGeneration)) {
throw createCancelledConnectAttemptError()
}
if (usesConfiguredSystemTransport || requiresSecurityKeyTransport) {
await this.doSystemSshProbeWithControlMasterRetry(connectGeneration, resolved)
return
}
// Why: ssh2 lacks gssapi-with-mic; GSSAPIAuthentication hosts try Kerberos SSO via system OpenSSH first, then fall through to key/credential auth.
if (
isOpenSshConfigBackedTarget(this.target) && resolved
? resolved.gssapiAuthentication === true
: this.target.gssapiAuthentication === true
) {
try {
await this.doSystemSshProbeWithControlMasterRetry(connectGeneration, resolved, true)
return
} catch (probeErr) {
if (!this.isCurrentConnectAttempt(connectGeneration)) {
throw probeErr
}
}
}
// Why: a synchronous spawn throw bypasses the probe's catch, so clear system-transport state here or exec/sftp keep routing through the failed transport.
this.resetSystemTransport()
const config = buildConnectConfig(this.target, resolved)
// Why: ssh2 doesn't support ProxyCommand/ProxyJump natively; spawn the resolved proxy and pipe its stdin/stdout as config.sock.
const effectiveProxy = resolveEffectiveProxy(this.target, resolved)
if (effectiveProxy) {
const proxy = spawnProxyCommand(effectiveProxy, config.host!, config.port!, config.username!)
this.proxyProcess = proxy.process
config.sock = proxy.sock
}
if (this.cachedPassphrase) {
config.passphrase = this.cachedPassphrase
}
if (this.cachedPassword) {
config.password = this.cachedPassword
}
try {
await this.doSsh2Connect(config, connectGeneration)
} catch (err) {
if (!(err instanceof Error)) {
this.killProxyIfCurrent(connectGeneration)
throw err
}
// Why generation and not the error: a superseded attempt's host key denial is deliberately
// NOT recorded — the verifier withholds it so a dead attempt cannot clobber the live one's
// outcome — so nothing about the error identifies it, and ssh2's generic handshake failure
// reads as an ordinary fault. Everything below either offers a credential or probes another
// transport, and doing that for a connection nobody is waiting on can put a passphrase prompt
// in front of a host we just refused. Checking the generation catches it whatever the error
// turned out to be, and leaves what connect() rejects with unchanged.
if (!this.isCurrentConnectAttempt(connectGeneration)) {
this.killProxyIfCurrent(connectGeneration)
throw err
}
// Why first: everything below this line either offers a credential or retries on another
// transport. We have just decided this host may not be the one it claims to be, so prompting
// would collect the user's passphrase or password on its behalf, and the system-ssh probe
// would connect anyway whenever the disagreement is with our own store rather than
// known_hosts. A denied key is final for this attempt.
if (isHostKeyVerificationError(err)) {
this.killProxyIfCurrent(connectGeneration)
throw err
}
// Cancellation must stop the credential and transport fallback chain.
if (this.keyboardInteractiveCancelled) {
this.killProxyIfCurrent(connectGeneration)
throw err
}
if (isSystemSshFallbackError(err)) {
this.killProxyIfCurrent(connectGeneration)
try {
// Why: on macOS, per-app network policy can block Orca's direct TCP socket while the system OpenSSH binary is still allowed.
await this.doSystemSshProbeWithControlMasterRetry(connectGeneration, resolved)
return
} catch {
this.resetSystemTransportIfCurrent(connectGeneration)
throw err
}
}
let authError = err
let passphrasePromptHandled = false
let credentialRetryConfig = config
// Why: ssh2 parses encrypted privateKey before agent auth; when an agent exists, let it try first and fall back to direct key parsing only if it fails.
if (isAgentFallbackError(authError) && config.agent && !config.privateKey) {
const keyConfig = buildConnectConfig(this.target, resolved, {
includeAgent: false,
includePrivateKey: true
})
// Why: if the agent path failed, password/passphrase retries must not reuse the same agent-only config.
credentialRetryConfig = keyConfig
if (this.cachedPassphrase) {
keyConfig.passphrase = this.cachedPassphrase
}
if (this.cachedPassword) {
keyConfig.password = this.cachedPassword
}
if (keyConfig.privateKey || keyConfig.password) {
this.respawnProxy(keyConfig, effectiveProxy, connectGeneration)
try {
await this.doSsh2Connect(keyConfig, connectGeneration)
return
} catch (keyErr) {
// Same reason as above: the retry re-runs the handshake, so it can be the attempt that
// denies the key, and the passphrase prompt is directly below.
if (!(keyErr instanceof Error) || isHostKeyVerificationError(keyErr)) {
this.killProxyIfCurrent(connectGeneration)
throw keyErr
}
// Key fallback must honor the same cancellation boundary.
if (this.keyboardInteractiveCancelled) {
this.killProxyIfCurrent(connectGeneration)
throw keyErr
}
authError = keyErr
const passphraseKeyPath = getPassphrasePrivateKeyPath(keyConfig)
// Why: with GSSAPI enabled, let the reactive system-ssh probe try a Kerberos ticket before prompting for the passphrase; the prompt still runs if it fails.
if (
(isPassphraseError(authError) || passphraseKeyPath) &&
!this.cachedPassphrase &&
!isGssapiSystemSshFallbackCandidate(authError, this.target, resolved)
) {
passphrasePromptHandled = true
if (
await this.promptPassphraseAndRetry(
keyConfig,
passphraseKeyPath,
resolved,
effectiveProxy,
connectGeneration
)
) {
return
}
}
// Why: a strict MaxAuthTries host can drop the key retry before it reaches the challenge
// the agent-first attempt held back for that key, so offer the challenge on its own.
if (
isAgentFallbackError(authError) &&
didDeferKeyboardInteractive(config) &&
!didOfferKeyboardInteractive(keyConfig)
) {
const challengeConfig = buildConnectConfig(this.target, resolved, {
keyboardInteractiveOnly: true
})
this.respawnProxy(challengeConfig, effectiveProxy, connectGeneration)
try {
await this.doSsh2Connect(challengeConfig, connectGeneration)
return
} catch (challengeErr) {
if (
!(challengeErr instanceof Error) ||
isHostKeyVerificationError(challengeErr) ||
this.keyboardInteractiveCancelled
) {
this.killProxyIfCurrent(connectGeneration)
throw challengeErr
}
authError = challengeErr
}
}
}
}
}
// Why: a Kerberos ticket may authenticate where keys did not; try the system ssh binary before falling back to interactive prompts.
if (isGssapiSystemSshFallbackCandidate(authError, this.target, resolved)) {
this.killProxyIfCurrent(connectGeneration)
try {
await this.doSystemSshProbeWithControlMasterRetry(connectGeneration, resolved, true)
return
} catch {
this.resetSystemTransportIfCurrent(connectGeneration)
}
// Why: if a disconnect/reconnect superseded this attempt mid-probe, throw the cancellation error (not the stale authError) so connect() doesn't post auth-failed.
if (!this.isCurrentConnectAttempt(connectGeneration)) {
throw createCancelledConnectAttemptError()
}
}
if (!this.callbacks.onCredentialRequest) {
this.killProxyIfCurrent(connectGeneration)
throw authError
}
// Why: prompt for passphrase on encrypted-key error, then retry with a fresh proxy socket (ssh2 may have destroyed the original).
const passphraseKeyPath = getPassphrasePrivateKeyPath(credentialRetryConfig)
if (
(isPassphraseError(authError) || passphraseKeyPath) &&
!this.cachedPassphrase &&
!passphrasePromptHandled
) {
if (
await this.promptPassphraseAndRetry(
credentialRetryConfig,
passphraseKeyPath,
resolved,
effectiveProxy,
connectGeneration
)
) {
return
}
}
// Why: an agent socket failure can still be recovered by password auth, but the retry must use the no-agent config selected above.
if (isAgentFallbackError(authError) && !this.cachedPassword) {
const val = await this.requestCredential(
'password',
config.host || this.target.label,
connectGeneration
)
if (val) {
this.cachedPassword = val
this.signInVerified = false
credentialRetryConfig.password = val
this.respawnProxy(credentialRetryConfig, effectiveProxy, connectGeneration)
await this.doSsh2Connect(credentialRetryConfig, connectGeneration)
return
}
}
this.killProxyIfCurrent(connectGeneration)
throw authError
}
}
/** False when the user supplied no passphrase; a failed retry throws. */
private async promptPassphraseAndRetry(
retryConfig: ConnectConfig,
passphraseKeyPath: string | null | undefined,
resolved: SshResolvedConfig | null,
effectiveProxy: ReturnType<typeof resolveEffectiveProxy>,
connectGeneration: number
): Promise<boolean> {
const detail =
passphraseKeyPath || this.target.identityFile || resolved?.identityFile?.[0] || '(unknown)'
const val = await this.requestCredential('passphrase', detail, connectGeneration)
if (!val) {
return false
}
this.cachedPassphrase = val
this.signInVerified = false
retryConfig.passphrase = val
this.respawnProxy(retryConfig, effectiveProxy, connectGeneration)
await this.doSsh2Connect(retryConfig, connectGeneration)
return true
}
async reconnect(): Promise<void> {
if (!this.mayRedial() || this.state.status === 'connecting') {
return
}
await this.restartReconnect()
}
/**
* Wake or network return: a spent ladder, a pending backoff, or a background handshake still
* running from before the route came back restarts now with a fresh budget, since attempts made
* while asleep (or in a macOS dark wake) say nothing about whether the host is reachable.
*/
retryAfterNetworkReturn(nowMs = Date.now()): boolean {
if (!this.mayRedial() || this.isAwaitingCredential()) {
return false
}
const { status } = this.state
const startedAt = this.backgroundAttemptStartedAtMs
const stalled =
status === 'reconnection-failed' ||
(status === 'reconnecting' && this.reconnectTimer !== null) ||
(status === 'connecting' &&
startedAt !== null &&
nowMs - startedAt >= STALE_BACKGROUND_ATTEMPT_MS)
if (!stalled) {
return false
}
void this.restartReconnect()
return true
}
private async restartReconnect(): Promise<void> {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer)
this.reconnectTimer = null
}
// Why: OS sleep/wake can leave ssh2 thinking a dead TCP socket is still connected; tear down and reconnect so the relay can reattach remote PTYs.
this.closeTransportsForReconnect()
this.state.reconnectAttempt = 0
this.reconnectLadder.reset()
this.setState('reconnecting')
await this.runReconnectAttempt()
}
private async doSystemSshProbe(connectGeneration: number): Promise<void> {
if (!this.isCurrentConnectAttempt(connectGeneration)) {
throw createCancelledConnectAttemptError()
}
this.useSystemSshTransport = true
this.client = null
this.killProxy()
// Why: this probe runs before remote platform detection; a raw echo works under POSIX shells, cmd.exe, and PowerShell, but `/bin/sh` wrapping does not.
const channel = this.spawnTrackedSystemSshCommand('echo ORCA-SYSTEM-SSH-OK', {
wrapCommand: false
})
try {
await new Promise<void>((resolve, reject) => {
let stdout = ''
let stderr = ''
let settled = false
const cleanup = (): void => {
clearTimeout(timeout)
channel.off('data', onStdoutData)
channel.stderr.off('data', onStderrData)
channel.off('error', onError)
channel.off('close', onClose)
this.systemCommandChannels.delete(channel)
}
const settle = (callback: () => void): void => {
if (settled) {
return
}
settled = true
cleanup()
callback()
}
const onStdoutData = (data: Buffer): void => {
stdout += data.toString('utf-8')
}
const onStderrData = (data: Buffer): void => {
stderr += data.toString('utf-8')
}
const onError = (err: Error): void => {
settle(() => reject(err))
}
const onClose = (code: number | null): void => {
settle(() => {
if (!this.isCurrentConnectAttempt(connectGeneration)) {
reject(createCancelledConnectAttemptError())
return
}
if (
(code === 0 && stdout.includes('ORCA-SYSTEM-SSH-OK')) ||
isGitHubRestrictedShellProbeSuccess(
this.target,
this.systemSshResolvedConfig,
code,
stderr
)
) {
this.setState('connected')
resolve()
return
}
reject(
new Error(
`System SSH probe failed${code != null ? ` (exit ${code})` : ''}.${stderr ? ` stderr: ${stderr.trim()}` : ''}`
)
)
})
}
const timeout = setTimeout(() => {
settle(() => {
channel.close()
reject(new Error('System SSH connection timed out'))
})
}, CONNECT_TIMEOUT_MS)
channel.on('data', onStdoutData)
channel.stderr.on('data', onStderrData)
channel.on('error', onError)
channel.on('close', onClose)
})
} catch (err) {
// Why: a superseded probe closes late; its cleanup must not strip the replacement's transport.
if (this.isCurrentConnectAttempt(connectGeneration)) {
this.useSystemSshTransport = false
this.systemSshResolvedConfig = null
}
throw err
}
}
private async doSystemSshProbeWithControlMasterRetry(
connectGeneration: number,
resolved: SshResolvedConfig | null,
gssapiOnly = false
): Promise<void> {
if (!this.isCurrentConnectAttempt(connectGeneration)) {
throw createCancelledConnectAttemptError()
}
this.systemSshResolvedConfig = cloneResolvedConfig(resolved)
this.systemSshControlMasterDisabledForSession = false
this.systemSshGssapiOnlyForSession = gssapiOnly
const controlPath = getOrcaControlSocketPath(this.target, {
resolvedConfig: this.systemSshResolvedConfig,
gssapiOnly: this.systemSshGssapiOnlyForSession
})
try {
await this.doSystemSshProbe(connectGeneration)
} catch (err) {
if (!controlPath || !this.isCurrentConnectAttempt(connectGeneration)) {
throw err
}
removeControlSocketPath(controlPath)
// Why: a timeout retry doubles the ladder step, but a stuck master can cause the first timeout.
if (
isDefiniteSystemSshHostFailure(err) ||
(err instanceof Error && (isAuthError(err) || isPassphraseError(err)))
) {
throw err
}
this.systemSshResolvedConfig = cloneResolvedConfig(resolved)
this.systemSshControlMasterDisabledForSession = true
try {
await this.doSystemSshProbe(connectGeneration)
} catch (retryErr) {
if (this.isCurrentConnectAttempt(connectGeneration)) {
this.systemSshControlMasterDisabledForSession = false
}
throw retryErr
}
}
}
private isCurrentConnectAttempt(connectGeneration: number): boolean {
return !this.disposed && connectGeneration === this.connectGeneration
}
private spawnTrackedSystemSshCommand(command: string, options?: SshExecOptions): ClientChannel {
if (options?.signal?.aborted) {
throw createSshOperationAbortError()
}
const buildArgsOptions = this.getSystemSshBuildArgsOptions()
const commandOptions =
options === undefined && Object.keys(buildArgsOptions).length === 0
? undefined
: { ...options, ...buildArgsOptions }
this.assertNotDisposed()
const channel =
commandOptions === undefined
? spawnSystemSshCommand(this.target, command)
: spawnSystemSshCommand(this.target, command, commandOptions)
trackSshConnectionChannelLifetime(channel, this.reportUnhandledChannelError('command'))
this.systemCommandChannels.add(channel)
const onAbort = (): void => {
channel.close()
}
const cleanup = (): void => {
options?.signal?.removeEventListener('abort', onAbort)
this.systemCommandChannels.delete(channel)
}
options?.signal?.addEventListener('abort', onAbort, { once: true })
channel.once('close', cleanup)
channel.once('error', cleanup)
return channel
}
getSystemSshBuildArgsOptions(): SystemSshBuildArgsOptions {
const options: SystemSshBuildArgsOptions = {}
if (this.systemSshResolvedConfig) {
options.resolvedConfig = this.systemSshResolvedConfig
}
// Why here and not inside buildSshArgs: the verdict reads ~/.ssh/config, and an arg builder
// that consults the filesystem answers differently on every machine, tests included.
if (this.target.configHost && !mayUserSshConfigClaimAlias(this.target.configHost)) {
options.aliasClaimedByConfig = false
}
if (this.systemSshControlMasterDisabledForSession) {
options.disableControlMaster = true
}
if (this.systemSshGssapiOnlyForSession) {
options.gssapiOnly = true
}
return options
}
// Why: ssh2 may destroy the proxy socket on auth failure, so credential retries need a fresh proxy process and Duplex stream.
private respawnProxy(
config: ConnectConfig,
proxy: ReturnType<typeof resolveEffectiveProxy> | null | undefined,
connectGeneration: number
): void {
if (!proxy) {
return
}
// Why: this.proxyProcess belongs to whichever attempt is current; a superseded one must not kill it.
if (!this.isCurrentConnectAttempt(connectGeneration)) {
throw createCancelledConnectAttemptError()
}
this.proxyProcess?.kill()
const p = spawnProxyCommand(proxy, config.host!, config.port!, config.username!)
this.proxyProcess = p.process
config.sock = p.sock
}
/** Reads known_hosts, our store and the site policy once per connect attempt. */
private loadHostKeyTrustSources(
connectGeneration: number,
hostKeyResolved: SshResolvedConfig | null,
sshGSuppressedSiteConfig: boolean
): Promise<HostKeyTrustSources> {
if (this.hostKeyTrustSources?.generation === connectGeneration) {
return this.hostKeyTrustSources.sources
}
const sources = (async (): Promise<HostKeyTrustSources> => {
const [siteMayRestrict, knownHostsEvidence, trustedHostKeys] = await Promise.all([
// Doubt wins on timeout for the same reason it wins on an unreadable file.
sshGSuppressedSiteConfig
? withTimeout(siteConfigMayRestrictHostKeys(), HOST_KEY_SOURCE_READ_TIMEOUT_MS, true)
: Promise.resolve(false),
// Why bounded: these reads sit AHEAD of client.connect, and readyTimeout only covers the
// handshake — nothing else wraps attemptConnect. A home directory on a stalled NFS or SMB
// mount makes readFile hang indefinitely, which left the connection wedged in `connecting`
// with no ladder entry and no recovery. The fallback is the one an unreadable file already
// produces: evidence withheld, so we connect as ssh does but record nothing, rather than the
// far worse "no hosts known" that would let a changed key through as first contact.
withTimeout(
resolveKnownHostsFiles(hostKeyResolved).then(loadKnownHostsEvidence),
HOST_KEY_SOURCE_READ_TIMEOUT_MS,
{ entries: [], unreadableFileCount: 1 }
),
// The catch sits INSIDE the timeout on purpose: withTimeout absorbs rejections into its
// fallback, so wrapping the other way round would silently swallow the warning below — the
// one signal that the store is unwired rather than merely slow.
withTimeout(
loadTrustedHostKeys().catch((err) => {
// Losing our own records is not a security regression: known_hosts is still consulted,
// so a changed key is still refused. A host trusted only by us degrades to first contact
// and is re-recorded, which reaches the same decision. Loud because it means the store
// is unwired.
console.warn('[ssh] host key store unavailable; falling back to known_hosts only:', err)
return []
}),
HOST_KEY_SOURCE_READ_TIMEOUT_MS,
[]
)
])
return { siteMayRestrict, knownHostsEvidence, trustedHostKeys }
})()
this.hostKeyTrustSources = { generation: connectGeneration, sources }
return sources
}
private async doSsh2Connect(config: ConnectConfig, connectGeneration: number): Promise<void> {
const hostKeyResolved = this.hostKeyResolvedConfig
const { host: hostKeyLookupHost, isHostKeyAlias } = resolveKnownHostsLookupHost(
hostKeyResolved,
config.host ?? ''
)
// `-F` suppresses /etc/ssh/ssh_config, so a site-wide StrictHostKeyChecking is invisible to us
// on that path. Joined below with the other way a source can go silent: an unreadable file.
//
// Gated on `ssh -G` having actually produced something. sshGArgsForHost only reports which args
// WOULD be used, so without this a machine where ssh is missing, broken or timed out was judged
// by whether it happens to have a ~/.ssh/config — incoherently, since the same broken machine
// WITHOUT one was fully permissive. The flag is a claim about a config file we could not read,
// and when ssh never ran there is no such claim to make.
const sshGSuppressedSiteConfig =
hostKeyResolved !== null &&
sshGArgsForHost(this.target.configHost || this.target.label).includes('-F')
// Being blind to the site policy is only a reason to refuse if the site config could actually be
// setting one. ssh cannot answer that — `-F` excludes the system config as surely as the user's,
// and `-F /dev/null` reports built-in defaults rather than inverting the exclusion — so the file
// is read instead, conservatively: any doubt keeps the refusal.
const { siteMayRestrict, knownHostsEvidence, trustedHostKeys } =
await this.loadHostKeyTrustSources(
connectGeneration,
hostKeyResolved,
sshGSuppressedSiteConfig
)
const siteConfigSuppressed = siteMayRestrict
const knownHostsEntries = knownHostsEvidence.entries
// Null means ssh2's own list was unreadable, so there is nothing safe to reorder against and
// ssh2 keeps proposing whatever it supports.
const serverHostKeyOrder = DEFAULT_SERVER_HOST_KEY_ALGORITHMS
? orderServerHostKeyAlgorithms(
knownHostsEntries,
hostKeyLookupHost,
config.port ?? 22,
DEFAULT_SERVER_HOST_KEY_ALGORITHMS,
storedKeyTypesForEndpoint(trustedHostKeys, hostKeyLookupHost, config.port ?? 22),
isHostKeyAlias
)
: undefined
return new Promise<void>((resolve, reject) => {
const client = new SshClient()
this.pendingSsh2Clients.add(client)
let settled = false
let startupTimer: ReturnType<typeof setTimeout> | null = null
let keyboardInteractiveRounds = 0
let keyboardInteractiveAnswer: Promise<void> | null = null
const clearStartupTimer = (): void => {
if (startupTimer) {
clearTimeout(startupTimer)
startupTimer = null
}
}
const rearmStartupTimer = (timeoutMs: number): void => {
clearStartupTimer()
startupTimer = setTimeout(() => {
const error = new Error('Timed out while waiting for SSH authentication')
Object.assign(error, { level: 'client-timeout' })
onStartupError(error)
}, timeoutMs)
}
// Local to this attempt: an instance field would let a superseded attempt's rejection replace
// the live attempt's error, and substituting a new Error drops ssh2's `code`, so a transient
// ECONNRESET would stop being classified as retryable.
let hostKeyRejection: HostKeyVerificationError | null = null
// Why the fingerprint is still recorded: the relay uses the negotiated server key to isolate
// shared-home install locks without comparing PIDs from an unrelated SSH host. Its format is
// load-bearing across versions, which is a second reason never to set ssh2's `hostHash`.
config.hostVerifier = createHostKeyVerifier({
host: hostKeyLookupHost,
port: config.port ?? 22,
// The name we looked up, not the label: the mismatch message prints `ssh-keygen -R <host>`
// and a label removes nothing from known_hosts.
displayHost: hostKeyLookupHost,
hostKeyStoreFile: boundSshHostKeyStoreFile() ?? undefined,
strictHostKeyChecking: hostKeyResolved?.strictHostKeyChecking ?? 'ask',
isHostKeyAlias,
isEphemeralRuntimeTarget: isEphemeralRuntimeSshOwner(this.target.owner),
siteConfigSuppressed,
// A file that EXISTS and will not open is the absence of evidence, not evidence of a new
// host — the entry that would have said "this key changed" may be in it. An ABSENT file is
// not this: that is the normal state for a fresh profile and genuinely means nothing is
// known. The decision connects anyway, as ssh does, but records nothing.
knownHostsUnreadable: knownHostsEvidence.unreadableFileCount > 0,
entries: knownHostsEntries,
// The store's own matcher, against records preloaded above because ssh2's verifier decides
// synchronously. Reimplementing the comparison here is exactly how the type downgrade got
// in, and how the host normalisation drifted from the one used to write the records.
isTrusted: (query) => matchTrustedHostKeys(trustedHostKeys, query),
rememberHostKey: (record) => {
void trustHostKey({
host: record.host,
port: record.port,
keyType: record.keyType,
key: record.key
}).catch((err) => {
// A failed write must not fail the connection: we verified the key, we just could not
// record it, so the next connect re-derives the same answer from known_hosts.
console.warn('[ssh] failed to record accepted host key:', err)
})
},
isCurrentAttempt: () => !this.disposed && connectGeneration === this.connectGeneration,
onDecision: (decision) => {
// Empty when the blob was not a readable host key: there is nothing to identify, and the
// relay uses this fingerprint to isolate install locks, so blanking it would be worse
// than leaving the previous one. A REJECTED key is excluded for the same reason read the
// other way: we refused to talk to that host, so adopting its fingerprint would scope the
// relay's locks to a host we never accepted.
if (
decision.fingerprint &&
decision.action !== 'reject' &&
!this.disposed &&
connectGeneration === this.connectGeneration
) {
this.hostKeyFingerprint = decision.fingerprint
}
if (decision.action === 'reject') {
hostKeyRejection = new HostKeyVerificationError(
decision.reason ?? 'Host key verification failed.',
decision.outcome
)
}
}
})
if (serverHostKeyOrder) {
config.algorithms = {
...config.algorithms,
serverHostKey: serverHostKeyOrder as NonNullable<
NonNullable<ConnectConfig['algorithms']>['serverHostKey']
>
}
}
const onKeyboardInteractive = (
name: string,
instructions: string,
_instructionsLanguage: string,
prompts: Prompt[],
finish: KeyboardInteractiveCallback
): void => {
keyboardInteractiveRounds += 1
if (keyboardInteractiveRounds > SSH_KEYBOARD_INTERACTIVE_MAX_ROUNDS) {
finish([])
return
}
rearmStartupTimer(SSH_KEYBOARD_INTERACTIVE_READY_TIMEOUT_MS)
const answer = this.answerKeyboardInteractive(
config.host || this.target.label,
name,
instructions,
prompts,
connectGeneration,
() => rearmStartupTimer(SSH_KEYBOARD_INTERACTIVE_READY_TIMEOUT_MS)
).then(
(responses) => {
// Settle cancellation immediately, but still answer ssh2’s pending callback.
if (
responses === null &&
(connectGeneration !== this.connectGeneration || this.keyboardInteractiveCancelled)
) {
finish([])
if (!settled) {
// Preserve the auth-failed state for explicit cancellation.
const cancelledError = Object.assign(
new Error(
`Keyboard-interactive authentication cancelled for ${this.target.label}`
),
{ level: 'client-authentication' }
)
failStartup(cancelledError)
}
return
}
const attemptIsCurrent =
!settled && !this.disposed && connectGeneration === this.connectGeneration
finish(attemptIsCurrent ? (responses ?? []) : [])
},
() => finish([])
)
keyboardInteractiveAnswer = answer
const forgetAnswer = (): void => {
if (keyboardInteractiveAnswer === answer) {
keyboardInteractiveAnswer = null
}
}
void answer.then(forgetAnswer, forgetAnswer)
}
const cleanupStartupListeners = (): void => {
client.off('ready', onReady)
client.off('error', onStartupError)
client.off('keyboard-interactive', onKeyboardInteractive)
client.off('close', onStartupClose)
this.pendingSsh2Clients.delete(client)
clearStartupTimer()
}
const swallowLateStartupError = (): void => {
// Why: ssh2 can emit another socket error while destroying a settled pre-handshake client.
}
const guardStartupDestroy = (): void => {
client.on('error', swallowLateStartupError)
}
const onReady = (): void => {
if (settled) {
return
}
// Why: connect() completion races with disconnect(); a late ready must not resurrect a torn-down client after generation/disposed changes.
if (!this.isCurrentConnectAttempt(connectGeneration)) {
settled = true
guardStartupDestroy()
cleanupStartupListeners()
client.end()
client.destroy()
reject(createCancelledConnectAttemptError())
return
}
settled = true
this.client = client
this.proxyProcess = null
this.setupDisconnectHandler(client)
cleanupStartupListeners()
// Why: ssh2 leaves Nagle on; enable TCP_NODELAY so keystrokes don't stack with delayed-ACK (~40ms each). No-op for proxy sockets.
client.setNoDelay(true)
this.setState('connected')
resolve()
}
// Why: a host that drops an unanswered login (sshd LoginGraceTime) must not close the open
// prompt for a retry that opens another; the user's answer decides, and a typed password is
// cached for that retry.
const onStartupError = (err: Error): void => {
if (keyboardInteractiveAnswer && !settled) {
const fail = (): void => failStartup(err)
void keyboardInteractiveAnswer.then(fail, fail)
return
}
failStartup(err)
}
const failStartup = (err: Error): void => {
if (settled) {
return
}
guardStartupDestroy()
cleanupStartupListeners()
settled = true
client.destroy()
// Why replace the error: ssh2 reports a denied host key as a generic handshake failure,
// which the reconnect ladder cannot distinguish from a transient fault and would retry
// forever against a decision that will never change.
reject(hostKeyRejection ?? err)
}
const onStartupClose = (): void => {
if (settled) {
return
}
const error = !this.isCurrentConnectAttempt(connectGeneration)
? createCancelledConnectAttemptError()
: Object.assign(new Error('SSH connection closed during authentication'), {
code: 'ECONNRESET'
})
onStartupError(error)
}
client.on('keyboard-interactive', onKeyboardInteractive)
client.on('ready', onReady)
client.on('error', onStartupError)
client.on('close', onStartupClose)
rearmStartupTimer(config.readyTimeout ?? CONNECT_TIMEOUT_MS)
client.connect({ ...config, readyTimeout: 0 })
})
}
// Why: guard on identity so a late event from the old client can't null out a successful reconnect.
private setupDisconnectHandler(client: SshClient): void {
const onDrop = () => {
if (this.disposed || this.client !== client) {
return
}
this.client = null
this.scheduleReconnect()
}
client.on('end', onDrop)
client.on('close', onDrop)
client.on('error', (err) => {
if (this.disposed || this.client !== client) {
return
}
console.warn(`[ssh] Connection error for ${this.target.label}: ${err.message}`)
this.client = null
this.scheduleReconnect()
})
}
// Why: a user's Disconnect is recorded before its teardown disposes this connection, which can
// wait behind the target's lifecycle; until then a drop, backoff or wake must not redial.
private mayRedial(): boolean {
return !this.disposed && !isSshUserDisconnected(this.target.id)
}
private scheduleReconnect(): void {
if (!this.mayRedial() || this.reconnectTimer) {
return
}
const decision = this.reconnectLadder.next(Date.now())
if (decision.kind === 'give-up') {
this.setState('reconnection-failed', 'Max reconnection attempts reached')
return
}
this.state.reconnectAttempt = decision.attemptIndex
this.setState('reconnecting')
console.warn(
`[ssh] Reconnecting to ${this.target.label} in ${decision.delayMs}ms (delay step ${decision.attemptIndex + 1}/${RECONNECT_BACKOFF_MS.length}, failed handshakes ${this.reconnectLadder.failedAttemptStreak}/${RECONNECT_BACKOFF_MS.length})`
)
this.reconnectTimer = setTimeout(async () => {
this.reconnectTimer = null
if (!this.mayRedial()) {
return
}
await this.runReconnectAttempt()
}, decision.delayMs)
}
private async runReconnectAttempt(): Promise<void> {
const connectGeneration = ++this.connectGeneration
this.backgroundAttemptStartedAtMs = Date.now()
try {
// Why: reset before connecting so the 'connected' broadcast carries reconnectAttempt=0, which ssh.ts uses to trigger relay re-establishment.
this.state.reconnectAttempt = 0
await this.attemptConnect(connectGeneration)
if (!this.isCurrentConnectAttempt(connectGeneration)) {
return
}
// Why: attemptConnect resolves only after a real handshake on either transport; the system-ssh probe's 'connected' must not clear the failure streak.
this.reconnectLadder.markConnected(Date.now())
} catch (err) {
// Why: a superseded attempt has no outcome to publish — the attempt that claimed the generation owns the state, and cancellation is that supersession.
if (!this.isCurrentConnectAttempt(connectGeneration)) {
return
}
const error = err instanceof Error ? err : new Error(String(err))
if (isCancelledConnectAttemptError(error)) {
return
}
if (isAuthError(error) || isPassphraseError(error)) {
this.setState('auth-failed', error.message)
return
}
// Why: the system-SSH transport reports timeouts as prose, so the narrow code table would strand FIDO2 targets in 'error' forever.
if (!isTransientReconnectError(error)) {
this.setState('error', error.message)
return
}
this.reconnectLadder.markAttemptFailed()
this.scheduleReconnect()
} finally {
if (this.connectGeneration === connectGeneration) {
this.backgroundAttemptStartedAtMs = null
}
}
}
private closePendingSsh2Clients(): void {
for (const client of this.pendingSsh2Clients) {
try {
client.end()
client.destroy()
} catch {
// The startup socket may already be closing.
}
}
this.pendingSsh2Clients.clear()
}
private closeTransportsForReconnect(): void {
this.connectGeneration += 1
const client = this.client
this.client = null
try {
client?.end()
client?.destroy()
} catch {
/* best-effort transport teardown */
}
this.credentialAbortController.abort()
this.credentialAbortController = new AbortController()
this.closeSessionTransports()
}
/** Teardown shared by reconnect and disconnect, after each has closed the ssh2 client. */
private closeSessionTransports(): void {
this.closePendingSsh2Clients()
this.killProxy()
this.systemOperationAbortController.abort()
this.systemOperationAbortController = new AbortController()
for (const channel of this.systemCommandChannels) {
channel.close()
}
this.systemCommandChannels.clear()
this.resetSystemTransport()
}
private resetSystemTransportIfCurrent(connectGeneration: number): void {
if (this.isCurrentConnectAttempt(connectGeneration)) {
this.resetSystemTransport()
}
}
private resetSystemTransport(): void {
this.systemSshResolvedConfig = null
this.systemSshControlMasterDisabledForSession = false
this.systemSshGssapiOnlyForSession = false
this.useSystemSshTransport = false
}
private killProxyIfCurrent(connectGeneration: number): void {
if (this.isCurrentConnectAttempt(connectGeneration)) {
this.killProxy()
}
}
private killProxy(): void {
this.proxyProcess?.kill()
this.proxyProcess = null
}
/** `quiet` tears down without publishing 'disconnected', keeping a failed startup's error. */
async disconnect(options?: { quiet?: boolean }): Promise<void> {
this.disposed = true
this.connectGeneration += 1
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer)
}
this.reconnectTimer = null
this.cachedPassphrase = null
this.cachedPassword = null
this.credentialAbortController.abort()
this.client?.end()
this.client = null
this.closeSessionTransports()
this.reconnectLadder.reset()
if (!options?.quiet) {
this.setState('disconnected')
}
}
private setState(status: SshConnectionStatus, error?: string): void {
if (status === 'auth-failed' || status === 'connected') {
this.signInVerified = status === 'connected'
}
this.state = {
...this.state,
status,
error: error ?? null,
supportsFolderDownload: status === 'connected' && !this.useSystemSshTransport
}
this.callbacks.onStateChange(this.target.id, { ...this.state })
}
}