fix: harden SSH connection reliability and add passphrase prompt (#636)

This commit is contained in:
Jinwoo Hong 2026-04-14 21:29:50 -04:00 committed by GitHub
parent 8c016c91b2
commit 42e04268fb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 2066 additions and 751 deletions

View file

@ -1,68 +0,0 @@
import { randomUUID } from 'crypto'
import { ipcMain, type BrowserWindow } from 'electron'
import type { SshConnectionCallbacks } from '../ssh/ssh-connection'
// Why: all three SSH auth callbacks (host-key-verify, auth-challenge, password)
// share the same IPC round-trip pattern: send a prompt event to the renderer,
// wait for a single response on a unique channel, clean up on timeout/close.
// Extracting the pattern into a generic helper avoids triplicating the cleanup
// logic and keeps ssh.ts under the max-lines threshold.
function promptRenderer<T>(
win: BrowserWindow,
sendChannel: string,
sendPayload: Record<string, unknown>,
fallback: T
): Promise<T> {
return new Promise<T>((resolve) => {
const responseChannel = `${sendChannel}-response-${randomUUID()}`
const onClosed = () => {
cleanup()
resolve(fallback)
}
const cleanup = () => {
ipcMain.removeAllListeners(responseChannel)
clearTimeout(timer)
win.removeListener('closed', onClosed)
}
const timer = setTimeout(() => {
cleanup()
resolve(fallback)
}, 120_000)
win.webContents.send(sendChannel, { ...sendPayload, responseChannel })
ipcMain.once(responseChannel, (_event, value: T) => {
cleanup()
resolve(value)
})
win.once('closed', onClosed)
})
}
export function buildSshAuthCallbacks(
getMainWindow: () => BrowserWindow | null
): Pick<SshConnectionCallbacks, 'onHostKeyVerify' | 'onAuthChallenge' | 'onPasswordPrompt'> {
return {
onHostKeyVerify: async (req) => {
const win = getMainWindow()
if (!win || win.isDestroyed()) {
return false
}
return promptRenderer<boolean>(win, 'ssh:host-key-verify', req, false)
},
onAuthChallenge: async (req) => {
const win = getMainWindow()
if (!win || win.isDestroyed()) {
return []
}
return promptRenderer<string[]>(win, 'ssh:auth-challenge', req, [])
},
onPasswordPrompt: async (targetId: string) => {
const win = getMainWindow()
if (!win || win.isDestroyed()) {
return null
}
return promptRenderer<string | null>(win, 'ssh:password-prompt', { targetId }, null)
}
}
}

View file

@ -0,0 +1,65 @@
import { ipcMain, type BrowserWindow } from 'electron'
import { randomUUID } from 'crypto'
import type { SshCredentialKind } from '../ssh/ssh-connection-utils'
const CREDENTIAL_TIMEOUT_MS = 120_000
const pendingRequests = new Map<string, { resolve: (value: string | null) => void }>()
function notifyCredentialResolved(
getMainWindow: () => BrowserWindow | null,
requestId: string
): void {
const win = getMainWindow()
if (win && !win.isDestroyed()) {
win.webContents.send('ssh:credential-resolved', { requestId })
}
}
export function requestCredential(
getMainWindow: () => BrowserWindow | null,
targetId: string,
kind: SshCredentialKind,
detail: string
): Promise<string | null> {
const requestId = randomUUID()
return new Promise((resolve) => {
const timer = setTimeout(() => {
if (pendingRequests.delete(requestId)) {
notifyCredentialResolved(getMainWindow, requestId)
resolve(null)
}
}, CREDENTIAL_TIMEOUT_MS)
pendingRequests.set(requestId, {
resolve: (value) => {
clearTimeout(timer)
resolve(value)
}
})
const win = getMainWindow()
if (win && !win.isDestroyed()) {
win.webContents.send('ssh:credential-request', { requestId, targetId, kind, detail })
} else {
pendingRequests.delete(requestId)
clearTimeout(timer)
notifyCredentialResolved(getMainWindow, requestId)
resolve(null)
}
})
}
export function registerCredentialHandler(getMainWindow: () => BrowserWindow | null): void {
ipcMain.removeHandler('ssh:submitCredential')
ipcMain.handle(
'ssh:submitCredential',
(_event, args: { requestId: string; value: string | null }) => {
const pending = pendingRequests.get(args.requestId)
if (pending) {
pendingRequests.delete(args.requestId)
notifyCredentialResolved(getMainWindow, args.requestId)
pending.resolve(args.value)
}
}
)
}

View file

@ -11,10 +11,11 @@ import { registerSshPtyProvider } from './pty'
import { registerSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
import { registerSshGitProvider } from '../providers/ssh-git-dispatch'
import { SshPortForwardManager } from '../ssh/ssh-port-forward'
import type { SshTarget, SshConnectionState } from '../../shared/ssh-types'
import type { SshTarget, SshConnectionState, SshConnectionStatus } from '../../shared/ssh-types'
import { isAuthError } from '../ssh/ssh-connection-utils'
import { cleanupConnection, wireUpSshPtyEvents, reestablishRelayStack } from './ssh-relay-helpers'
import { buildSshAuthCallbacks } from './ssh-auth-helpers'
import { registerSshBrowseHandler } from './ssh-browse'
import { requestCredential, registerCredentialHandler } from './ssh-passphrase'
let sshStore: SshConnectionStore | null = null
let connectionManager: SshConnectionManager | null = null
@ -32,6 +33,7 @@ const initializedConnections = new Set<string>()
// flash "connected" then "disconnected". Suppressing broadcasts during tests
// avoids that visual glitch.
const testingTargets = new Set<string>()
const explicitRelaySetupTargets = new Set<string>()
export function registerSshHandlers(
store: Store,
@ -59,7 +61,11 @@ export function registerSshHandlers(
sshStore = new SshConnectionStore(store)
registerCredentialHandler(getMainWindow)
const callbacks: SshConnectionCallbacks = {
onCredentialRequest: (targetId, kind, detail) =>
requestCredential(getMainWindow, targetId, kind, detail),
onStateChange: (targetId: string, state: SshConnectionState) => {
if (testingTargets.has(targetId)) {
return
@ -76,7 +82,8 @@ export function registerSshHandlers(
if (
state.status === 'connected' &&
state.reconnectAttempt === 0 &&
initializedConnections.has(targetId)
initializedConnections.has(targetId) &&
!explicitRelaySetupTargets.has(targetId)
) {
void reestablishRelayStack(
targetId,
@ -86,9 +93,7 @@ export function registerSshHandlers(
portForwardManager
)
}
},
...buildSshAuthCallbacks(getMainWindow)
}
}
connectionManager = new SshConnectionManager(callbacks)
@ -129,21 +134,23 @@ export function registerSshHandlers(
}
let conn
explicitRelaySetupTargets.add(args.targetId)
try {
conn = await connectionManager!.connect(target)
} catch (err) {
// Why: SshConnection.connect() sets its internal state to 'error', but
// the onStateChange callback may have been suppressed or the state may
// not have propagated to the renderer. Explicitly broadcast the error
// so the UI leaves 'connecting'/'host-key-verification'.
// Why: SshConnection.connect() sets its internal state, but the
// onStateChange callback may not have propagated to the renderer.
// Explicitly broadcast so the UI leaves 'connecting'.
const errObj = err instanceof Error ? err : new Error(String(err))
const status: SshConnectionStatus = isAuthError(errObj) ? 'auth-failed' : 'error'
const win = getMainWindow()
if (win && !win.isDestroyed()) {
win.webContents.send('ssh:state-changed', {
targetId: args.targetId,
state: {
targetId: args.targetId,
status: 'error',
error: err instanceof Error ? err.message : String(err),
status,
error: errObj.message,
reconnectAttempt: 0
}
})
@ -151,22 +158,17 @@ export function registerSshHandlers(
throw err
}
// Deploy relay and establish multiplexer
callbacks.onStateChange(args.targetId, {
targetId: args.targetId,
status: 'deploying-relay',
error: null,
reconnectAttempt: 0
})
try {
const { transport } = await deployAndLaunchRelay(conn, (status) => {
const win = getMainWindow()
if (win && !win.isDestroyed()) {
win.webContents.send('ssh:deploy-progress', { targetId: args.targetId, status })
}
// Deploy relay and establish multiplexer
callbacks.onStateChange(args.targetId, {
targetId: args.targetId,
status: 'deploying-relay',
error: null,
reconnectAttempt: 0
})
const { transport } = await deployAndLaunchRelay(conn)
const mux = new SshChannelMultiplexer(transport)
activeMultiplexers.set(args.targetId, mux)
@ -202,6 +204,8 @@ export function registerSshHandlers(
// Relay deployment failed — disconnect SSH
await connectionManager!.disconnect(args.targetId)
throw err
} finally {
explicitRelaySetupTargets.delete(args.targetId)
}
return connectionManager!.getState(args.targetId)

View file

@ -52,6 +52,12 @@ function normalizeSortBy(sortBy: unknown): 'name' | 'recent' | 'repo' {
return getDefaultUIState().sortBy
}
// Why: old persisted targets predate configHost. Default to label-based lookup
// so imported SSH aliases keep resolving through ssh -G after upgrade.
function normalizeSshTarget(t: SshTarget): SshTarget {
return { ...t, configHost: t.configHost ?? t.label ?? t.host }
}
export class Store {
private state: PersistedState
private writeTimer: ReturnType<typeof setTimeout> | null = null
@ -88,7 +94,7 @@ export class Store {
sortBy: normalizeSortBy(parsed.ui?.sortBy)
},
workspaceSession: { ...defaults.workspaceSession, ...parsed.workspaceSession },
sshTargets: parsed.sshTargets ?? []
sshTargets: (parsed.sshTargets ?? []).map(normalizeSshTarget)
}
}
} catch (err) {
@ -311,18 +317,17 @@ export class Store {
// ── SSH Targets ────────────────────────────────────────────────────
getSshTargets(): SshTarget[] {
return this.state.sshTargets ?? []
return (this.state.sshTargets ?? []).map(normalizeSshTarget)
}
getSshTarget(id: string): SshTarget | undefined {
return this.state.sshTargets?.find((t) => t.id === id)
const target = this.state.sshTargets?.find((t) => t.id === id)
return target ? normalizeSshTarget(target) : undefined
}
addSshTarget(target: SshTarget): void {
if (!this.state.sshTargets) {
this.state.sshTargets = []
}
this.state.sshTargets.push(target)
this.state.sshTargets ??= []
this.state.sshTargets.push(normalizeSshTarget(target))
this.scheduleSave()
}
@ -331,7 +336,7 @@ export class Store {
if (!target) {
return null
}
Object.assign(target, updates)
Object.assign(target, updates, normalizeSshTarget({ ...target, ...updates }))
this.scheduleSave()
return { ...target }
}

View file

@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { parseSshConfig, sshConfigHostsToTargets } from './ssh-config-parser'
import { parseSshConfig, sshConfigHostsToTargets, parseSshGOutput } from './ssh-config-parser'
vi.mock('os', () => ({
homedir: () => '/home/testuser'
@ -209,3 +209,105 @@ describe('sshConfigHostsToTargets', () => {
expect(targets[0].jumpHost).toBe('bastion.example.com')
})
})
// ── parseSshGOutput ──────────────────────────────────────────────────
describe('parseSshGOutput', () => {
it('parses hostname, user, port from ssh -G output', () => {
const output = [
'hostname 192.168.1.100',
'user deploy',
'port 2222',
'identityfile /home/testuser/.ssh/id_ed25519',
'forwardagent no'
].join('\n')
const result = parseSshGOutput(output)
expect(result.hostname).toBe('192.168.1.100')
expect(result.user).toBe('deploy')
expect(result.port).toBe(2222)
expect(result.identityFile).toEqual(['/home/testuser/.ssh/id_ed25519'])
expect(result.forwardAgent).toBe(false)
})
it('collects multiple identity files', () => {
const output = [
'hostname example.com',
'identityfile ~/.ssh/id_ed25519',
'identityfile ~/.ssh/id_rsa',
'port 22'
].join('\n')
const result = parseSshGOutput(output)
expect(result.identityFile).toEqual([
'/home/testuser/.ssh/id_ed25519',
'/home/testuser/.ssh/id_rsa'
])
})
it('parses forwardagent yes', () => {
const output = 'hostname example.com\nforwardagent yes\nport 22'
const result = parseSshGOutput(output)
expect(result.forwardAgent).toBe(true)
})
it('defaults port to 22 when missing', () => {
const output = 'hostname example.com'
const result = parseSshGOutput(output)
expect(result.port).toBe(22)
})
it('returns empty hostname when missing', () => {
const output = 'user admin\nport 22'
const result = parseSshGOutput(output)
expect(result.hostname).toBe('')
})
it('returns undefined user when missing', () => {
const output = 'hostname example.com\nport 22'
const result = parseSshGOutput(output)
expect(result.user).toBeUndefined()
})
it('handles empty output', () => {
const result = parseSshGOutput('')
expect(result.hostname).toBe('')
expect(result.port).toBe(22)
expect(result.identityFile).toEqual([])
})
it('skips lines without spaces', () => {
const output = 'hostname example.com\nbadline\nport 22'
const result = parseSshGOutput(output)
expect(result.hostname).toBe('example.com')
expect(result.port).toBe(22)
})
it('parses proxycommand and filters "none"', () => {
const output = 'hostname example.com\nproxycommand ssh -W %h:%p bastion\nport 22'
const result = parseSshGOutput(output)
expect(result.proxyCommand).toBe('ssh -W %h:%p bastion')
const noneOutput = 'hostname example.com\nproxycommand none\nport 22'
const noneResult = parseSshGOutput(noneOutput)
expect(noneResult.proxyCommand).toBeUndefined()
})
it('parses proxyjump and filters "none"', () => {
const output = 'hostname example.com\nproxyjump bastion.example.com\nport 22'
const result = parseSshGOutput(output)
expect(result.proxyJump).toBe('bastion.example.com')
const noneOutput = 'hostname example.com\nproxyjump none\nport 22'
const noneResult = parseSshGOutput(noneOutput)
expect(noneResult.proxyJump).toBeUndefined()
})
it('handles ~ expansion in identity file paths', () => {
const output = 'hostname example.com\nidentityfile ~/custom_key\nport 22'
const result = parseSshGOutput(output)
expect(result.identityFile).toEqual(['/home/testuser/custom_key'])
})
})
// Why: resolveWithSshG tests are in ssh-config-resolver.test.ts (max-lines).

View file

@ -1,4 +1,5 @@
import { readFileSync, existsSync } from 'fs'
import { execFile } from 'child_process'
import { join } from 'path'
import { homedir } from 'os'
import type { SshTarget } from '../../shared/ssh-types'
@ -137,6 +138,7 @@ export function sshConfigHostsToTargets(
targets.push({
id: `ssh-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
label,
configHost: entry.host,
host: effectiveHost,
port: entry.port ?? 22,
username: entry.user ?? '',
@ -148,3 +150,71 @@ export function sshConfigHostsToTargets(
return targets
}
// ── ssh -G config resolution ──────────────────────────────────────────
export type SshResolvedConfig = {
hostname: string
user?: string
port: number
identityFile: string[]
forwardAgent: boolean
proxyCommand?: string
proxyJump?: string
}
const SSH_G_TIMEOUT_MS = 5000
// Why: `ssh -G <host>` asks OpenSSH to resolve the full effective config
// for a host, including Include directives, Match blocks, wildcard
// inheritance, and ProxyCommand expansion. This gives us correct config
// resolution without reimplementing OpenSSH's complex matching logic.
export function resolveWithSshG(host: string): Promise<SshResolvedConfig | null> {
return new Promise((resolve) => {
// Why: '--' prevents a host label starting with '-' from being interpreted
// as an SSH flag (classic argument injection vector).
execFile('ssh', ['-G', '--', host], { timeout: SSH_G_TIMEOUT_MS }, (err, stdout) => {
if (err) {
resolve(null)
return
}
resolve(parseSshGOutput(stdout))
})
})
}
export function parseSshGOutput(stdout: string): SshResolvedConfig {
const map = new Map<string, string>()
const identityFiles: string[] = []
for (const line of stdout.split('\n')) {
const spaceIdx = line.indexOf(' ')
if (spaceIdx === -1) {
continue
}
const key = line.substring(0, spaceIdx).toLowerCase()
const value = line.substring(spaceIdx + 1).trim()
if (key === 'identityfile') {
identityFiles.push(resolveHomePath(value))
} else {
map.set(key, value)
}
}
// Why: `ssh -G` outputs `proxycommand none` / `proxyjump none` when no
// proxy is configured. Treating "none" as real would spawn bad commands.
const rawProxy = map.get('proxycommand')
const proxyCommand = rawProxy && rawProxy !== 'none' ? rawProxy : undefined
const rawJump = map.get('proxyjump')
const proxyJump = rawJump && rawJump !== 'none' ? rawJump : undefined
return {
hostname: map.get('hostname') ?? '',
user: map.get('user') || undefined,
port: parseInt(map.get('port') ?? '22', 10),
identityFile: identityFiles,
forwardAgent: map.get('forwardagent') === 'yes',
proxyCommand,
proxyJump
}
}

View file

@ -0,0 +1,64 @@
import { describe, expect, it, vi } from 'vitest'
import { resolveWithSshG } from './ssh-config-parser'
vi.mock('os', () => ({
homedir: () => '/home/testuser'
}))
vi.mock('child_process', () => ({
execFile: vi.fn()
}))
describe('resolveWithSshG', () => {
it('returns parsed config on success', async () => {
const { execFile } = await import('child_process')
const mockExecFile = vi.mocked(execFile)
mockExecFile.mockImplementation(
(_cmd: unknown, _args: unknown, _opts: unknown, cb: unknown) => {
const callback = cb as (err: Error | null, stdout: string) => void
callback(null, 'hostname 10.0.0.1\nuser admin\nport 22')
return undefined as never
}
)
const result = await resolveWithSshG('myhost')
expect(result).toBeDefined()
expect(result!.hostname).toBe('10.0.0.1')
expect(result!.user).toBe('admin')
})
it('calls ssh -G with the given host', async () => {
const { execFile } = await import('child_process')
const mockExecFile = vi.mocked(execFile)
mockExecFile.mockImplementation(
(_cmd: unknown, _args: unknown, _opts: unknown, cb: unknown) => {
const callback = cb as (err: Error | null, stdout: string) => void
callback(null, 'hostname example.com\nport 22')
return undefined as never
}
)
await resolveWithSshG('testserver')
expect(mockExecFile).toHaveBeenCalledWith(
'ssh',
['-G', '--', 'testserver'],
expect.objectContaining({ timeout: 5000 }),
expect.any(Function)
)
})
it('returns null when ssh -G fails', async () => {
const { execFile } = await import('child_process')
const mockExecFile = vi.mocked(execFile)
mockExecFile.mockImplementation(
(_cmd: unknown, _args: unknown, _opts: unknown, cb: unknown) => {
const callback = cb as (err: Error | null, stdout: string) => void
callback(new Error('ssh not found'), '')
return undefined as never
}
)
const result = await resolveWithSshG('myhost')
expect(result).toBeNull()
})
})

View file

@ -16,6 +16,7 @@ export class SshConnectionStore {
addTarget(target: Omit<SshTarget, 'id'>): SshTarget {
const full: SshTarget = {
...target,
configHost: target.configHost ?? target.host,
id: `ssh-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
}
this.store.addSshTarget(full)
@ -35,7 +36,7 @@ export class SshConnectionStore {
* Returns the newly imported targets.
*/
importFromSshConfig(): SshTarget[] {
const existingLabels = new Set(this.store.getSshTargets().map((t) => t.label))
const existingLabels = new Set(this.store.getSshTargets().map((t) => t.configHost ?? t.label))
const configHosts = loadUserSshConfig()
const newTargets = sshConfigHostsToTargets(configHosts, existingLabels)

View file

@ -0,0 +1,369 @@
import { afterEach, describe, expect, it, vi, beforeEach } from 'vitest'
vi.mock('os', () => ({
homedir: () => '/home/testuser'
}))
const mockExistsSync = vi.fn().mockReturnValue(false)
const mockReadFileSync = vi.fn()
vi.mock('fs', () => ({
existsSync: (...args: unknown[]) => mockExistsSync(...args),
readFileSync: (...args: unknown[]) => mockReadFileSync(...args)
}))
import {
isTransientError,
isAuthError,
sleep,
shellEscape,
findDefaultKeyFile,
buildConnectConfig,
resolveEffectiveProxy,
CONNECT_TIMEOUT_MS,
INITIAL_RETRY_ATTEMPTS,
INITIAL_RETRY_DELAY_MS,
RECONNECT_BACKOFF_MS
} from './ssh-connection-utils'
import type { SshTarget } from '../../shared/ssh-types'
import type { SshResolvedConfig } from './ssh-config-parser'
// ── Constants ────────────────────────────────────────────────────────
describe('SSH connection constants', () => {
it('CONNECT_TIMEOUT_MS is 30 seconds (matches VS Code)', () => {
expect(CONNECT_TIMEOUT_MS).toBe(30_000)
})
it('INITIAL_RETRY_ATTEMPTS is 5', () => {
expect(INITIAL_RETRY_ATTEMPTS).toBe(5)
})
it('INITIAL_RETRY_DELAY_MS is 2 seconds', () => {
expect(INITIAL_RETRY_DELAY_MS).toBe(2000)
})
it('RECONNECT_BACKOFF_MS has 9 entries', () => {
expect(RECONNECT_BACKOFF_MS).toHaveLength(9)
})
})
// ── isTransientError ─────────────────────────────────────────────────
describe('isTransientError', () => {
it('returns true for ETIMEDOUT code', () => {
const err = new Error('timed out') as NodeJS.ErrnoException
err.code = 'ETIMEDOUT'
expect(isTransientError(err)).toBe(true)
})
it('returns true for ECONNREFUSED code', () => {
const err = new Error('refused') as NodeJS.ErrnoException
err.code = 'ECONNREFUSED'
expect(isTransientError(err)).toBe(true)
})
it('returns true for ECONNRESET code', () => {
const err = new Error('reset') as NodeJS.ErrnoException
err.code = 'ECONNRESET'
expect(isTransientError(err)).toBe(true)
})
it('returns true for EHOSTUNREACH code', () => {
const err = new Error('host unreachable') as NodeJS.ErrnoException
err.code = 'EHOSTUNREACH'
expect(isTransientError(err)).toBe(true)
})
it('returns true for ENETUNREACH code', () => {
const err = new Error('net unreachable') as NodeJS.ErrnoException
err.code = 'ENETUNREACH'
expect(isTransientError(err)).toBe(true)
})
it('returns true for EAI_AGAIN code', () => {
const err = new Error('dns') as NodeJS.ErrnoException
err.code = 'EAI_AGAIN'
expect(isTransientError(err)).toBe(true)
})
it('returns true for ETIMEDOUT in message (no code)', () => {
expect(isTransientError(new Error('connect ETIMEDOUT 1.2.3.4:22'))).toBe(true)
})
it('returns true for ECONNREFUSED in message', () => {
expect(isTransientError(new Error('connect ECONNREFUSED 1.2.3.4:22'))).toBe(true)
})
it('returns true for ECONNRESET in message', () => {
expect(isTransientError(new Error('read ECONNRESET'))).toBe(true)
})
it('returns false for auth errors', () => {
expect(isTransientError(new Error('All configured authentication methods failed'))).toBe(false)
})
it('returns false for generic errors', () => {
expect(isTransientError(new Error('something went wrong'))).toBe(false)
})
})
// ── isAuthError ──────────────────────────────────────────────────────
describe('isAuthError', () => {
it('returns true for "All configured authentication methods failed"', () => {
expect(isAuthError(new Error('All configured authentication methods failed'))).toBe(true)
})
it('returns true for "Authentication failed"', () => {
expect(isAuthError(new Error('Authentication failed'))).toBe(true)
})
it('returns true for client-authentication level', () => {
const err = new Error('auth') as Error & { level: string }
err.level = 'client-authentication'
expect(isAuthError(err)).toBe(true)
})
it('returns false for transient errors', () => {
expect(isAuthError(new Error('connect ETIMEDOUT'))).toBe(false)
})
})
// ── sleep ────────────────────────────────────────────────────────────
describe('sleep', () => {
it('resolves after the given delay', async () => {
const start = Date.now()
await sleep(50)
expect(Date.now() - start).toBeGreaterThanOrEqual(40)
})
})
// ── shellEscape ──────────────────────────────────────────────────────
describe('shellEscape', () => {
it('wraps string in single quotes', () => {
expect(shellEscape('hello')).toBe("'hello'")
})
it('escapes embedded single quotes', () => {
expect(shellEscape("it's")).toBe("'it'\\''s'")
})
it('handles empty string', () => {
expect(shellEscape('')).toBe("''")
})
it('handles special characters', () => {
expect(shellEscape('foo bar; rm -rf /')).toBe("'foo bar; rm -rf /'")
})
})
// ── findDefaultKeyFile ───────────────────────────────────────────────
describe('findDefaultKeyFile', () => {
beforeEach(() => {
mockExistsSync.mockReturnValue(false)
mockReadFileSync.mockReset()
})
it('returns undefined when no default keys exist', () => {
expect(findDefaultKeyFile()).toBeUndefined()
})
it('returns the first existing key file', () => {
mockExistsSync.mockImplementation((path: unknown) => {
return path === '/home/testuser/.ssh/id_ed25519'
})
mockReadFileSync.mockReturnValue(Buffer.from('key-contents'))
const result = findDefaultKeyFile()
expect(result).toBeDefined()
expect(result!.path).toBe('~/.ssh/id_ed25519')
expect(result!.contents).toEqual(Buffer.from('key-contents'))
})
it('probes keys in VS Code order: ed25519, rsa, ecdsa, dsa, xmss', () => {
const checkedPaths: string[] = []
mockExistsSync.mockImplementation((path: unknown) => {
checkedPaths.push(String(path))
return false
})
findDefaultKeyFile()
expect(checkedPaths).toEqual([
'/home/testuser/.ssh/id_ed25519',
'/home/testuser/.ssh/id_rsa',
'/home/testuser/.ssh/id_ecdsa',
'/home/testuser/.ssh/id_dsa',
'/home/testuser/.ssh/id_xmss'
])
})
it('skips unreadable key files and tries next', () => {
mockExistsSync.mockImplementation((path: unknown) => {
return path === '/home/testuser/.ssh/id_ed25519' || path === '/home/testuser/.ssh/id_rsa'
})
mockReadFileSync.mockImplementation((path: unknown) => {
if (String(path) === '/home/testuser/.ssh/id_ed25519') {
throw new Error('permission denied')
}
return Buffer.from('rsa-key')
})
const result = findDefaultKeyFile()
expect(result).toBeDefined()
expect(result!.path).toBe('~/.ssh/id_rsa')
})
})
// ── buildConnectConfig ──────────────────────────────────────────────
function makeTarget(overrides?: Partial<SshTarget>): SshTarget {
return {
id: 'test-1',
label: 'myhost',
host: 'example.com',
port: 22,
username: 'deploy',
...overrides
}
}
function makeResolved(overrides?: Partial<SshResolvedConfig>): SshResolvedConfig {
return {
hostname: '10.0.0.1',
port: 22,
identityFile: [],
forwardAgent: false,
...overrides
}
}
describe('buildConnectConfig', () => {
const originalEnv = process.env.SSH_AUTH_SOCK
beforeEach(() => {
mockExistsSync.mockReturnValue(false)
mockReadFileSync.mockReset()
process.env.SSH_AUTH_SOCK = '/tmp/agent.sock'
})
afterEach(() => {
if (originalEnv !== undefined) {
process.env.SSH_AUTH_SOCK = originalEnv
} else {
delete process.env.SSH_AUTH_SOCK
}
})
it('uses target host/port/username', () => {
const config = buildConnectConfig(makeTarget(), null)
expect(config.host).toBe('example.com')
expect(config.port).toBe(22)
expect(config.username).toBe('deploy')
})
it('falls back to resolved config when target fields are empty', () => {
const config = buildConnectConfig(
makeTarget({ host: '', port: 0, username: '' }),
makeResolved({ hostname: '10.0.0.1', port: 2222, user: 'admin' })
)
expect(config.host).toBe('10.0.0.1')
expect(config.port).toBe(2222)
expect(config.username).toBe('admin')
})
it('sets readyTimeout to CONNECT_TIMEOUT_MS', () => {
const config = buildConnectConfig(makeTarget(), null)
expect(config.readyTimeout).toBe(30_000)
})
it('sets keepaliveInterval to 15s', () => {
const config = buildConnectConfig(makeTarget(), null)
expect(config.keepaliveInterval).toBe(15_000)
})
it('uses agent auth when no explicit key and SSH_AUTH_SOCK is set', () => {
const config = buildConnectConfig(makeTarget(), null)
expect(config.agent).toBe('/tmp/agent.sock')
})
it('uses keyFile auth when target.identityFile is set', () => {
mockReadFileSync.mockReturnValue(Buffer.from('key'))
const config = buildConnectConfig(makeTarget({ identityFile: '/home/user/.ssh/custom' }), null)
expect(config.privateKey).toEqual(Buffer.from('key'))
expect(config.agent).toBe('/tmp/agent.sock')
})
it('uses keyFile auth when resolved identityFile is non-default', () => {
mockReadFileSync.mockReturnValue(Buffer.from('custom-key'))
const config = buildConnectConfig(
makeTarget(),
makeResolved({ identityFile: ['/home/user/.ssh/work_key'] })
)
expect(config.privateKey).toEqual(Buffer.from('custom-key'))
expect(config.agent).toBe('/tmp/agent.sock')
})
it('uses agent auth when resolved identityFile is a default path (expanded)', () => {
const config = buildConnectConfig(
makeTarget(),
makeResolved({ identityFile: ['/home/testuser/.ssh/id_ed25519'] })
)
expect(config.agent).toBe('/tmp/agent.sock')
})
it('provides fallback key in agent auth mode', () => {
mockExistsSync.mockImplementation(
(p: unknown) => String(p) === '/home/testuser/.ssh/id_ed25519'
)
mockReadFileSync.mockReturnValue(Buffer.from('fallback'))
const config = buildConnectConfig(makeTarget(), null)
expect(config.agent).toBe('/tmp/agent.sock')
expect(config.privateKey).toEqual(Buffer.from('fallback'))
})
})
// ── resolveEffectiveProxy ───────────────────────────────────────────
describe('resolveEffectiveProxy', () => {
it('returns target.proxyCommand first', () => {
const target = { ...makeTarget(), proxyCommand: 'cloudflared access ssh --hostname %h' }
const resolved = makeResolved({ proxyCommand: 'other' })
expect(resolveEffectiveProxy(target, resolved)).toEqual({
kind: 'proxy-command',
command: 'cloudflared access ssh --hostname %h'
})
})
it('falls back to resolved proxyCommand', () => {
expect(
resolveEffectiveProxy(makeTarget(), makeResolved({ proxyCommand: 'ssh -W %h:%p gw' }))
).toEqual({
kind: 'proxy-command',
command: 'ssh -W %h:%p gw'
})
})
it('returns structured jump-host config for target.jumpHost', () => {
const target = { ...makeTarget(), jumpHost: 'bastion.example.com' }
expect(resolveEffectiveProxy(target, null)).toEqual({
kind: 'jump-host',
jumpHost: 'bastion.example.com'
})
})
it('returns structured jump-host config for resolved proxyJump', () => {
expect(resolveEffectiveProxy(makeTarget(), makeResolved({ proxyJump: 'jump.host' }))).toEqual({
kind: 'jump-host',
jumpHost: 'jump.host'
})
})
it('returns undefined when no proxy is configured', () => {
expect(resolveEffectiveProxy(makeTarget(), null)).toBeUndefined()
})
})

View file

@ -1,39 +1,33 @@
import { Client as SshClient } from 'ssh2'
import type { ConnectConfig, ClientChannel } from 'ssh2'
import { type ChildProcess, execFileSync } from 'child_process'
import { readFileSync } from 'fs'
import { createHash } from 'crypto'
import { readFileSync, existsSync } from 'fs'
import { spawn, type ChildProcess } from 'child_process'
import { homedir } from 'os'
import { join } from 'path'
import { Duplex } from 'stream'
import type { Socket as NetSocket } from 'net'
import type { ConnectConfig } from 'ssh2'
import type { SshTarget, SshConnectionState } from '../../shared/ssh-types'
import type { SshResolvedConfig } from './ssh-config-parser'
// Why: types live here (not ssh-connection.ts) to break a circular import.
export type HostKeyVerifyRequest = {
host: string
ip: string
fingerprint: string
keyType: string
}
export type AuthChallengeRequest = {
targetId: string
name: string
instructions: string
prompts: { prompt: string; echo: boolean }[]
}
export type SshCredentialKind = 'passphrase' | 'password'
export type SshConnectionCallbacks = {
onStateChange: (targetId: string, state: SshConnectionState) => void
onHostKeyVerify: (req: HostKeyVerifyRequest) => Promise<boolean>
onAuthChallenge: (req: AuthChallengeRequest) => Promise<string[]>
onPasswordPrompt: (targetId: string) => Promise<string | null>
onCredentialRequest?: (
targetId: string,
kind: SshCredentialKind,
detail: string
) => Promise<string | null>
}
export function isPassphraseError(err: Error): boolean {
const msg = err.message.toLowerCase()
return msg.includes('passphrase') || msg.includes('encrypted key') || msg.includes('bad decrypt')
}
export const INITIAL_RETRY_ATTEMPTS = 5
export const INITIAL_RETRY_DELAY_MS = 2000
export const RECONNECT_BACKOFF_MS = [1000, 2000, 5000, 5000, 10000, 10000, 10000, 30000, 30000]
export const AUTH_CHALLENGE_TIMEOUT_MS = 60_000
export const CONNECT_TIMEOUT_MS = 15_000
export const CONNECT_TIMEOUT_MS = 30_000
const TRANSIENT_ERROR_CODES = new Set([
'ETIMEDOUT',
@ -44,6 +38,15 @@ const TRANSIENT_ERROR_CODES = new Set([
'EAI_AGAIN'
])
export function isAuthError(err: Error): boolean {
const msg = err.message.toLowerCase()
return (
msg.includes('all configured authentication methods failed') ||
msg.includes('authentication failed') ||
(err as { level?: string }).level === 'client-authentication'
)
}
export function isTransientError(err: Error): boolean {
const code = (err as NodeJS.ErrnoException).code
if (code && TRANSIENT_ERROR_CODES.has(code)) {
@ -65,235 +68,161 @@ export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
// Why: prevents shell injection when interpolating into ProxyCommand.
export function shellEscape(s: string): string {
return `'${s.replace(/'/g, "'\\''")}'`
}
// Why: ssh2 doesn't check known_hosts. Without this, every connection blocks
// on a UI prompt that isn't wired up yet, causing a silent timeout.
function isHostKnown(host: string, port: number): boolean {
try {
const lookup = port === 22 ? host : `[${host}]:${port}`
execFileSync('ssh-keygen', ['-F', lookup], { stdio: 'pipe', timeout: 3000 })
return true
} catch {
return false
}
function cmdEscape(s: string): string {
return `"${s.replace(/"/g, '""')}"`
}
// ── Auth handler state (passed in by the connection) ────────────────
// Why: ssh2 only tries keys that are explicitly provided. Users with keys in
// standard locations (e.g. ~/.ssh/id_ed25519) but no SSH agent running would
// fail to authenticate. Probing default paths matches VS Code's _findDefaultKeyFile.
const DEFAULT_KEY_NAMES = ['id_ed25519', 'id_rsa', 'id_ecdsa', 'id_dsa', 'id_xmss']
export type AuthHandlerState = {
agentAttempted: boolean
keyAttempted: boolean
setState: (status: string, error?: string) => void
}
const DEFAULT_KEY_PATHS = DEFAULT_KEY_NAMES.map((name) => `~/.ssh/${name}`)
export type ConnectConfigResult = {
config: ConnectConfig
jumpClient: SshClient | null
proxyProcess: ChildProcess | null
}
export async function buildConnectConfig(
target: SshTarget,
callbacks: SshConnectionCallbacks,
authState: AuthHandlerState
): Promise<ConnectConfigResult> {
const config: ConnectConfig = {
host: target.host,
port: target.port,
username: target.username,
readyTimeout: CONNECT_TIMEOUT_MS,
keepaliveInterval: 5000,
keepaliveCountMax: 4,
// Why: parseSshGOutput expands ~ to homedir(), so resolved identityFile
// paths won't match the ~/... form in DEFAULT_KEY_PATHS. Pre-expand for
// the comparison in buildConnectConfig.
const EXPANDED_DEFAULT_KEY_PATHS = DEFAULT_KEY_NAMES.map((name) => join(homedir(), '.ssh', name))
// Why: ssh2's hostVerifier callback form `(key, verify) => void` blocks
// the handshake until `verify(true/false)` is called. We check
// known_hosts first so trusted hosts connect without a UI prompt.
hostVerifier: (key: Buffer, verify: (accept: boolean) => void) => {
if (isHostKnown(target.host, target.port)) {
verify(true)
return
}
const fingerprint = createHash('sha256').update(key).digest('base64')
const keyType = 'unknown'
authState.setState('host-key-verification')
callbacks
.onHostKeyVerify({
host: target.host,
ip: target.host,
fingerprint,
keyType
})
.then((accepted) => {
verify(accepted)
})
.catch(() => {
verify(false)
})
},
authHandler: (methodsLeft, _partialSuccess, callback) => {
// ssh2 passes null on the first call, meaning "try whatever you want".
// Treat it as all methods available.
const methods = methodsLeft ?? ['publickey', 'keyboard-interactive', 'password']
// Try auth methods in order: agent -> publickey -> keyboard-interactive -> password
// The custom authHandler overrides ssh2's built-in sequence, so we must
// explicitly try agent auth here -- the config.agent field alone is not enough.
if (methods.includes('publickey') && process.env.SSH_AUTH_SOCK && !authState.agentAttempted) {
authState.agentAttempted = true
callback({
type: 'agent' as const,
agent: process.env.SSH_AUTH_SOCK,
username: target.username
} as never)
return
}
if (methods.includes('publickey') && target.identityFile && !authState.keyAttempted) {
authState.keyAttempted = true
try {
callback({
type: 'publickey' as const,
username: target.username,
key: readFileSync(target.identityFile)
} as never)
return
} catch {
// Key file unreadable -- fall through to next method
}
}
if (methods.includes('keyboard-interactive')) {
callback({
type: 'keyboard-interactive' as const,
username: target.username,
prompt: async (
_name: string,
instructions: string,
_lang: string,
prompts: { prompt: string; echo: boolean }[],
finish: (responses: string[]) => void
) => {
authState.setState('auth-challenge')
const timeoutPromise = sleep(AUTH_CHALLENGE_TIMEOUT_MS).then(() => null)
const responsePromise = callbacks.onAuthChallenge({
targetId: target.id,
name: _name,
instructions,
prompts
})
const responses = await Promise.race([responsePromise, timeoutPromise])
if (!responses) {
finish([])
return
}
finish(responses)
}
} as never)
return
}
if (methods.includes('password')) {
callbacks
.onPasswordPrompt(target.id)
.then((password) => {
if (password === null) {
authState.setState('auth-failed', 'Authentication cancelled')
callback(false as never)
return
}
callback({
type: 'password' as const,
username: target.username,
password
} as never)
})
.catch(() => {
callback(false as never)
})
return
}
authState.setState('auth-failed', 'No supported authentication methods')
callback(false as never)
}
}
// If an identity file is specified, try it for the initial attempt
if (target.identityFile) {
export function findDefaultKeyFile(): { path: string; contents: Buffer } | undefined {
for (const keyPath of DEFAULT_KEY_PATHS) {
const resolved = keyPath.replace(/^~/, homedir())
try {
config.privateKey = readFileSync(target.identityFile)
if (!existsSync(resolved)) {
continue
}
const contents = readFileSync(resolved)
return { path: keyPath, contents }
} catch {
// Will fall through to other auth methods
continue
}
}
return undefined
}
// Try SSH agent by default
// Why: matches VS Code's _connectSSH auth method selection (lines 606-611, 727-758).
// ssh2 handles the auth negotiation natively — no custom authHandler needed.
export function buildConnectConfig(
target: SshTarget,
resolved: SshResolvedConfig | null
): ConnectConfig {
const effectiveHost = target.host || resolved?.hostname || target.label
const effectivePort = target.port || resolved?.port || 22
const effectiveUser = target.username || resolved?.user || ''
const config: Record<string, unknown> = {
host: effectiveHost,
port: effectivePort,
username: effectiveUser,
readyTimeout: CONNECT_TIMEOUT_MS,
keepaliveInterval: 15_000
}
// Why: always provide agent when available. Unlike VS Code (which has a
// passphrase prompt UI), we can't decrypt passphrase-protected keys at
// runtime. The agent holds decrypted keys, so it must always be a
// fallback even when an explicit key file is also provided.
if (process.env.SSH_AUTH_SOCK) {
config.agent = process.env.SSH_AUTH_SOCK
}
let proxyProcess: ChildProcess | null = null
if (target.proxyCommand) {
const { spawn } = await import('child_process')
const expanded = target.proxyCommand
.replace(/%h/g, shellEscape(target.host))
.replace(/%p/g, shellEscape(String(target.port)))
.replace(/%r/g, shellEscape(target.username))
proxyProcess = spawn('/bin/sh', ['-c', expanded], { stdio: ['pipe', 'pipe', 'pipe'] })
// Why: a single PassThrough used for both directions creates a feedback loop —
// proxy stdout data flows through the PassThrough and gets piped right back to
// proxy stdin. Use a Duplex wrapper where reads come from stdout and writes
// go to stdin independently.
const { Duplex } = await import('stream')
const stream = new Duplex({
read() {},
write(chunk, _encoding, cb) {
proxyProcess!.stdin!.write(chunk, cb)
}
})
proxyProcess.stdout!.on('data', (data) => stream.push(data))
proxyProcess.stdout!.on('end', () => stream.push(null))
config.sock = stream as unknown as NetSocket
const resolvedIdentity = resolved?.identityFile?.[0]
const explicitKey =
target.identityFile ||
(resolvedIdentity && !EXPANDED_DEFAULT_KEY_PATHS.includes(resolvedIdentity)
? resolvedIdentity
: undefined)
if (explicitKey) {
try {
config.privateKey = readFileSync(explicitKey.replace(/^~/, homedir()))
} catch {
// Key unreadable — agent will handle auth if available
}
} else {
const fallback = findDefaultKeyFile()
if (fallback) {
config.privateKey = fallback.contents
}
}
// Wire JumpHost: establish an intermediate SSH connection and forward a channel.
// Why: the jump client is returned to the caller so it can be destroyed on
// disconnect — otherwise the intermediate TCP connection leaks.
let jumpClient: SshClient | null = null
if (target.jumpHost && !target.proxyCommand) {
jumpClient = new SshClient()
const jumpConn = jumpClient
await new Promise<void>((resolve, reject) => {
jumpConn.on('ready', () => resolve())
jumpConn.on('error', (err) => reject(err))
jumpConn.connect({
host: target.jumpHost!,
port: 22,
username: target.username,
agent: process.env.SSH_AUTH_SOCK ?? undefined,
readyTimeout: CONNECT_TIMEOUT_MS
})
})
const forwardedChannel = await new Promise<ClientChannel>((resolve, reject) => {
jumpConn.forwardOut('127.0.0.1', 0, target.host, target.port, (err, channel) => {
if (err) {
reject(err)
} else {
resolve(channel)
}
})
})
config.sock = forwardedChannel as unknown as NetSocket
}
return { config, jumpClient, proxyProcess }
return config as ConnectConfig
}
// Why: ProxyJump and jumpHost are syntactic sugar for ProxyCommand.
// OpenSSH internally converts `ProxyJump bastion` to
// `ProxyCommand ssh -W %h:%p bastion`. We do the same so that ssh2
// gets a single proxy spawn path regardless of how the tunnel was configured.
export type EffectiveProxy =
| { kind: 'proxy-command'; command: string }
| { kind: 'jump-host'; jumpHost: string }
export function resolveEffectiveProxy(
target: SshTarget,
resolved: SshResolvedConfig | null
): EffectiveProxy | undefined {
if (target.proxyCommand) {
return { kind: 'proxy-command', command: target.proxyCommand }
}
if (resolved?.proxyCommand) {
return { kind: 'proxy-command', command: resolved.proxyCommand }
}
const jump = target.jumpHost || resolved?.proxyJump
if (jump) {
return { kind: 'jump-host', jumpHost: jump }
}
return undefined
}
// Why: ssh2 doesn't natively support ProxyCommand. When the SSH config
// specifies one (e.g. `cloudflared access ssh --hostname %h`), we spawn
// the command and bridge its stdin/stdout into a Duplex stream that ssh2
// uses as its transport socket via `config.sock`.
function getShellSpawnConfig(command: string): { file: string; args: string[] } {
if (process.platform === 'win32') {
const comspec = process.env.ComSpec || 'cmd.exe'
return { file: comspec, args: ['/d', '/s', '/c', command] }
}
return { file: '/bin/sh', args: ['-c', command] }
}
export function spawnProxyCommand(
proxy: EffectiveProxy,
host: string,
port: number,
user: string
): { process: ChildProcess; sock: NetSocket } {
const proc =
proxy.kind === 'jump-host'
? // Why: ProxyJump is structured input, not a shell snippet. Spawn ssh
// directly so jump-host values cannot escape through shell parsing.
spawn('ssh', ['-W', `${host}:${port}`, '--', proxy.jumpHost], { stdio: ['pipe', 'pipe', 'pipe'] })
: (() => {
const escape = process.platform === 'win32' ? cmdEscape : shellEscape
const expanded = proxy.command
.replace(/%h/g, escape(host))
.replace(/%p/g, escape(String(port)))
.replace(/%r/g, escape(user))
const shell = getShellSpawnConfig(expanded)
return spawn(shell.file, shell.args, { stdio: ['pipe', 'pipe', 'pipe'] })
})()
// Why: a single PassThrough for both directions creates a feedback loop.
// Reads come from the proxy's stdout; writes go to its stdin.
const stream = new Duplex({
read() {},
write(chunk, _encoding, cb) {
proc.stdin!.write(chunk, cb)
}
})
proc.stdout!.on('data', (data) => stream.push(data))
proc.stdout!.on('end', () => stream.push(null))
proc.stdin!.on('error', (err) => stream.destroy(err))
proc.on('error', (err) => stream.destroy(err))
return { process: proc, sock: stream as unknown as NetSocket }
}

View file

@ -36,7 +36,12 @@ vi.mock('./ssh-system-fallback', () => ({
})
}))
vi.mock('./ssh-config-parser', () => ({
resolveWithSshG: vi.fn().mockResolvedValue(null)
}))
import { SshConnection, SshConnectionManager, type SshConnectionCallbacks } from './ssh-connection'
import { resolveWithSshG } from './ssh-config-parser'
import type { SshTarget } from '../../shared/ssh-types'
function createTarget(overrides?: Partial<SshTarget>): SshTarget {
@ -53,9 +58,6 @@ function createTarget(overrides?: Partial<SshTarget>): SshTarget {
function createCallbacks(overrides?: Partial<SshConnectionCallbacks>): SshConnectionCallbacks {
return {
onStateChange: vi.fn(),
onHostKeyVerify: vi.fn().mockResolvedValue(true),
onAuthChallenge: vi.fn().mockResolvedValue(['123456']),
onPasswordPrompt: vi.fn().mockResolvedValue('password'),
...overrides
}
}
@ -138,6 +140,21 @@ describe('SshConnection', () => {
await expect(conn.connect()).rejects.toThrow('Connection disposed')
})
it('resolves OpenSSH config using configHost when present', async () => {
const callbacks = createCallbacks()
const conn = new SshConnection(
createTarget({
label: 'Friendly Name',
configHost: 'ssh-alias'
}),
callbacks
)
await conn.connect()
expect(resolveWithSshG).toHaveBeenCalledWith('ssh-alias')
})
})
describe('SshConnectionManager', () => {

View file

@ -1,31 +1,28 @@
/* 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 type { ChildProcess } from 'child_process'
import type { ClientChannel, SFTPWrapper } from 'ssh2'
import type { ClientChannel, ConnectConfig, SFTPWrapper } from 'ssh2'
import type { SshTarget, SshConnectionState, SshConnectionStatus } from '../../shared/ssh-types'
import { spawnSystemSsh, type SystemSshProcess } from './ssh-system-fallback'
import { resolveWithSshG } from './ssh-config-parser'
import {
INITIAL_RETRY_ATTEMPTS,
INITIAL_RETRY_DELAY_MS,
RECONNECT_BACKOFF_MS,
CONNECT_TIMEOUT_MS,
isTransientError,
isAuthError,
isPassphraseError,
sleep,
buildConnectConfig,
resolveEffectiveProxy,
spawnProxyCommand,
type SshConnectionCallbacks
} from './ssh-connection-utils'
// Why: type definitions live in ssh-connection-utils.ts to break a circular
// import. Re-exported here so existing import sites keep working.
export type {
HostKeyVerifyRequest,
AuthChallengeRequest,
SshConnectionCallbacks
} from './ssh-connection-utils'
export type { SshConnectionCallbacks } from './ssh-connection-utils'
export class SshConnection {
private client: SshClient | null = null
/** Why: the jump host client must be tracked so it can be torn down on
* disconnect otherwise the intermediate TCP connection leaks. */
private jumpClient: SshClient | null = null
private proxyProcess: ChildProcess | null = null
private systemSsh: SystemSshProcess | null = null
private state: SshConnectionState
@ -33,8 +30,9 @@ export class SshConnection {
private target: SshTarget
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
private disposed = false
private agentAttempted = false
private keyAttempted = false
private cachedPassphrase: string | null = null
private cachedPassword: string | null = null
private connectGeneration = 0
constructor(target: SshTarget, callbacks: SshConnectionCallbacks) {
this.target = target
@ -50,47 +48,25 @@ export class SshConnection {
getState(): SshConnectionState {
return { ...this.state }
}
getClient(): SshClient | null {
return this.client
}
getTarget(): SshTarget {
return { ...this.target }
}
/** Open an exec channel. Used by relay deployment to run commands on the remote. */
async exec(command: string): Promise<ClientChannel> {
const client = this.client
if (!client) {
async exec(cmd: string): Promise<ClientChannel> {
if (!this.client) {
throw new Error('Not connected')
}
return new Promise((resolve, reject) => {
client.exec(command, (err, channel) => {
if (err) {
reject(err)
} else {
resolve(channel)
}
})
})
return new Promise((res, rej) => this.client!.exec(cmd, (e, ch) => (e ? rej(e) : res(ch))))
}
/** Open an SFTP session for file transfers (relay deployment). */
async sftp(): Promise<SFTPWrapper> {
const client = this.client
if (!client) {
if (!this.client) {
throw new Error('Not connected')
}
return new Promise((resolve, reject) => {
client.sftp((err, sftp) => {
if (err) {
reject(err)
} else {
resolve(sftp)
}
})
})
return new Promise((res, rej) => this.client!.sftp((e, s) => (e ? rej(e) : res(s))))
}
async connect(): Promise<void> {
@ -107,7 +83,13 @@ export class SshConnection {
} catch (err) {
lastError = err instanceof Error ? err : new Error(String(err))
if (isAuthError(lastError) || isPassphraseError(lastError)) {
this.setState('auth-failed', lastError.message)
throw lastError
}
if (!isTransientError(lastError)) {
this.setState('error', lastError.message)
throw lastError
}
@ -124,95 +106,136 @@ export class SshConnection {
private async attemptConnect(): Promise<void> {
this.setState('connecting')
this.agentAttempted = false
this.keyAttempted = false
this.proxyProcess?.kill()
this.proxyProcess = null
const connectGeneration = ++this.connectGeneration
// Why: clean up resources from a prior failed attempt before overwriting.
// Without this, a retry after timeout/auth-failure orphans the old jump
// host TCP connection and proxy child process.
if (this.jumpClient) {
this.jumpClient.end()
this.jumpClient = null
const resolved = await resolveWithSshG(this.target.configHost || this.target.label).catch(() => null)
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.proxyProcess) {
this.proxyProcess.kill()
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.callbacks.onCredentialRequest) {
this.proxyProcess?.kill()
this.proxyProcess = null
throw err
}
// Why: prompt for passphrase on encrypted-key error, then retry with
// a fresh proxy socket (ssh2 may have destroyed the original).
if (isPassphraseError(err) && !this.cachedPassphrase) {
const detail = this.target.identityFile || resolved?.identityFile?.[0] || '(unknown)'
const val = await this.callbacks.onCredentialRequest(this.target.id, 'passphrase', detail)
if (val) {
this.cachedPassphrase = val
config.passphrase = val
this.respawnProxy(config, effectiveProxy)
await this.doSsh2Connect(config, connectGeneration)
return
}
}
// Why: prompt for password on auth failure. Check the original error
// (not a retry error) to avoid conflating passphrase vs password failures.
if (isAuthError(err) && !this.cachedPassword) {
const val = await this.callbacks.onCredentialRequest(
this.target.id,
'password',
config.host || this.target.label
)
if (val) {
this.cachedPassword = val
config.password = val
this.respawnProxy(config, effectiveProxy)
await this.doSsh2Connect(config, connectGeneration)
return
}
}
this.proxyProcess?.kill()
this.proxyProcess = null
throw err
}
}
const { config, jumpClient, proxyProcess } = await this.buildConfig()
this.jumpClient = jumpClient
this.proxyProcess = proxyProcess
// 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
): void {
if (!proxy) {
return
}
this.proxyProcess?.kill()
const p = spawnProxyCommand(proxy, config.host!, config.port!, config.username!)
this.proxyProcess = p.process
config.sock = p.sock
}
private doSsh2Connect(config: ConnectConfig, connectGeneration: number): Promise<void> {
return new Promise<void>((resolve, reject) => {
const client = new SshClient()
let settled = false
const timeout = setTimeout(() => {
if (!settled) {
settled = true
client.destroy()
const msg = `Connection timed out after ${CONNECT_TIMEOUT_MS}ms`
this.setState('error', msg)
reject(new Error(msg))
}
}, CONNECT_TIMEOUT_MS)
// Why: host key verification is now handled inside the hostVerifier
// callback in buildConnectConfig (ssh-connection-utils.ts). The
// callback form `(key, verify) => void` blocks the handshake until
// the user accepts/rejects, so no separate 'handshake' listener is
// needed here.
client.on('ready', () => {
if (settled) {
return
}
// Why: connect() completion races with explicit disconnect(). Once a
// newer connect attempt or disconnect bumps the generation/disposed
// state, this late ready event must not resurrect the torn-down client.
if (this.disposed || connectGeneration !== this.connectGeneration) {
settled = true
client.end()
client.destroy()
reject(new Error('SSH connection attempt was cancelled'))
return
}
settled = true
clearTimeout(timeout)
this.client = client
this.proxyProcess = null
this.setState('connected')
this.setupDisconnectHandler(client)
resolve()
})
client.on('error', (err) => {
if (settled) {
return
}
settled = true
clearTimeout(timeout)
this.setState('error', err.message)
client.destroy()
reject(err)
})
client.connect(config)
})
}
private async buildConfig() {
// Why: config-building logic extracted to ssh-connection-utils.ts (max-lines).
return buildConnectConfig(this.target, this.callbacks, {
agentAttempted: this.agentAttempted,
keyAttempted: this.keyAttempted,
setState: (status: string, error?: string) => {
this.setState(status as SshConnectionStatus, error)
}
})
}
// Why: both `end` and `close` fire on disconnect. If reconnect succeeds
// between the two events, the second handler would null out the *new*
// connection. Guarding on `this.client === client` prevents that.
// Why: guard on identity so a late event from the old client doesn't
// null out a successful reconnect.
private setupDisconnectHandler(client: SshClient): void {
const handleDisconnect = () => {
const onDrop = () => {
if (this.disposed || this.client !== client) {
return
}
this.client = null
this.scheduleReconnect()
}
client.on('end', handleDisconnect)
client.on('close', handleDisconnect)
client.on('end', onDrop)
client.on('close', onDrop)
client.on('error', (err) => {
if (this.disposed || this.client !== client) {
return
@ -227,71 +250,58 @@ export class SshConnection {
if (this.disposed || this.reconnectTimer) {
return
}
const attempt = this.state.reconnectAttempt
if (attempt >= RECONNECT_BACKOFF_MS.length) {
this.setState('reconnection-failed', 'Max reconnection attempts reached')
return
}
this.setState('reconnecting')
const delay = RECONNECT_BACKOFF_MS[attempt]
this.reconnectTimer = setTimeout(async () => {
this.reconnectTimer = null
if (this.disposed) {
return
}
try {
await this.attemptConnect()
// Why: reset the counter and re-broadcast so the UI shows attempt 0.
// attemptConnect already calls setState('connected'), but the attempt
// counter must be zeroed *before* so the broadcast carries the right value.
// Why: reset reconnectAttempt before attemptConnect so setState('connected')
// broadcasts reconnectAttempt=0, which ssh.ts uses to trigger relay re-establishment.
this.state.reconnectAttempt = 0
this.setState('connected')
} catch {
// Why: increment before scheduleReconnect so the setState('reconnecting')
// call inside it broadcasts the updated attempt number to the UI.
this.state.reconnectAttempt++
await this.attemptConnect()
} catch (err) {
if (this.disposed) {
return
}
const error = err instanceof Error ? err : new Error(String(err))
if (isAuthError(error) || isPassphraseError(error)) {
this.setState('auth-failed', error.message)
return
}
if (!isTransientError(error)) {
this.setState('error', error.message)
return
}
this.state.reconnectAttempt = attempt + 1
this.scheduleReconnect()
}
}, delay)
}, RECONNECT_BACKOFF_MS[attempt])
}
/** Fall back to system SSH binary when ssh2 cannot handle auth (FIDO2, ControlMaster). */
async connectViaSystemSsh(): Promise<SystemSshProcess> {
if (this.disposed) {
throw new Error('Connection disposed')
}
// Why: if connectViaSystemSsh is called again after a prior failed attempt,
// the old process may still be running. Without cleanup, overwriting
// this.systemSsh at line 267 would orphan the old process.
if (this.systemSsh) {
this.systemSsh.kill()
this.systemSsh = null
}
this.systemSsh?.kill()
this.systemSsh = null
this.setState('connecting')
try {
const proc = spawnSystemSsh(this.target)
this.systemSsh = proc
// Why: two onExit handlers are registered — one for the initial handshake
// (reject the promise on early exit) and one for post-connect reconnection.
// Without a settled flag, an early exit during handshake would fire both,
// causing the reconnection handler to schedule a reconnect for a connection
// that was never established.
let settled = false
// Why: verify the SSH connection succeeded before reporting connected.
// Wait for relay sentinel output or a non-zero exit.
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
settled = true
proc.kill()
reject(new Error('System SSH connection timed out'))
}, CONNECT_TIMEOUT_MS)
proc.stdout.once('data', () => {
settled = true
clearTimeout(timeout)
@ -303,69 +313,54 @@ export class SshConnection {
}
settled = true
clearTimeout(timeout)
if (code !== 0) {
reject(new Error(`System SSH exited with code ${code}`))
}
reject(
new Error(
code !== 0
? `System SSH exited with code ${code}`
: 'System SSH exited before producing output'
)
)
})
})
this.setState('connected')
// Why: unlike ssh2 Client which emits end/close, the system SSH process
// only signals disconnection through its exit event. Without this handler
// an unexpected exit would leave the connection in 'connected' state with
// no underlying transport.
proc.onExit((_code) => {
// Why: register reconnection handler only after the initial handshake
// succeeds. The onExit registered above guards with `settled` so it
// won't fire a duplicate for exits during the handshake phase.
proc.onExit(() => {
if (!this.disposed && this.systemSsh === proc) {
this.systemSsh = null
this.scheduleReconnect()
}
})
return proc
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
this.setState('error', msg)
this.setState('error', err instanceof Error ? err.message : String(err))
throw err
}
}
async disconnect(): Promise<void> {
this.disposed = true
this.connectGeneration += 1
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer)
this.reconnectTimer = null
}
if (this.client) {
this.client.end()
this.client = null
}
// Why: the jump host client holds an open TCP connection to the
// intermediate host. Failing to close it would leak the socket.
if (this.jumpClient) {
this.jumpClient.end()
this.jumpClient = null
}
if (this.proxyProcess) {
this.proxyProcess.kill()
this.proxyProcess = null
}
if (this.systemSsh) {
this.systemSsh.kill()
this.systemSsh = null
}
this.reconnectTimer = null
this.cachedPassphrase = null
this.cachedPassword = null
this.client?.end()
this.client = null
this.proxyProcess?.kill()
this.proxyProcess = null
this.systemSsh?.kill()
this.systemSsh = null
this.setState('disconnected')
}
private setState(status: SshConnectionStatus, error?: string): void {
this.state = {
...this.state,
status,
error: error ?? null
}
this.state = { ...this.state, status, error: error ?? null }
this.callbacks.onStateChange(this.target.id, { ...this.state })
}
}
// Why: extracted to ssh-connection-manager.ts to stay under 300-line max-lines.
export { SshConnectionManager } from './ssh-connection-manager'

View file

@ -0,0 +1,112 @@
import { describe, expect, it, vi } from 'vitest'
vi.mock('electron', () => ({
app: { getAppPath: () => '/mock/app' }
}))
vi.mock('fs', () => ({
existsSync: vi.fn().mockReturnValue(false),
readFileSync: vi.fn()
}))
vi.mock('./relay-protocol', () => ({
RELAY_VERSION: '0.1.0',
RELAY_REMOTE_DIR: '.orca-remote',
parseUnameToRelayPlatform: vi.fn().mockReturnValue('linux-x64'),
RELAY_SENTINEL: 'ORCA-RELAY v0.1.0 READY\n',
RELAY_SENTINEL_TIMEOUT_MS: 10_000
}))
vi.mock('./ssh-relay-deploy-helpers', () => ({
uploadDirectory: vi.fn().mockResolvedValue(undefined),
waitForSentinel: vi.fn().mockResolvedValue({
write: vi.fn(),
onData: vi.fn(),
onClose: vi.fn()
}),
execCommand: vi.fn().mockResolvedValue('Linux x86_64'),
resolveRemoteNodePath: vi.fn().mockResolvedValue('/usr/bin/node')
}))
vi.mock('./ssh-connection-utils', () => ({
shellEscape: (s: string) => `'${s}'`
}))
import { deployAndLaunchRelay } from './ssh-relay-deploy'
import { execCommand } from './ssh-relay-deploy-helpers'
import type { SshConnection } from './ssh-connection'
function makeMockConnection(): SshConnection {
return {
exec: vi.fn().mockResolvedValue({
on: vi.fn(),
stderr: { on: vi.fn() },
stdin: {},
stdout: { on: vi.fn() },
close: vi.fn()
}),
sftp: vi.fn().mockResolvedValue({
mkdir: vi.fn((_p: string, cb: (err: Error | null) => void) => cb(null)),
createWriteStream: vi.fn().mockReturnValue({
on: vi.fn((_event: string, cb: () => void) => {
if (_event === 'close') {
setTimeout(cb, 0)
}
}),
end: vi.fn()
}),
end: vi.fn()
})
} as unknown as SshConnection
}
describe('deployAndLaunchRelay', () => {
it('calls exec to detect remote platform', async () => {
const conn = makeMockConnection()
const mockExecCommand = vi.mocked(execCommand)
mockExecCommand.mockResolvedValueOnce('Linux x86_64') // uname -sm
mockExecCommand.mockResolvedValueOnce('/home/user') // echo $HOME
mockExecCommand.mockResolvedValueOnce('OK') // check relay exists
mockExecCommand.mockResolvedValueOnce('0.1.0') // version check
await deployAndLaunchRelay(conn)
expect(mockExecCommand).toHaveBeenCalledWith(conn, 'uname -sm')
})
it('reports progress via callback', async () => {
const conn = makeMockConnection()
const mockExecCommand = vi.mocked(execCommand)
mockExecCommand.mockResolvedValueOnce('Linux x86_64')
mockExecCommand.mockResolvedValueOnce('/home/user')
mockExecCommand.mockResolvedValueOnce('OK')
mockExecCommand.mockResolvedValueOnce('0.1.0')
const progress: string[] = []
await deployAndLaunchRelay(conn, (status) => progress.push(status))
expect(progress).toContain('Detecting remote platform...')
expect(progress).toContain('Starting relay...')
})
it('has a 120-second overall timeout', async () => {
const conn = makeMockConnection()
const mockExecCommand = vi.mocked(execCommand)
// Make the first exec never resolve
mockExecCommand.mockReturnValueOnce(new Promise(() => {}))
vi.useFakeTimers()
// Catch the rejection immediately to avoid unhandled rejection warning
const promise = deployAndLaunchRelay(conn).catch((err: Error) => err)
await vi.advanceTimersByTimeAsync(121_000)
const result = await promise
expect(result).toBeInstanceOf(Error)
expect((result as Error).message).toBe('Relay deployment timed out after 120s')
vi.useRealTimers()
})
})

View file

@ -22,6 +22,12 @@ export type RelayDeployResult = {
platform: RelayPlatform
}
// Why: individual exec commands have 30s timeouts, but the full deploy
// pipeline (detect platform → check existing → upload → npm install →
// launch) has no overall bound. A hanging `npm install` or slow SFTP
// upload could block the connection indefinitely.
const RELAY_DEPLOY_TIMEOUT_MS = 120_000
/**
* Deploy the relay to the remote host and launch it.
*
@ -36,6 +42,24 @@ export type RelayDeployResult = {
export async function deployAndLaunchRelay(
conn: SshConnection,
onProgress?: (status: string) => void
): Promise<RelayDeployResult> {
let timeoutHandle: ReturnType<typeof setTimeout>
const timeoutPromise = new Promise<never>((_resolve, reject) => {
timeoutHandle = setTimeout(() => {
reject(new Error(`Relay deployment timed out after ${RELAY_DEPLOY_TIMEOUT_MS / 1000}s`))
}, RELAY_DEPLOY_TIMEOUT_MS)
})
try {
return await Promise.race([deployAndLaunchRelayInner(conn, onProgress), timeoutPromise])
} finally {
clearTimeout(timeoutHandle!)
}
}
async function deployAndLaunchRelayInner(
conn: SshConnection,
onProgress?: (status: string) => void
): Promise<RelayDeployResult> {
onProgress?.('Detecting remote platform...')
console.log('[ssh-relay] Detecting remote platform...')
@ -50,9 +74,11 @@ export async function deployAndLaunchRelay(
// Why: SFTP does not expand `~`, so we must resolve the remote home directory
// explicitly. `echo $HOME` over exec gives us the absolute path.
const remoteHome = (await execCommand(conn, 'echo $HOME')).trim()
// Why: a malicious or misconfigured remote could return a $HOME containing
// shell metacharacters. Validate it looks like a reasonable path.
if (!remoteHome || !/^\/[a-zA-Z0-9/_.-]+$/.test(remoteHome)) {
// Why: we only interpolate $HOME into single-quoted shell strings later, so
// this validation only needs to reject obviously unsafe control characters.
// Allow spaces and non-ASCII so valid home directories are not rejected.
// oxlint-disable-next-line no-control-regex
if (!remoteHome || !remoteHome.startsWith('/') || /[\u0000\r\n]/.test(remoteHome)) {
throw new Error(`Remote $HOME is not a valid path: ${remoteHome.slice(0, 100)}`)
}
const remoteRelayDir = `${remoteHome}/${RELAY_REMOTE_DIR}/relay-v${RELAY_VERSION}`

View file

@ -76,7 +76,7 @@ function buildSshArgs(target: SshTarget): string[] {
}
const userHost = target.username ? `${target.username}@${target.host}` : target.host
args.push(userHost)
args.push('--', userHost)
return args
}

View file

@ -68,6 +68,7 @@ import type {
ClaudeUsageSummary
} from '../../shared/claude-usage-types'
import type { RateLimitState } from '../../shared/rate-limit-types'
import type { SshConnectionState, SshTarget } from '../../shared/ssh-types'
import type {
CodexUsageBreakdownKind,
CodexUsageBreakdownRow,
@ -250,6 +251,7 @@ export type PreloadApi = {
env?: Record<string, string>
command?: string
connectionId?: string | null
worktreeId?: string
}) => Promise<{ id: string }>
write: (id: string, data: string) => void
resize: (id: string, cols: number, rows: number) => void
@ -529,42 +531,18 @@ export type PreloadApi = {
onUpdate: (callback: (state: RateLimitState) => void) => () => void
}
ssh: {
listTargets: () => Promise<unknown[]>
addTarget: (args: { target: Record<string, unknown> }) => Promise<unknown>
updateTarget: (args: { id: string; updates: Record<string, unknown> }) => Promise<unknown>
listTargets: () => Promise<SshTarget[]>
addTarget: (args: { target: Omit<SshTarget, 'id'> }) => Promise<SshTarget>
updateTarget: (args: { id: string; updates: Partial<Omit<SshTarget, 'id'>> }) => Promise<SshTarget>
removeTarget: (args: { id: string }) => Promise<void>
importConfig: () => Promise<unknown[]>
connect: (args: { targetId: string }) => Promise<unknown>
importConfig: () => Promise<SshTarget[]>
connect: (args: { targetId: string }) => Promise<SshConnectionState | null>
disconnect: (args: { targetId: string }) => Promise<void>
getState: (args: { targetId: string }) => Promise<unknown>
getState: (args: { targetId: string }) => Promise<SshConnectionState | null>
testConnection: (args: {
targetId: string
}) => Promise<{ success: boolean; error?: string; state?: unknown }>
onStateChanged: (callback: (data: { targetId: string; state: unknown }) => void) => () => void
onHostKeyVerify: (
callback: (data: {
host: string
ip: string
fingerprint: string
keyType: string
responseChannel: string
}) => void
) => () => void
respondHostKeyVerify: (args: { channel: string; accepted: boolean }) => void
onAuthChallenge: (
callback: (data: {
targetId: string
name: string
instructions: string
prompts: { prompt: string; echo: boolean }[]
responseChannel: string
}) => void
) => () => void
respondAuthChallenge: (args: { channel: string; responses: string[] }) => void
onPasswordPrompt: (
callback: (data: { targetId: string; responseChannel: string }) => void
) => () => void
respondPassword: (args: { channel: string; password: string | null }) => void
}) => Promise<{ success: boolean; error?: string; state?: SshConnectionState }>
onStateChanged: (callback: (data: { targetId: string; state: SshConnectionState }) => void) => () => void
addPortForward: (args: {
targetId: string
localPort: number
@ -578,5 +556,15 @@ export type PreloadApi = {
entries: { name: string; isDirectory: boolean }[]
resolvedPath: string
}>
onCredentialRequest: (
callback: (data: {
requestId: string
targetId: string
kind: 'passphrase' | 'password'
detail: string
}) => void
) => () => void
onCredentialResolved: (callback: (data: { requestId: string }) => void) => () => void
submitCredential: (args: { requestId: string; value: string | null }) => Promise<void>
}
}

View file

@ -11,6 +11,7 @@ import type {
} from '../shared/types'
import type { RuntimeStatus, RuntimeSyncWindowGraph } from '../shared/runtime-types'
import type { RateLimitState } from '../shared/rate-limit-types'
import type { SshConnectionState, SshTarget } from '../shared/ssh-types'
import {
ORCA_EDITOR_SAVE_DIRTY_FILES_EVENT,
type EditorSaveDirtyFilesDetail
@ -1123,113 +1124,46 @@ const api = {
},
ssh: {
listTargets: (): Promise<unknown[]> => ipcRenderer.invoke('ssh:listTargets'),
listTargets: (): Promise<SshTarget[]> => ipcRenderer.invoke('ssh:listTargets'),
addTarget: (args: { target: Record<string, unknown> }): Promise<unknown> =>
addTarget: (args: { target: Omit<SshTarget, 'id'> }): Promise<SshTarget> =>
ipcRenderer.invoke('ssh:addTarget', args),
updateTarget: (args: { id: string; updates: Record<string, unknown> }): Promise<unknown> =>
updateTarget: (
args: { id: string; updates: Partial<Omit<SshTarget, 'id'>> }
): Promise<SshTarget> =>
ipcRenderer.invoke('ssh:updateTarget', args),
removeTarget: (args: { id: string }): Promise<void> =>
ipcRenderer.invoke('ssh:removeTarget', args),
importConfig: (): Promise<unknown[]> => ipcRenderer.invoke('ssh:importConfig'),
importConfig: (): Promise<SshTarget[]> => ipcRenderer.invoke('ssh:importConfig'),
connect: (args: { targetId: string }): Promise<unknown> =>
connect: (args: { targetId: string }): Promise<SshConnectionState | null> =>
ipcRenderer.invoke('ssh:connect', args),
disconnect: (args: { targetId: string }): Promise<void> =>
ipcRenderer.invoke('ssh:disconnect', args),
getState: (args: { targetId: string }): Promise<unknown> =>
getState: (args: { targetId: string }): Promise<SshConnectionState | null> =>
ipcRenderer.invoke('ssh:getState', args),
testConnection: (args: {
targetId: string
}): Promise<{ success: boolean; error?: string; state?: unknown }> =>
}): Promise<{ success: boolean; error?: string; state?: SshConnectionState }> =>
ipcRenderer.invoke('ssh:testConnection', args),
onStateChanged: (
callback: (data: { targetId: string; state: unknown }) => void
callback: (data: { targetId: string; state: SshConnectionState }) => void
): (() => void) => {
const listener = (
_event: Electron.IpcRendererEvent,
data: { targetId: string; state: unknown }
data: { targetId: string; state: SshConnectionState }
) => callback(data)
ipcRenderer.on('ssh:state-changed', listener)
return () => ipcRenderer.removeListener('ssh:state-changed', listener)
},
onHostKeyVerify: (
callback: (data: {
host: string
ip: string
fingerprint: string
keyType: string
responseChannel: string
}) => void
): (() => void) => {
const listener = (
_event: Electron.IpcRendererEvent,
data: {
host: string
ip: string
fingerprint: string
keyType: string
responseChannel: string
}
) => callback(data)
ipcRenderer.on('ssh:host-key-verify', listener)
return () => ipcRenderer.removeListener('ssh:host-key-verify', listener)
},
respondHostKeyVerify: (args: { channel: string; accepted: boolean }): void => {
ipcRenderer.send(args.channel, args.accepted)
},
onAuthChallenge: (
callback: (data: {
targetId: string
name: string
instructions: string
prompts: { prompt: string; echo: boolean }[]
responseChannel: string
}) => void
): (() => void) => {
const listener = (
_event: Electron.IpcRendererEvent,
data: {
targetId: string
name: string
instructions: string
prompts: { prompt: string; echo: boolean }[]
responseChannel: string
}
) => callback(data)
ipcRenderer.on('ssh:auth-challenge', listener)
return () => ipcRenderer.removeListener('ssh:auth-challenge', listener)
},
respondAuthChallenge: (args: { channel: string; responses: string[] }): void => {
ipcRenderer.send(args.channel, args.responses)
},
onPasswordPrompt: (
callback: (data: { targetId: string; responseChannel: string }) => void
): (() => void) => {
const listener = (
_event: Electron.IpcRendererEvent,
data: { targetId: string; responseChannel: string }
) => callback(data)
ipcRenderer.on('ssh:password-prompt', listener)
return () => ipcRenderer.removeListener('ssh:password-prompt', listener)
},
respondPassword: (args: { channel: string; password: string | null }): void => {
ipcRenderer.send(args.channel, args.password)
},
addPortForward: (args: {
targetId: string
localPort: number
@ -1250,7 +1184,38 @@ const api = {
}): Promise<{
entries: { name: string; isDirectory: boolean }[]
resolvedPath: string
}> => ipcRenderer.invoke('ssh:browseDir', args)
}> => ipcRenderer.invoke('ssh:browseDir', args),
onCredentialRequest: (
callback: (data: {
requestId: string
targetId: string
kind: 'passphrase' | 'password'
detail: string
}) => void
): (() => void) => {
const listener = (
_event: Electron.IpcRendererEvent,
data: {
requestId: string
targetId: string
kind: 'passphrase' | 'password'
detail: string
}
) => callback(data)
ipcRenderer.on('ssh:credential-request', listener)
return () => ipcRenderer.removeListener('ssh:credential-request', listener)
},
onCredentialResolved: (callback: (data: { requestId: string }) => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent, data: { requestId: string }) =>
callback(data)
ipcRenderer.on('ssh:credential-resolved', listener)
return () => ipcRenderer.removeListener('ssh:credential-resolved', listener)
},
submitCredential: (args: { requestId: string; value: string | null }): Promise<void> =>
ipcRenderer.invoke('ssh:submitCredential', args)
}
}

View file

@ -23,6 +23,7 @@ import WorktreeJumpPalette from './components/WorktreeJumpPalette'
import { StatusBar } from './components/status-bar/StatusBar'
import { UpdateCard } from './components/UpdateCard'
import { ZoomOverlay } from './components/ZoomOverlay'
import { SshPassphraseDialog } from './components/settings/SshPassphraseDialog'
import { useGitStatusPolling } from './components/right-sidebar/useGitStatusPolling'
import {
setRuntimeGraphStoreStateGetter,
@ -696,6 +697,7 @@ function App(): React.JSX.Element {
<WorktreeJumpPalette />
<UpdateCard />
<ZoomOverlay />
<SshPassphraseDialog />
<Toaster closeButton toastOptions={{ className: 'font-sans text-sm' }} />
</div>
)

View file

@ -50,20 +50,34 @@ export function SshPane(_props: SshPaneProps): React.JSX.Element {
const [showForm, setShowForm] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null)
const [form, setForm] = useState<EditingTarget>(EMPTY_FORM)
const [testing, setTesting] = useState<string | null>(null)
const [testingIds, setTestingIds] = useState<Set<string>>(new Set())
const [pendingRemove, setPendingRemove] = useState<{ id: string; label: string } | null>(null)
const loadTargets = useCallback(async () => {
const setSshTargetLabels = useAppStore((s) => s.setSshTargetLabels)
const loadTargets = useCallback(async (opts?: { signal?: AbortSignal }) => {
try {
const result = (await window.api.ssh.listTargets()) as SshTarget[]
if (opts?.signal?.aborted) {
return
}
setTargets(result)
const labels = new Map<string, string>()
for (const t of result) {
labels.set(t.id, t.label)
}
setSshTargetLabels(labels)
} catch {
toast.error('Failed to load SSH targets')
if (!opts?.signal?.aborted) {
toast.error('Failed to load SSH targets')
}
}
}, [])
}, [setSshTargetLabels])
useEffect(() => {
void loadTargets()
const abortController = new AbortController()
void loadTargets({ signal: abortController.signal })
return () => abortController.abort()
}, [loadTargets])
const handleSave = async (): Promise<void> => {
@ -80,6 +94,7 @@ export function SshPane(_props: SshPaneProps): React.JSX.Element {
const target = {
label: form.label.trim() || `${form.username}@${form.host}`,
configHost: form.configHost.trim() || form.host.trim(),
host: form.host.trim(),
port,
username: form.username.trim(),
@ -126,6 +141,7 @@ export function SshPane(_props: SshPaneProps): React.JSX.Element {
setEditingId(target.id)
setForm({
label: target.label,
configHost: target.configHost ?? target.host,
host: target.host,
port: String(target.port),
username: target.username,
@ -153,7 +169,7 @@ export function SshPane(_props: SshPaneProps): React.JSX.Element {
}
const handleTest = async (targetId: string): Promise<void> => {
setTesting(targetId)
setTestingIds((prev) => new Set(prev).add(targetId))
try {
const result = await window.api.ssh.testConnection({ targetId })
if (result.success) {
@ -164,7 +180,11 @@ export function SshPane(_props: SshPaneProps): React.JSX.Element {
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Test failed')
} finally {
setTesting(null)
setTestingIds((prev) => {
const next = new Set(prev)
next.delete(targetId)
return next
})
}
}
@ -238,7 +258,7 @@ export function SshPane(_props: SshPaneProps): React.JSX.Element {
key={target.id}
target={target}
state={sshConnectionStates.get(target.id)}
testing={testing === target.id}
testing={testingIds.has(target.id)}
onConnect={(id) => void handleConnect(id)}
onDisconnect={(id) => void handleDisconnect(id)}
onTest={(id) => void handleTest(id)}

View file

@ -0,0 +1,122 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { useAppStore } from '@/store'
export function SshPassphraseDialog(): React.JSX.Element | null {
const request = useAppStore((s) => s.sshCredentialQueue[0] ?? null)
const targetLabels = useAppStore((s) => s.sshTargetLabels)
const removeRequest = useAppStore((s) => s.removeSshCredentialRequest)
const [value, setValue] = useState('')
const [submitting, setSubmitting] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
const open = request !== null
const requestId = request?.requestId
useEffect(() => {
if (requestId) {
setValue('')
setSubmitting(false)
requestAnimationFrame(() => inputRef.current?.focus())
}
}, [requestId])
const handleSubmit = useCallback(async () => {
if (!request || !value) {
return
}
setSubmitting(true)
try {
await window.api.ssh.submitCredential({ requestId: request.requestId, value })
removeRequest(request.requestId)
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to submit SSH credential')
setSubmitting(false)
}
}, [request, value, removeRequest])
const handleCancel = useCallback(async () => {
if (request) {
setSubmitting(true)
try {
await window.api.ssh.submitCredential({ requestId: request.requestId, value: null })
removeRequest(request.requestId)
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to cancel SSH credential request')
setSubmitting(false)
}
}
}, [request, removeRequest])
if (!request) {
return null
}
const label = targetLabels.get(request.targetId) ?? request.targetId
const isPassword = request.kind === 'password'
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && void handleCancel()}>
<DialogContent showCloseButton={false} className="max-w-[360px]">
<DialogHeader>
<DialogTitle className="text-sm">
{isPassword ? 'SSH Password' : 'SSH Key Passphrase'}
</DialogTitle>
<DialogDescription className="text-xs">
{isPassword ? (
<>
Enter the password for <span className="font-medium">{label}</span>
</>
) : (
<>
Enter the passphrase for <span className="font-medium">{label}</span>
</>
)}
</DialogDescription>
</DialogHeader>
<div>
<label
htmlFor="ssh-credential-input"
className="text-[11px] font-medium text-muted-foreground mb-1 block"
>
{isPassword ? `Password for ${request.detail}` : `Passphrase for ${request.detail}`}
</label>
<Input
id="ssh-credential-input"
ref={inputRef}
type="password"
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
void handleSubmit()
}
}}
placeholder={isPassword ? 'Enter password' : 'Enter passphrase'}
className="h-8 text-sm"
disabled={submitting}
/>
</div>
<DialogFooter className="mt-1">
<Button variant="outline" size="sm" onClick={() => void handleCancel()} disabled={submitting}>
Cancel
</Button>
<Button size="sm" onClick={() => void handleSubmit()} disabled={!value || submitting}>
{isPassword ? 'Connect' : 'Unlock'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View file

@ -1,3 +1,4 @@
import { useState } from 'react'
import { Loader2, MonitorSmartphone, Pencil, Server, Trash2, Wifi, WifiOff } from 'lucide-react'
import type {
SshTarget,
@ -11,8 +12,6 @@ import { Button } from '../ui/button'
export const STATUS_LABELS: Record<SshConnectionStatus, string> = {
disconnected: 'Disconnected',
connecting: 'Connecting\u2026',
'host-key-verification': 'Verifying host key\u2026',
'auth-challenge': 'Authenticating\u2026',
'auth-failed': 'Auth failed',
'deploying-relay': 'Deploying relay\u2026',
connected: 'Connected',
@ -26,8 +25,6 @@ export function statusColor(status: SshConnectionStatus): string {
case 'connected':
return 'bg-emerald-500'
case 'connecting':
case 'host-key-verification':
case 'auth-challenge':
case 'deploying-relay':
case 'reconnecting':
return 'bg-yellow-500'
@ -41,9 +38,7 @@ export function statusColor(status: SshConnectionStatus): string {
}
export function isConnecting(status: SshConnectionStatus): boolean {
return ['connecting', 'host-key-verification', 'auth-challenge', 'deploying-relay'].includes(
status
)
return ['connecting', 'deploying-relay', 'reconnecting'].includes(status)
}
// ── SshTargetCard ────────────────────────────────────────────────────
@ -70,6 +65,23 @@ export function SshTargetCard({
onRemove
}: SshTargetCardProps): React.JSX.Element {
const status: SshConnectionStatus = state?.status ?? 'disconnected'
const [actionInFlight, setActionInFlight] = useState<'connect' | 'disconnect' | null>(null)
const handleConnect = (): void => {
if (actionInFlight) {
return
}
setActionInFlight('connect')
Promise.resolve(onConnect(target.id)).finally(() => setActionInFlight(null))
}
const handleDisconnect = (): void => {
if (actionInFlight) {
return
}
setActionInFlight('disconnect')
Promise.resolve(onDisconnect(target.id)).finally(() => setActionInFlight(null))
}
return (
<div className="flex items-center gap-3 rounded-lg border border-border/50 bg-card/40 px-4 py-3">
@ -95,8 +107,9 @@ export function SshTargetCard({
<Button
variant="ghost"
size="xs"
onClick={() => onDisconnect(target.id)}
onClick={handleDisconnect}
className="gap-1.5"
disabled={actionInFlight !== null}
>
<WifiOff className="size-3" />
Disconnect
@ -111,10 +124,15 @@ export function SshTargetCard({
<Button
variant="ghost"
size="xs"
onClick={() => onConnect(target.id)}
onClick={handleConnect}
className="gap-1.5"
disabled={actionInFlight !== null}
>
<Wifi className="size-3" />
{actionInFlight === 'connect' ? (
<Loader2 className="size-3 animate-spin" />
) : (
<Wifi className="size-3" />
)}
Connect
</Button>
<Button

View file

@ -5,6 +5,7 @@ import { Label } from '../ui/label'
export type EditingTarget = {
label: string
configHost: string
host: string
port: string
username: string
@ -15,6 +16,7 @@ export type EditingTarget = {
export const EMPTY_FORM: EditingTarget = {
label: '',
configHost: '',
host: '',
port: '22',
username: '',

View file

@ -47,15 +47,18 @@ export function SshDisconnectedDialog({
try {
await window.api.ssh.connect({ targetId })
onOpenChange(false)
toast.success(`Reconnected to ${targetLabel}`)
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Reconnection failed')
} finally {
setConnecting(false)
}
}, [targetId, targetLabel, onOpenChange])
}, [targetId, onOpenChange])
const isConnecting = connecting || status === 'reconnecting' || status === 'connecting'
const isConnecting =
connecting ||
status === 'connecting' ||
status === 'deploying-relay' ||
status === 'reconnecting'
const message = isConnecting
? 'Reconnecting to the remote host...'
: (STATUS_MESSAGES[status] ?? 'This remote repository is not connected.')
@ -63,40 +66,45 @@ export function SshDisconnectedDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md" showCloseButton={false}>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<DialogContent className="sm:max-w-sm gap-3 p-5" showCloseButton={false}>
<DialogHeader className="gap-1">
<DialogTitle className="flex items-center gap-2 text-sm font-semibold">
{isConnecting ? (
<Loader2 className="size-5 text-yellow-500 animate-spin" />
<Loader2 className="size-4 text-yellow-500 animate-spin" />
) : (
<WifiOff className="size-5 text-muted-foreground" />
<WifiOff className="size-4 text-muted-foreground" />
)}
{isConnecting ? 'Reconnecting...' : 'SSH Disconnected'}
</DialogTitle>
<DialogDescription className="pt-1">{message}</DialogDescription>
<DialogDescription className="text-xs">{message}</DialogDescription>
</DialogHeader>
<div className="flex items-center gap-3 rounded-lg border border-border/50 bg-card/40 px-4 py-3">
<Globe className="size-4 shrink-0 text-muted-foreground" />
<div className="flex items-center gap-2.5 rounded-md border border-border/50 bg-card/40 px-3 py-2">
<Globe className="size-3.5 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<span className="text-sm font-medium">{targetLabel}</span>
<span className="text-xs font-medium">{targetLabel}</span>
</div>
{isConnecting ? (
<Loader2 className="size-4 shrink-0 text-yellow-500 animate-spin" />
<Loader2 className="size-3.5 shrink-0 text-yellow-500 animate-spin" />
) : (
<span className={`size-2 shrink-0 rounded-full ${statusColor(status)}`} />
<span className={`size-1.5 shrink-0 rounded-full ${statusColor(status)}`} />
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isConnecting}>
<Button
variant="outline"
size="sm"
onClick={() => onOpenChange(false)}
disabled={isConnecting}
>
Dismiss
</Button>
{showReconnect && (
<Button onClick={() => void handleReconnect()} disabled={isConnecting}>
<Button size="sm" onClick={() => void handleReconnect()} disabled={isConnecting}>
{isConnecting ? (
<>
<Loader2 className="size-4 animate-spin" />
<Loader2 className="size-3.5 animate-spin" />
Connecting...
</>
) : (

View file

@ -84,6 +84,15 @@ const WorktreeCard = React.memo(function WorktreeCard({
})
const isSshDisconnected = sshStatus != null && sshStatus !== 'connected'
const [showDisconnectedDialog, setShowDisconnectedDialog] = useState(false)
// Why: on restart the previously-active worktree is auto-restored without a
// click, so the dialog never opens. Auto-show it for the active card when SSH
// is disconnected so the user sees the reconnect prompt immediately.
useEffect(() => {
if (isActive && isSshDisconnected) {
setShowDisconnectedDialog(true)
}
}, [isActive, isSshDisconnected])
// Why: read the target label from the store (populated during hydration in
// useIpcEvents.ts) instead of calling listTargets IPC per card instance.
const sshTargetLabel = useAppStore((s) =>

View file

@ -0,0 +1,212 @@
import React, { useCallback, useState } from 'react'
import { Loader2, MonitorSmartphone, Wifi, WifiOff } from 'lucide-react'
import { toast } from 'sonner'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { useAppStore } from '../../store'
import { STATUS_LABELS, statusColor } from '../settings/SshTargetCard'
import type { SshConnectionStatus } from '../../../../shared/ssh-types'
function isConnecting(status: SshConnectionStatus): boolean {
return ['connecting', 'deploying-relay', 'reconnecting'].includes(status)
}
function isReconnectable(status: SshConnectionStatus): boolean {
return ['disconnected', 'reconnection-failed', 'error', 'auth-failed'].includes(status)
}
function overallStatus(
statuses: SshConnectionStatus[]
): 'connected' | 'partial' | 'disconnected' | 'connecting' {
if (statuses.length === 0) {
return 'disconnected'
}
if (statuses.every((s) => s === 'connected')) {
return 'connected'
}
if (statuses.some((s) => isConnecting(s))) {
return 'connecting'
}
if (statuses.some((s) => s === 'connected')) {
return 'partial'
}
return 'disconnected'
}
function overallDotColor(status: 'connected' | 'partial' | 'disconnected' | 'connecting'): string {
switch (status) {
case 'connected':
return 'bg-emerald-500'
case 'partial':
return 'bg-yellow-500'
case 'connecting':
return 'bg-yellow-500'
default:
return 'bg-muted-foreground/40'
}
}
function overallLabel(status: 'connected' | 'partial' | 'disconnected' | 'connecting'): string {
switch (status) {
case 'connected':
return 'Connected'
case 'partial':
return 'Partial'
case 'connecting':
return 'Connecting…'
default:
return 'Disconnected'
}
}
function TargetRow({
targetId,
label,
status
}: {
targetId: string
label: string
status: SshConnectionStatus
}): React.JSX.Element {
const [busy, setBusy] = useState(false)
const handleConnect = useCallback(async () => {
setBusy(true)
try {
await window.api.ssh.connect({ targetId })
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Connection failed')
} finally {
setBusy(false)
}
}, [targetId])
const handleDisconnect = useCallback(async () => {
setBusy(true)
try {
await window.api.ssh.disconnect({ targetId })
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Disconnect failed')
} finally {
setBusy(false)
}
}, [targetId])
return (
<div className="flex items-center gap-2.5 px-2 py-1.5">
<span className={`size-1.5 shrink-0 rounded-full ${statusColor(status)}`} />
<div className="min-w-0 flex-1">
<div className="truncate text-[12px] font-medium">{label}</div>
<div className="text-[10px] text-muted-foreground">{STATUS_LABELS[status]}</div>
</div>
{busy ? (
<Loader2 className="size-3 shrink-0 animate-spin text-muted-foreground" />
) : isReconnectable(status) ? (
<button
type="button"
onClick={() => void handleConnect()}
className="shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium text-foreground hover:bg-accent/70"
>
Connect
</button>
) : status === 'connected' ? (
<button
type="button"
onClick={() => void handleDisconnect()}
className="shrink-0 rounded px-1.5 py-0.5 text-[10px] text-muted-foreground hover:bg-accent/70 hover:text-foreground"
>
Disconnect
</button>
) : null}
</div>
)
}
export function SshStatusSegment({
compact,
iconOnly
}: {
compact: boolean
iconOnly: boolean
}): React.JSX.Element | null {
const sshConnectionStates = useAppStore((s) => s.sshConnectionStates)
const sshTargetLabels = useAppStore((s) => s.sshTargetLabels)
const setActiveView = useAppStore((s) => s.setActiveView)
const openSettingsTarget = useAppStore((s) => s.openSettingsTarget)
const targets = Array.from(sshTargetLabels.entries()).map(([id, label]) => {
const state = sshConnectionStates.get(id)
return { id, label, status: (state?.status ?? 'disconnected') as SshConnectionStatus }
})
if (targets.length === 0) {
return null
}
const statuses = targets.map((t) => t.status)
const overall = overallStatus(statuses)
const anyConnecting = overall === 'connecting'
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="inline-flex items-center gap-1.5 cursor-pointer rounded px-1 py-0.5 hover:bg-accent/70"
aria-label="SSH connection status"
>
{iconOnly ? (
<span className="inline-flex items-center gap-1">
<span className={`inline-block size-2 rounded-full ${overallDotColor(overall)}`} />
{anyConnecting ? (
<Loader2 className="size-3 animate-spin text-muted-foreground" />
) : (
<MonitorSmartphone className="size-3 text-muted-foreground" />
)}
</span>
) : (
<span className="inline-flex items-center gap-1.5">
{anyConnecting ? (
<Loader2 className="size-3 animate-spin text-yellow-500" />
) : overall === 'connected' ? (
<Wifi className="size-3 text-emerald-500" />
) : overall === 'partial' ? (
<Wifi className="size-3 text-muted-foreground" />
) : (
<WifiOff className="size-3 text-muted-foreground" />
)}
{!compact && (
<span className="text-[11px]">
SSH <span className="text-muted-foreground">{overallLabel(overall)}</span>
</span>
)}
<span className={`inline-block size-1.5 rounded-full ${overallDotColor(overall)}`} />
</span>
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start" sideOffset={8} className="w-[220px]">
<div className="px-2 pt-1.5 pb-1 text-[10px] font-medium uppercase tracking-[0.08em] text-muted-foreground">
SSH Connections
</div>
{targets.map((t) => (
<TargetRow key={t.id} targetId={t.id} label={t.label} status={t.status} />
))}
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => {
openSettingsTarget({ pane: 'general', repoId: null, sectionId: 'ssh' })
setActiveView('settings')
}}
>
Manage SSH
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}

View file

@ -23,6 +23,7 @@ import type { CodexRateLimitAccountsState } from '../../../../shared/types'
import type { ProviderRateLimits, RateLimitWindow } from '../../../../shared/rate-limit-types'
import { ProviderIcon, ProviderPanel } from './tooltip'
import { markLiveCodexSessionsForRestart } from '@/lib/codex-session-restart'
import { SshStatusSegment } from './SshStatusSegment'
function getCodexAccountLabel(
state: CodexRateLimitAccountsState,
@ -118,13 +119,6 @@ function ProviderSegment({
// Has data (ok, fetching with stale data, or error with stale data)
const isStale = p.status === 'error'
const isFetching = p.status === 'fetching'
const trailingStatusIcon = isFetching ? (
<RefreshCw size={10} className="animate-spin text-muted-foreground" />
) : isStale ? (
<AlertTriangle size={11} className="text-muted-foreground/80" />
) : null
return (
<span className="inline-flex items-center gap-1.5">
<ProviderIcon provider={provider} />
@ -132,12 +126,7 @@ function ProviderSegment({
{p.session && <WindowLabel w={p.session} label="5h" />}
{p.session && p.weekly && <span className="text-muted-foreground">&middot;</span>}
{p.weekly && <WindowLabel w={p.weekly} label="wk" />}
<span className="inline-flex w-3 justify-center">
{/* Why: reserve the trailing provider-status slot even when no icon is
visible so Claude/Codex do not shift horizontally as fetching/error
adornments appear and disappear. */}
{trailingStatusIcon}
</span>
{isStale && <AlertTriangle size={11} className="text-muted-foreground/80" />}
</span>
)
}
@ -463,7 +452,9 @@ function StatusBarInner(): React.JSX.Element | null {
// configured but currently unavailable.
const showClaude = claude && statusBarItems.includes('claude')
const showCodex = codex && statusBarItems.includes('codex')
const showSsh = statusBarItems.includes('ssh')
const anyVisible = showClaude || showCodex
const anyFetching = claude?.status === 'fetching' || codex?.status === 'fetching'
const compact = containerWidth < 900
const iconOnly = containerWidth < 500
@ -475,52 +466,41 @@ function StatusBarInner(): React.JSX.Element | null {
ref={containerRefCallback}
className="flex items-center h-6 min-h-[24px] px-3 gap-4 border-t border-border bg-[var(--bg-titlebar,var(--card))] text-xs select-none shrink-0"
>
{iconOnly ? (
<>
{showClaude && (
<ProviderDetailsMenu
provider={claude}
compact={compact}
iconOnly
ariaLabel="Open Claude usage details"
/>
)}
{showCodex && <CodexSwitcherMenu codex={codex} compact={compact} iconOnly />}
</>
) : (
<>
{showClaude && (
<ProviderDetailsMenu
provider={claude}
compact={compact}
iconOnly={false}
ariaLabel="Open Claude usage details"
/>
)}
{showCodex && <CodexSwitcherMenu codex={codex} compact={compact} iconOnly={false} />}
</>
)}
<div className="flex items-center gap-3">
{showClaude && (
<ProviderDetailsMenu
provider={claude}
compact={compact}
iconOnly={iconOnly}
ariaLabel="Open Claude usage details"
/>
)}
{showCodex && <CodexSwitcherMenu codex={codex} compact={compact} iconOnly={iconOnly} />}
{anyVisible && (
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={handleRefresh}
disabled={isRefreshing}
className="p-0.5 rounded hover:bg-accent text-muted-foreground hover:text-foreground transition-colors disabled:opacity-40"
aria-label="Refresh rate limits"
>
<RefreshCw
size={11}
className={isRefreshing || anyFetching ? 'animate-spin' : ''}
/>
</button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={6}>
Refresh usage data
</TooltipContent>
</Tooltip>
)}
</div>
<div className="flex-1" />
{anyVisible && (
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={handleRefresh}
disabled={isRefreshing}
className="p-0.5 rounded hover:bg-accent text-muted-foreground hover:text-foreground transition-colors disabled:opacity-40"
aria-label="Refresh rate limits"
>
<RefreshCw size={11} className={isRefreshing ? 'animate-spin' : ''} />
</button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={6}>
Refresh usage data
</TooltipContent>
</Tooltip>
)}
{showSsh && <SshStatusSegment compact={compact} iconOnly={iconOnly} />}
</div>
</ContextMenuTrigger>
@ -537,6 +517,12 @@ function StatusBarInner(): React.JSX.Element | null {
>
Codex Usage
</ContextMenuCheckboxItem>
<ContextMenuCheckboxItem
checked={statusBarItems.includes('ssh')}
onCheckedChange={() => toggleStatusBarItem('ssh')}
>
SSH Status
</ContextMenuCheckboxItem>
</ContextMenuContent>
</ContextMenu>
)

View file

@ -1,3 +1,9 @@
const SSH_PREFIX = 'SSH connection is not active'
function isSshError(error: string): boolean {
return error.startsWith(SSH_PREFIX)
}
export function TerminalErrorToast({
error,
onDismiss
@ -5,6 +11,8 @@ export function TerminalErrorToast({
error: string
onDismiss: () => void
}): React.JSX.Element {
const ssh = isSshError(error)
return (
<div
style={{
@ -15,9 +23,9 @@ export function TerminalErrorToast({
zIndex: 50,
padding: '10px 14px',
borderRadius: 6,
background: 'rgba(220, 38, 38, 0.15)',
border: '1px solid rgba(220, 38, 38, 0.4)',
color: '#fca5a5',
background: ssh ? 'rgba(234, 179, 8, 0.12)' : 'rgba(220, 38, 38, 0.15)',
border: ssh ? '1px solid rgba(234, 179, 8, 0.35)' : '1px solid rgba(220, 38, 38, 0.4)',
color: ssh ? '#fde68a' : '#fca5a5',
fontSize: 12,
fontFamily: 'monospace',
whiteSpace: 'pre-wrap',
@ -27,22 +35,26 @@ export function TerminalErrorToast({
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start' }}>
<span>
{error}
{'\n'}
If this persists, please{' '}
<a
href="https://github.com/stablyai/orca/issues"
style={{ color: '#fca5a5', textDecoration: 'underline' }}
>
file an issue
</a>
.
{!ssh && (
<>
{'\n'}
If this persists, please{' '}
<a
href="https://github.com/stablyai/orca/issues"
style={{ color: '#fca5a5', textDecoration: 'underline' }}
>
file an issue
</a>
.
</>
)}
</span>
<button
onClick={onDismiss}
style={{
background: 'none',
border: 'none',
color: '#fca5a5',
color: ssh ? '#fde68a' : '#fca5a5',
cursor: 'pointer',
fontSize: 14,
padding: '0 0 0 8px',

View file

@ -382,6 +382,7 @@ export default function TerminalPane({
transport?.destroy?.()
paneTransportsRef.current.delete(paneId)
setCacheTimerStartedAt(`${tabId}:${paneId}`, null)
setTerminalError(null)
const newPaneBinding = connectPanePty(pane, manager, {
tabId,

View file

@ -347,6 +347,11 @@ export function connectPanePty(
startFreshSpawn()
return
}
// Why: this attach path reuses a PTY spawned by an earlier mount.
// Persist the binding here so tab-level PTY ownership stays correct
// even if no later spawn event or layout snapshot runs.
deps.syncPanePtyLayoutBinding(pane.id, spawnedPtyId)
deps.updateTabPtyId(deps.tabId, spawnedPtyId)
transport.attach({
existingPtyId: spawnedPtyId,
cols,

View file

@ -207,8 +207,17 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra
return result.id
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
storedCallbacks.onError?.(msg)
throw err
// Why: on cold start, SSH provider isn't registered yet so pty:spawn
// throws a raw IPC error. Replace with a friendly message since this
// is an expected state, not an application crash.
if (connectionId && msg.includes('No PTY provider for connection')) {
storedCallbacks.onError?.(
'SSH connection is not active. Use the reconnect dialog or Settings to connect.'
)
} else {
storedCallbacks.onError?.(msg)
}
return undefined
}
},

View file

@ -1,3 +1,4 @@
/* eslint-disable max-lines -- Why: this test file keeps the hook wiring mocks close to the assertions so IPC event behavior stays understandable and maintainable. */
import type * as ReactModule from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { resolveZoomTarget } from './useIpcEvents'
@ -65,9 +66,15 @@ describe('useIpcEvents updater integration', () => {
it('routes updater status events into store state', async () => {
const setUpdateStatus = vi.fn()
const removeSshCredentialRequest = vi.fn()
const updaterStatusListenerRef: { current: ((status: unknown) => void) | null } = {
current: null
}
const credentialResolvedListenerRef: {
current: ((data: { requestId: string }) => void) | null
} = {
current: null
}
vi.doMock('react', async () => {
const actual = await vi.importActual<typeof ReactModule>('react')
@ -102,6 +109,8 @@ describe('useIpcEvents updater integration', () => {
setRateLimitsFromPush: vi.fn(),
setSshConnectionState: vi.fn(),
setSshTargetLabels: vi.fn(),
enqueueSshCredentialRequest: vi.fn(),
removeSshCredentialRequest,
settings: { terminalFontSize: 13 }
})
}
@ -171,7 +180,12 @@ describe('useIpcEvents updater integration', () => {
ssh: {
listTargets: () => Promise.resolve([]),
getState: () => Promise.resolve(null),
onStateChanged: () => () => {}
onStateChanged: () => () => {},
onCredentialRequest: () => () => {},
onCredentialResolved: (listener: (data: { requestId: string }) => void) => {
credentialResolvedListenerRef.current = listener
return () => {}
}
}
}
})
@ -190,5 +204,170 @@ describe('useIpcEvents updater integration', () => {
updaterStatusListenerRef.current(availableStatus)
expect(setUpdateStatus).toHaveBeenCalledWith(availableStatus)
if (typeof credentialResolvedListenerRef.current !== 'function') {
throw new Error('Expected credential resolved listener to be registered')
}
credentialResolvedListenerRef.current({ requestId: 'req-1' })
expect(removeSshCredentialRequest).toHaveBeenCalledWith('req-1')
})
it('clears stale remote PTYs when an SSH connection fully disconnects', async () => {
const clearTabPtyId = vi.fn()
const setSshConnectionState = vi.fn()
const sshStateListenerRef: {
current: ((data: { targetId: string; state: unknown }) => void) | null
} = {
current: null
}
const storeState = {
setUpdateStatus: vi.fn(),
fetchRepos: vi.fn(),
fetchWorktrees: vi.fn(),
setActiveView: vi.fn(),
activeModal: null,
closeModal: vi.fn(),
openModal: vi.fn(),
activeWorktreeId: 'wt-1',
activeView: 'terminal',
setActiveRepo: vi.fn(),
setActiveWorktree: vi.fn(),
revealWorktreeInSidebar: vi.fn(),
setIsFullScreen: vi.fn(),
updateBrowserTabPageState: vi.fn(),
activeTabType: 'terminal',
editorFontZoomLevel: 0,
setEditorFontZoomLevel: vi.fn(),
setRateLimitsFromPush: vi.fn(),
setSshConnectionState,
setSshTargetLabels: vi.fn(),
enqueueSshCredentialRequest: vi.fn(),
removeSshCredentialRequest: vi.fn(),
clearTabPtyId,
repos: [{ id: 'repo-1', connectionId: 'conn-1' }],
worktreesByRepo: {
'repo-1': [{ id: 'wt-1', repoId: 'repo-1' }]
},
tabsByWorktree: {
'wt-1': [
{ id: 'tab-1', ptyId: 'pty-1', worktreeId: 'wt-1', title: 'Terminal 1' },
{ id: 'tab-2', ptyId: null, worktreeId: 'wt-1', title: 'Terminal 2' }
]
},
sshTargetLabels: new Map<string, string>([['conn-1', 'Remote']]),
settings: { terminalFontSize: 13 }
}
vi.doMock('react', async () => {
const actual = await vi.importActual<typeof ReactModule>('react')
return {
...actual,
useEffect: (effect: () => void | (() => void)) => {
effect()
}
}
})
vi.doMock('../store', () => ({
useAppStore: {
getState: () => storeState,
setState: vi.fn((updater: (state: typeof storeState) => typeof storeState) =>
updater(storeState)
)
}
}))
vi.doMock('@/lib/ui-zoom', () => ({
applyUIZoom: vi.fn()
}))
vi.doMock('@/lib/worktree-activation', () => ({
activateAndRevealWorktree: vi.fn(),
ensureWorktreeHasInitialTerminal: vi.fn()
}))
vi.doMock('@/components/sidebar/visible-worktrees', () => ({
getVisibleWorktreeIds: () => []
}))
vi.doMock('@/lib/editor-font-zoom', () => ({
nextEditorFontZoomLevel: vi.fn(() => 0),
computeEditorFontSize: vi.fn(() => 13)
}))
vi.doMock('@/components/settings/SettingsConstants', () => ({
zoomLevelToPercent: vi.fn(() => 100),
ZOOM_MIN: -3,
ZOOM_MAX: 3
}))
vi.doMock('@/lib/zoom-events', () => ({
dispatchZoomLevelChanged: vi.fn()
}))
vi.stubGlobal('window', {
api: {
repos: { onChanged: () => () => {} },
worktrees: { onChanged: () => () => {} },
ui: {
onOpenSettings: () => () => {},
onToggleLeftSidebar: () => () => {},
onToggleRightSidebar: () => () => {},
onToggleWorktreePalette: () => () => {},
onOpenQuickOpen: () => () => {},
onJumpToWorktreeIndex: () => () => {},
onActivateWorktree: () => () => {},
onNewBrowserTab: () => () => {},
onNewTerminalTab: () => () => {},
onCloseActiveTab: () => () => {},
onSwitchTab: () => () => {},
onToggleStatusBar: () => () => {},
onFullscreenChanged: () => () => {},
onTerminalZoom: () => () => {},
getZoomLevel: () => 0,
set: vi.fn()
},
updater: {
getStatus: () => Promise.resolve({ state: 'idle' }),
onStatus: () => () => {},
onClearDismissal: () => () => {}
},
browser: {
onGuestLoadFailed: () => () => {},
onOpenLinkInOrcaTab: () => () => {}
},
rateLimits: {
get: () => Promise.resolve({ limits: {}, lastUpdatedAt: Date.now() }),
onUpdate: () => () => {}
},
ssh: {
listTargets: () => Promise.resolve([]),
getState: () => Promise.resolve(null),
onStateChanged: (listener: (data: { targetId: string; state: unknown }) => void) => {
sshStateListenerRef.current = listener
return () => {}
},
onCredentialRequest: () => () => {},
onCredentialResolved: () => () => {}
}
}
})
const { useIpcEvents } = await import('./useIpcEvents')
useIpcEvents()
await Promise.resolve()
if (typeof sshStateListenerRef.current !== 'function') {
throw new Error('Expected ssh state listener to be registered')
}
sshStateListenerRef.current({
targetId: 'conn-1',
state: { status: 'disconnected', error: null, reconnectAttempt: 0 }
})
expect(setSshConnectionState).toHaveBeenCalledWith(
'conn-1',
expect.objectContaining({ status: 'disconnected' })
)
expect(clearTabPtyId).toHaveBeenCalledWith('tab-1')
expect(clearTabPtyId).not.toHaveBeenCalledWith('tab-2')
})
})

View file

@ -263,11 +263,91 @@ export function useIpcEvents(): void {
}
})()
unsubs.push(
window.api.ssh.onCredentialRequest((data) => {
useAppStore.getState().enqueueSshCredentialRequest(data)
})
)
unsubs.push(
window.api.ssh.onCredentialResolved(({ requestId }) => {
useAppStore.getState().removeSshCredentialRequest(requestId)
})
)
unsubs.push(
window.api.ssh.onStateChanged((data: { targetId: string; state: unknown }) => {
useAppStore
.getState()
.setSshConnectionState(data.targetId, data.state as SshConnectionState)
const store = useAppStore.getState()
const state = data.state as SshConnectionState
store.setSshConnectionState(data.targetId, state)
const remoteRepos = store.repos.filter((r) => r.connectionId === data.targetId)
// Why: targets added after boot aren't in the labels map. Re-fetch
// so the status bar popover shows the new target immediately.
if (!store.sshTargetLabels.has(data.targetId)) {
window.api.ssh
.listTargets()
.then((targets) => {
const labels = new Map<string, string>()
for (const t of targets as { id: string; label: string }[]) {
labels.set(t.id, t.label)
}
useAppStore.getState().setSshTargetLabels(labels)
})
.catch(() => {})
}
if (
['disconnected', 'auth-failed', 'reconnection-failed', 'error'].includes(state.status)
) {
// Why: an explicit disconnect or terminal failure tears down the SSH
// PTY provider without emitting per-PTY exit events. Clear the stale
// PTY ids in renderer state so a later reconnect remounts TerminalPane
// instead of keeping a dead remote PTY attached to the tab.
const remoteWorktreeIds = new Set(
Object.values(store.worktreesByRepo)
.flat()
.filter((w) => remoteRepos.some((r) => r.id === w.repoId))
.map((w) => w.id)
)
for (const worktreeId of remoteWorktreeIds) {
const tabs = useAppStore.getState().tabsByWorktree[worktreeId] ?? []
for (const tab of tabs) {
if (tab.ptyId) {
useAppStore.getState().clearTabPtyId(tab.id)
}
}
}
}
if (state.status === 'connected') {
void Promise.all(remoteRepos.map((r) => store.fetchWorktrees(r.id))).then(() => {
// Why: terminal panes that failed to spawn (no PTY provider on cold
// start) sit inert. Bumping generation forces TerminalPane to remount
// and retry pty:spawn. Only bump tabs with no live ptyId.
const freshStore = useAppStore.getState()
const remoteRepoIds = new Set(remoteRepos.map((r) => r.id))
const worktreeIds = Object.values(freshStore.worktreesByRepo)
.flat()
.filter((w) => remoteRepoIds.has(w.repoId))
.map((w) => w.id)
for (const worktreeId of worktreeIds) {
const tabs = freshStore.tabsByWorktree[worktreeId] ?? []
const hasDead = tabs.some((t) => !t.ptyId)
if (hasDead) {
useAppStore.setState((s) => ({
tabsByWorktree: {
...s.tabsByWorktree,
[worktreeId]: (s.tabsByWorktree[worktreeId] ?? []).map((t) =>
t.ptyId ? t : { ...t, generation: (t.generation ?? 0) + 1 }
)
}
}))
}
}
})
}
})
)

View file

@ -1,35 +1,43 @@
import type { StateCreator } from 'zustand'
import type { AppState } from '../types'
import type { SshConnectionState, SshConnectionStatus } from '../../../../shared/ssh-types'
import type { SshConnectionState } from '../../../../shared/ssh-types'
export type SshCredentialRequest = {
requestId: string
targetId: string
kind: 'passphrase' | 'password'
detail: string
}
export type SshSlice = {
sshConnectionStates: Map<string, SshConnectionState>
/** Maps target IDs to their user-facing labels. Populated during hydration
* so components can look up labels without per-component IPC calls. */
sshTargetLabels: Map<string, string>
sshCredentialQueue: SshCredentialRequest[]
setSshConnectionState: (targetId: string, state: SshConnectionState) => void
setSshTargetLabels: (labels: Map<string, string>) => void
getSshConnectionStatus: (connectionId: string | null | undefined) => SshConnectionStatus | null
enqueueSshCredentialRequest: (req: SshCredentialRequest) => void
removeSshCredentialRequest: (requestId: string) => void
}
export const createSshSlice: StateCreator<AppState, [], [], SshSlice> = (set, get) => ({
export const createSshSlice: StateCreator<AppState, [], [], SshSlice> = (set) => ({
sshConnectionStates: new Map(),
sshTargetLabels: new Map(),
sshCredentialQueue: [],
setSshConnectionState: (targetId, state) =>
set(() => {
const next = new Map(get().sshConnectionStates)
set((s) => {
const next = new Map(s.sshConnectionStates)
next.set(targetId, state)
return { sshConnectionStates: next }
}),
setSshTargetLabels: (labels) => set({ sshTargetLabels: labels }),
getSshConnectionStatus: (connectionId) => {
if (!connectionId) {
return null
}
const state = get().sshConnectionStates.get(connectionId)
return state?.status ?? 'disconnected'
}
enqueueSshCredentialRequest: (req) =>
set((s) => ({ sshCredentialQueue: [...s.sshCredentialQueue, req] })),
removeSshCredentialRequest: (requestId) =>
set((s) => ({
sshCredentialQueue: s.sshCredentialQueue.filter((req) => req.requestId !== requestId)
}))
})

View file

@ -52,7 +52,7 @@ export const DEFAULT_WORKTREE_CARD_PROPERTIES: WorktreeCardProperty[] = [
'comment'
]
export const DEFAULT_STATUS_BAR_ITEMS: StatusBarItem[] = ['claude', 'codex']
export const DEFAULT_STATUS_BAR_ITEMS: StatusBarItem[] = ['claude', 'codex', 'ssh']
export const REPO_COLORS = [
'#737373', // neutral

View file

@ -18,8 +18,6 @@ describe('SSH types', () => {
const statuses: SshConnectionStatus[] = [
'disconnected',
'connecting',
'host-key-verification',
'auth-challenge',
'auth-failed',
'deploying-relay',
'connected',
@ -27,7 +25,7 @@ describe('SSH types', () => {
'reconnection-failed',
'error'
]
expect(statuses).toHaveLength(10)
expect(statuses).toHaveLength(8)
})
it('SshConnectionState composes correctly', () => {

View file

@ -3,6 +3,8 @@
export type SshTarget = {
id: string
label: string
/** Host alias to resolve through OpenSSH config (ssh -G). */
configHost?: string
host: string
port: number
username: string
@ -17,8 +19,6 @@ export type SshTarget = {
export type SshConnectionStatus =
| 'disconnected'
| 'connecting'
| 'host-key-verification'
| 'auth-challenge'
| 'auth-failed'
| 'deploying-relay'
| 'connected'

View file

@ -545,7 +545,7 @@ export type OpenCodeStatusEvent = {
export type WorktreeCardProperty = 'status' | 'unread' | 'ci' | 'issue' | 'pr' | 'comment'
export type StatusBarItem = 'claude' | 'codex'
export type StatusBarItem = 'claude' | 'codex' | 'ssh'
export type PersistedUIState = {
lastActiveRepoId: string | null