mirror of
https://github.com/stablyai/orca
synced 2026-04-21 14:17:16 +00:00
fix: harden SSH connection reliability and add passphrase prompt (#636)
This commit is contained in:
parent
8c016c91b2
commit
42e04268fb
37 changed files with 2066 additions and 751 deletions
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
65
src/main/ipc/ssh-passphrase.ts
Normal file
65
src/main/ipc/ssh-passphrase.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
64
src/main/ssh/ssh-config-resolver.test.ts
Normal file
64
src/main/ssh/ssh-config-resolver.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
369
src/main/ssh/ssh-connection-utils.test.ts
Normal file
369
src/main/ssh/ssh-connection-utils.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
112
src/main/ssh/ssh-relay-deploy.test.ts
Normal file
112
src/main/ssh/ssh-relay-deploy.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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}`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
52
src/preload/api-types.d.ts
vendored
52
src/preload/api-types.d.ts
vendored
|
|
@ -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>
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
122
src/renderer/src/components/settings/SshPassphraseDialog.tsx
Normal file
122
src/renderer/src/components/settings/SshPassphraseDialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: '',
|
||||
|
|
|
|||
|
|
@ -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...
|
||||
</>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
212
src/renderer/src/components/status-bar/SshStatusSegment.tsx
Normal file
212
src/renderer/src/components/status-bar/SshStatusSegment.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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">·</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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
)
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}))
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue