mirror of
https://github.com/stablyai/orca.git
synced 2026-10-12 08:02:37 +00:00
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:
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }>
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user