From 0f67ff1f7ff57f2b5bb658833acf21660dffb7ee Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 4 May 2026 14:48:37 -0700 Subject: [PATCH] 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 --- src/main/ipc/ssh.ts | 16 +++++ src/main/ssh/ssh-connection.ts | 14 +++- src/preload/api-types.ts | 1 + src/preload/index.ts | 3 + .../terminal-pane/pty-connection.ts | 68 +++++++++++++++++++ 5 files changed, 101 insertions(+), 1 deletion(-) diff --git a/src/main/ipc/ssh.ts b/src/main/ipc/ssh.ts index 91bba2f0487..edb556697b1 100644 --- a/src/main/ipc/ssh.ts +++ b/src/main/ipc/ssh.ts @@ -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) { diff --git a/src/main/ssh/ssh-connection.ts b/src/main/ssh/ssh-connection.ts index 6c986fbcb48..fc19c622b1c 100644 --- a/src/main/ssh/ssh-connection.ts +++ b/src/main/ssh/ssh-connection.ts @@ -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 { 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 diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 3c5551eb3b8..b87b895f078 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -943,6 +943,7 @@ export type PreloadApi = { connect: (args: { targetId: string }) => Promise disconnect: (args: { targetId: string }) => Promise getState: (args: { targetId: string }) => Promise + needsPassphrasePrompt: (args: { targetId: string }) => Promise testConnection: (args: { targetId: string }) => Promise<{ success: boolean; error?: string; state?: SshConnectionState }> diff --git a/src/preload/index.ts b/src/preload/index.ts index c568c75a24d..fd71d204430 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1717,6 +1717,9 @@ const api = { getState: (args: { targetId: string }): Promise => ipcRenderer.invoke('ssh:getState', args), + needsPassphrasePrompt: (args: { targetId: string }): Promise => + ipcRenderer.invoke('ssh:needsPassphrasePrompt', args), + testConnection: (args: { targetId: string }): Promise<{ success: boolean; error?: string; state?: SshConnectionState }> => diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 71fdaadc712..c63f0162fae 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -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((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.