feat(ssh): gate auto-connect on passphrase prompt

Adds ssh:needsPassphrasePrompt IPC so tab focus / Cmd+J auto-connect
waits for user-driven connect when a passphrase dialog would otherwise
pop unprompted. No-passphrase targets continue auto-connecting.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinjing
2026-05-04 14:48:37 -07:00
co-authored by Orca
parent ea8ea08116
commit 0f67ff1f7f
5 changed files with 101 additions and 1 deletions
+16
View File
@@ -162,6 +162,7 @@ export function registerSshHandlers(
'ssh:connect',
'ssh:disconnect',
'ssh:getState',
'ssh:needsPassphrasePrompt',
'ssh:testConnection',
'ssh:addPortForward',
'ssh:updatePortForward',
@@ -424,6 +425,21 @@ export function registerSshHandlers(
return connectionManager!.getState(args.targetId)
})
// Why: callers that want to auto-connect (Cmd+J jump, terminal reattach) need
// to know whether doing so will pop a passphrase/password dialog. Auto-firing
// the connect is fine when no prompt is needed, but surprising otherwise —
// the user expects to enter the credential before the app starts connecting.
// Returns true if the target's last successful connect required a credential
// AND the live SshConnection (if any) does not already have one cached.
ipcMain.handle('ssh:needsPassphrasePrompt', (_event, args: { targetId: string }) => {
const target = sshStore!.getTarget(args.targetId)
if (!target?.lastRequiredPassphrase) {
return false
}
const conn = connectionManager!.getConnection(args.targetId)
return !conn?.hasCachedCredential()
})
ipcMain.handle('ssh:testConnection', async (_event, args: { targetId: string }) => {
const target = sshStore!.getTarget(args.targetId)
if (!target) {
+13 -1
View File
@@ -55,6 +55,16 @@ export class SshConnection {
return { ...this.target }
}
// Why: exposes whether a passphrase/password is already cached in-memory for
// this connection. Used by ssh:needsPassphrasePrompt so callers can decide
// whether a manual-reconnect will prompt or go through silently. Without this,
// lastRequiredPassphrase stays true across the session even after the user
// has entered the credential once, causing redundant "enter passphrase"
// prompts on disconnect→reconnect cycles within a single app session.
hasCachedCredential(): boolean {
return this.cachedPassphrase != null || this.cachedPassword != null
}
async exec(cmd: string): Promise<ClientChannel> {
if (!this.client) {
throw new Error('Not connected')
@@ -110,7 +120,9 @@ export class SshConnection {
this.proxyProcess = null
const connectGeneration = ++this.connectGeneration
const resolved = await resolveWithSshG(this.target.configHost || this.target.label).catch(() => 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
+1
View File
@@ -943,6 +943,7 @@ export type PreloadApi = {
connect: (args: { targetId: string }) => Promise<SshConnectionState | null>
disconnect: (args: { targetId: string }) => Promise<void>
getState: (args: { targetId: string }) => Promise<SshConnectionState | null>
needsPassphrasePrompt: (args: { targetId: string }) => Promise<boolean>
testConnection: (args: {
targetId: string
}) => Promise<{ success: boolean; error?: string; state?: SshConnectionState }>
+3
View File
@@ -1717,6 +1717,9 @@ const api = {
getState: (args: { targetId: string }): Promise<SshConnectionState | null> =>
ipcRenderer.invoke('ssh:getState', args),
needsPassphrasePrompt: (args: { targetId: string }): Promise<boolean> =>
ipcRenderer.invoke('ssh:needsPassphrasePrompt', args),
testConnection: (args: {
targetId: string
}): Promise<{ success: boolean; error?: string; state?: SshConnectionState }> =>
@@ -639,6 +639,74 @@ export function connectPanePty(
)
if (pendingSessionId || isDeferredTarget) {
void (async () => {
// Why: if the target requires a passphrase/password and no credential
// is cached yet, auto-firing ssh.connect would surprise the user —
// a prompt pops unprompted just because they focused a tab / jumped
// via Cmd+J. Wait for the user to initiate the connect (via
// SshDisconnectedDialog → passphrase dialog) before proceeding with
// the PTY reattach. No-passphrase targets (ssh-agent, unencrypted
// key, cached creds) return false here and continue auto-connecting
// as before.
let needsPrompt = false
try {
needsPrompt = await window.api.ssh.needsPassphrasePrompt({
targetId: connectionId
})
} catch (err) {
console.warn('[pty-connection] needsPassphrasePrompt probe failed:', err)
// Why: if the probe fails, fall through to the existing auto-connect
// behavior rather than stranding the tab — a stuck tab is worse
// than a surprising prompt.
}
if (disposed) {
return
}
if (needsPrompt) {
const alreadyConnected =
useAppStore.getState().sshConnectionStates.get(connectionId)?.status === 'connected'
if (!alreadyConnected) {
// Wait for the user-driven connect (SshDisconnectedDialog →
// passphrase dialog → ssh.connect) to complete, then continue.
// Why: resolve on terminal-failure statuses too ('auth-failed',
// 'error', 'reconnection-failed') so this promise can't hang
// forever if the user cancels or the connect fails —
// waitForSshConnection below has its own error path that will
// surface the failure via reportError.
await new Promise<void>((resolve) => {
const isTerminalStatus = (status: string | undefined): boolean =>
status === 'connected' ||
status === 'auth-failed' ||
status === 'error' ||
status === 'reconnection-failed'
const unsub = useAppStore.subscribe((state) => {
if (disposed) {
unsub()
resolve()
return
}
if (isTerminalStatus(state.sshConnectionStates.get(connectionId)?.status)) {
unsub()
resolve()
}
})
// Why: re-read state immediately after subscribing to close the
// race where status transitioned between the alreadyConnected
// check above and the subscribe registration — otherwise we'd
// wait forever for a state change that already happened.
const currentStatus = useAppStore
.getState()
.sshConnectionStates.get(connectionId)?.status
if (isTerminalStatus(currentStatus)) {
unsub()
resolve()
}
})
if (disposed) {
return
}
}
}
// Why: ensure the SSH connection is established before attempting
// PTY reattach. Multiple panes/tabs may need the same connection,
// so we wait for it rather than returning early when in-flight.