From 475adfc4a292e082e5bd139fdaf90234bc6ffe07 Mon Sep 17 00:00:00 2001 From: Gemini CLI Date: Tue, 26 May 2026 17:54:23 +0000 Subject: [PATCH 1/5] feat(backend): add support for SSH password/PIN prompt --- src-tauri/src/commands.rs | 7 ++++++ src-tauri/src/export_import_tests.rs | 1 + src-tauri/src/models.rs | 8 +++++++ src-tauri/src/ssh_tunnel.rs | 33 ++++++++++++++++++++++------ 4 files changed, 42 insertions(+), 7 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 790d5602..e6d9980b 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -194,6 +194,7 @@ pub async fn expand_ssh_connection_params( expanded_params.ssh_password = ssh_conn.password.clone(); expanded_params.ssh_key_file = ssh_conn.key_file.clone(); expanded_params.ssh_key_passphrase = ssh_conn.key_passphrase.clone(); + expanded_params.ssh_allow_passphrase_prompt = ssh_conn.allow_passphrase_prompt; } } @@ -258,6 +259,7 @@ pub fn resolve_connection_params(params: &ConnectionParams) -> Result(app: &AppHandle) -> Result<(), S Some(key_file.clone()) }, key_passphrase: None, + allow_passphrase_prompt: None, save_in_keychain: conn.params.save_in_keychain, }; @@ -1139,6 +1142,7 @@ pub async fn save_ssh_connection( } else { ssh.key_passphrase.clone() }, + allow_passphrase_prompt: ssh.allow_passphrase_prompt, save_in_keychain: ssh.save_in_keychain, }; @@ -1206,6 +1210,7 @@ pub async fn update_ssh_connection( } else { ssh.key_passphrase.clone() }, + allow_passphrase_prompt: ssh.allow_passphrase_prompt, save_in_keychain: ssh.save_in_keychain, }; @@ -1302,6 +1307,7 @@ pub async fn test_ssh_connection( resolved_password.as_deref(), ssh.key_file.as_deref(), resolved_passphrase.as_deref(), + ssh.allow_passphrase_prompt.unwrap_or(false), ) } @@ -1695,6 +1701,7 @@ mod tests { password: password.map(|p| p.to_string()), key_file: None, key_passphrase: None, + allow_passphrase_prompt: None, save_in_keychain: Some(save_in_keychain), } } diff --git a/src-tauri/src/export_import_tests.rs b/src-tauri/src/export_import_tests.rs index 13bc7621..73f26ca2 100644 --- a/src-tauri/src/export_import_tests.rs +++ b/src-tauri/src/export_import_tests.rs @@ -52,6 +52,7 @@ mod tests { password: Some("ssh_password".to_string()), key_file: None, key_passphrase: None, + allow_passphrase_prompt: None, save_in_keychain: Some(true), }], }; diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index 6261201a..abfdd15d 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -80,6 +80,8 @@ pub struct SshConnection { pub key_file: Option, #[serde(skip_serializing_if = "Option::is_none")] pub key_passphrase: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub allow_passphrase_prompt: Option, pub save_in_keychain: Option, } @@ -95,6 +97,8 @@ pub struct SshConnectionInput { pub key_file: Option, #[serde(skip_serializing_if = "Option::is_none")] pub key_passphrase: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub allow_passphrase_prompt: Option, pub save_in_keychain: Option, } @@ -110,6 +114,8 @@ pub struct SshTestParams { #[serde(skip_serializing_if = "Option::is_none")] pub key_passphrase: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub allow_passphrase_prompt: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub connection_id: Option, } @@ -141,6 +147,8 @@ pub struct ConnectionParams { pub ssh_key_file: Option, #[serde(skip_serializing_if = "Option::is_none")] pub ssh_key_passphrase: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ssh_allow_passphrase_prompt: Option, pub save_in_keychain: Option, // Connection ID for stable pooling (not persisted, set at runtime) #[serde(skip_serializing_if = "Option::is_none")] diff --git a/src-tauri/src/ssh_tunnel.rs b/src-tauri/src/ssh_tunnel.rs index d9f9492a..27dcffac 100644 --- a/src-tauri/src/ssh_tunnel.rs +++ b/src-tauri/src/ssh_tunnel.rs @@ -95,13 +95,14 @@ impl SshTunnel { ssh_password: Option<&str>, ssh_key_file: Option<&str>, ssh_key_passphrase: Option<&str>, + ssh_allow_passphrase_prompt: bool, remote_host: &str, remote_port: u16, ) -> Result { let use_system_ssh = should_use_system_ssh(ssh_password); println!( - "[SSH Tunnel] New Request: Host={}, Port={}, User={}, UseSystemSSH={}", - ssh_host, ssh_port, ssh_user, use_system_ssh + "[SSH Tunnel] New Request: Host={}, Port={}, User={}, UseSystemSSH={}, AllowPrompt={}", + ssh_host, ssh_port, ssh_user, use_system_ssh, ssh_allow_passphrase_prompt ); let local_port = { @@ -120,6 +121,7 @@ impl SshTunnel { ssh_port, ssh_user, ssh_key_file, + ssh_allow_passphrase_prompt, remote_host, remote_port, local_port, @@ -152,6 +154,7 @@ impl SshTunnel { ssh_port: u16, ssh_user: &str, ssh_key_file: Option<&str>, + ssh_allow_passphrase_prompt: bool, remote_host: &str, remote_port: u16, local_port: u16, @@ -188,7 +191,11 @@ impl SshTunnel { args.push("-o".to_string()); args.push("StrictHostKeyChecking=accept-new".to_string()); args.push("-o".to_string()); - args.push("BatchMode=yes".to_string()); + if ssh_allow_passphrase_prompt { + args.push("BatchMode=no".to_string()); + } else { + args.push("BatchMode=yes".to_string()); + } args.push(destination); @@ -526,15 +533,22 @@ pub fn test_ssh_connection( ssh_password: Option<&str>, ssh_key_file: Option<&str>, ssh_key_passphrase: Option<&str>, + ssh_allow_passphrase_prompt: bool, ) -> Result { let use_system_ssh = should_use_system_ssh(ssh_password); println!( - "[SSH Test] Testing connection to {}:{} as {} (UseSystemSSH={})", - ssh_host, ssh_port, ssh_user, use_system_ssh + "[SSH Test] Testing connection to {}:{} as {} (UseSystemSSH={}, AllowPrompt={})", + ssh_host, ssh_port, ssh_user, use_system_ssh, ssh_allow_passphrase_prompt ); if use_system_ssh { - test_ssh_connection_system(ssh_host, ssh_port, ssh_user, ssh_key_file) + test_ssh_connection_system( + ssh_host, + ssh_port, + ssh_user, + ssh_key_file, + ssh_allow_passphrase_prompt, + ) } else { test_ssh_connection_russh( ssh_host, @@ -553,6 +567,7 @@ fn test_ssh_connection_system( ssh_port: u16, ssh_user: &str, ssh_key_file: Option<&str>, + ssh_allow_passphrase_prompt: bool, ) -> Result { println!("[SSH Test] Using system SSH (supports ~/.ssh/config)"); @@ -563,7 +578,11 @@ fn test_ssh_connection_system( let mut args = Vec::with_capacity(12); args.extend([ "-o", - "BatchMode=yes", + if ssh_allow_passphrase_prompt { + "BatchMode=no" + } else { + "BatchMode=yes" + }, "-o", "ConnectTimeout=10", "-o", From bd64d8e6b219f7a3c4a9dc4c76e0e0307223c0f9 Mon Sep 17 00:00:00 2001 From: Gemini CLI Date: Tue, 26 May 2026 17:54:23 +0000 Subject: [PATCH 2/5] feat(frontend): add SSH prompt toggle to connection modals --- src/components/modals/NewConnectionModal.tsx | 18 ++++++++++++++++++ src/components/modals/SshConnectionsModal.tsx | 19 +++++++++++++++++++ src/utils/connections.ts | 1 + src/utils/credentials.ts | 1 + src/utils/ssh.ts | 1 + 5 files changed, 40 insertions(+) diff --git a/src/components/modals/NewConnectionModal.tsx b/src/components/modals/NewConnectionModal.tsx index 4790d098..d1de710a 100644 --- a/src/components/modals/NewConnectionModal.tsx +++ b/src/components/modals/NewConnectionModal.tsx @@ -59,6 +59,7 @@ interface ConnectionParams { ssh_password?: string; ssh_key_file?: string; ssh_key_passphrase?: string; + ssh_allow_passphrase_prompt?: boolean; save_in_keychain?: boolean; } @@ -1432,6 +1433,23 @@ export const NewConnectionModal = ({ type="password" placeholder={t("newConnection.sshKeyPassphrasePlaceholder")} /> +
+ + updateField("ssh_allow_passphrase_prompt", e.target.checked) + } + className="accent-blue-500 w-3.5 h-3.5 rounded cursor-pointer" + /> + +
)} diff --git a/src/components/modals/SshConnectionsModal.tsx b/src/components/modals/SshConnectionsModal.tsx index 3baabaf2..a6420812 100644 --- a/src/components/modals/SshConnectionsModal.tsx +++ b/src/components/modals/SshConnectionsModal.tsx @@ -217,6 +217,7 @@ export function SshConnectionsModal({ password: formData.password, key_file: formData.key_file, key_passphrase: formData.key_passphrase, + allow_passphrase_prompt: formData.allow_passphrase_prompt, save_in_keychain: formData.save_in_keychain, }; @@ -559,6 +560,24 @@ export function SshConnectionsModal({ +
+ { + updateField("allow_passphrase_prompt", e.target.checked); + }} + className="accent-blue-500 w-4 h-4 rounded cursor-pointer" + /> + +
+ {/* Test Button and Status */}
+
+ + {/* Content */} +
+

+ {request.prompt} +

+ + {isSecret && ( + setValue(e.target.value)} + className="w-full px-3 py-2 bg-base border border-strong rounded-lg text-primary focus:border-blue-500 focus:outline-none" + placeholder={t("sshAskpass.placeholder")} + autoFocus + /> + )} + + {isNotify && ( +
+ + {t("sshAskpass.waiting")} +
+ )} + + {/* Footer */} +
+ + {!isNotify && ( + + )} +
+
+ + + ); +}; diff --git a/src/hooks/useSshAskpass.ts b/src/hooks/useSshAskpass.ts new file mode 100644 index 00000000..82500cf5 --- /dev/null +++ b/src/hooks/useSshAskpass.ts @@ -0,0 +1,51 @@ +import { useCallback, useEffect, useState } from "react"; +import { invoke } from "@tauri-apps/api/core"; +import { listen } from "@tauri-apps/api/event"; +import type { SshAskpassRequest } from "../types/askpass"; + +const REQUEST_EVENT = "ssh-askpass://request"; +const DISMISS_EVENT = "ssh-askpass://dismiss"; + +export interface UseSshAskpassResult { + /** Oldest pending prompt, shown one at a time. */ + current: SshAskpassRequest | null; + /** Answer a secret/confirm prompt; `null` means the user cancelled. */ + respond: (id: number, response: string | null) => Promise; + /** Remove a prompt locally without answering (notify modals). */ + dismiss: (id: number) => void; +} + +/** + * Queue of SSH askpass prompts emitted by the backend while a system `ssh` + * process is authenticating (key passphrases, security-key PINs, presence + * notifications). Mounted once at the App level via `SshAskpassGate`. + */ +export function useSshAskpass(): UseSshAskpassResult { + const [queue, setQueue] = useState([]); + + useEffect(() => { + const unlistenRequest = listen(REQUEST_EVENT, (event) => { + setQueue((prev) => [...prev, event.payload]); + }); + // The backend dismisses prompts that timed out or whose security-key + // notification was satisfied (key touched). + const unlistenDismiss = listen(DISMISS_EVENT, (event) => { + setQueue((prev) => prev.filter((r) => r.id !== event.payload)); + }); + return () => { + unlistenRequest.then((fn) => fn()).catch(() => {}); + unlistenDismiss.then((fn) => fn()).catch(() => {}); + }; + }, []); + + const respond = useCallback(async (id: number, response: string | null) => { + setQueue((prev) => prev.filter((r) => r.id !== id)); + await invoke("respond_ssh_askpass", { id, response }); + }, []); + + const dismiss = useCallback((id: number) => { + setQueue((prev) => prev.filter((r) => r.id !== id)); + }, []); + + return { current: queue[0] ?? null, respond, dismiss }; +} diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 9e6aab16..e8671733 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -705,6 +705,12 @@ "testFailed": "Verbindungstest fehlgeschlagen", "savedInKeychain": "Passwort im System-Keychain gespeichert" }, + "sshAskpass": { + "title": "SSH-Authentifizierung", + "subtitle": "Die SSH-Verbindung benötigt Ihre Eingabe", + "placeholder": "PIN oder Passphrase", + "waiting": "Warten auf Ihren Sicherheitsschlüssel — zum Bestätigen berühren." + }, "dataGrid": { "newRow": "NEU", "noData": "Keine Daten zum Anzeigen", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index e07d6a79..4e378a1a 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -726,6 +726,12 @@ "testFailed": "Connection test failed", "savedInKeychain": "Password saved in system keychain" }, + "sshAskpass": { + "title": "SSH Authentication", + "subtitle": "The SSH connection needs your input", + "placeholder": "PIN or passphrase", + "waiting": "Waiting for your security key — touch it to confirm." + }, "dataGrid": { "newRow": "NEW", "noData": "No data to display", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index aada42df..6c6dc7f1 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -710,6 +710,12 @@ "testFailed": "La prueba de conexión falló", "savedInKeychain": "Contraseña guardada en el llavero del sistema" }, + "sshAskpass": { + "title": "Autenticación SSH", + "subtitle": "La conexión SSH necesita tu intervención", + "placeholder": "PIN o frase de contraseña", + "waiting": "Esperando tu llave de seguridad — tócala para confirmar." + }, "dataGrid": { "newRow": "NUEVA", "noData": "No hay datos para mostrar", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 7442d863..ffda0967 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -705,6 +705,12 @@ "testFailed": "Échec du test de connexion", "savedInKeychain": "Mot de passe enregistré dans le trousseau système" }, + "sshAskpass": { + "title": "Authentification SSH", + "subtitle": "La connexion SSH nécessite votre intervention", + "placeholder": "PIN ou phrase secrète", + "waiting": "En attente de votre clé de sécurité — touchez-la pour confirmer." + }, "dataGrid": { "newRow": "NOUVELLE", "noData": "Aucune donnée à afficher", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 0134ab0f..c07b39e6 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -710,6 +710,12 @@ "testFailed": "Test connessione fallito", "savedInKeychain": "Password salvata nel portachiavi di sistema" }, + "sshAskpass": { + "title": "Autenticazione SSH", + "subtitle": "La connessione SSH richiede un tuo intervento", + "placeholder": "PIN o passphrase", + "waiting": "In attesa della security key — toccala per confermare." + }, "dataGrid": { "newRow": "NUOVA", "noData": "Nessun dato da visualizzare", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 6b3fa470..63b51e96 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -719,6 +719,12 @@ "testFailed": "接続テストに失敗しました", "savedInKeychain": "パスワードをシステムのキーチェーンに保存しました" }, + "sshAskpass": { + "title": "SSH認証", + "subtitle": "SSH接続には入力が必要です", + "placeholder": "PINまたはパスフレーズ", + "waiting": "セキュリティキーを待っています — タッチして確認してください。" + }, "dataGrid": { "noData": "表示するデータがありません", "editRow": "行を編集", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 9259c76e..3b16d3d8 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -698,6 +698,12 @@ "testFailed": "Проверка подключения не удалась", "savedInKeychain": "Пароль сохранён в системном хранилище ключей" }, + "sshAskpass": { + "title": "Аутентификация SSH", + "subtitle": "SSH-подключению требуется ваш ввод", + "placeholder": "PIN-код или парольная фраза", + "waiting": "Ожидание ключа безопасности — коснитесь его для подтверждения." + }, "dataGrid": { "newRow": "НОВАЯ", "noData": "Нет данных для отображения", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 3f3bbc8e..4d0d0459 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -673,6 +673,12 @@ "testFailed": "连接测试失败", "savedInKeychain": "密码已保存到系统密钥链" }, + "sshAskpass": { + "title": "SSH 身份验证", + "subtitle": "SSH 连接需要您的输入", + "placeholder": "PIN 或密码短语", + "waiting": "正在等待您的安全密钥 — 触摸以确认。" + }, "dataGrid": { "newRow": "新建", "noData": "无数据显示", diff --git a/src/types/askpass.ts b/src/types/askpass.ts new file mode 100644 index 00000000..32ac2d44 --- /dev/null +++ b/src/types/askpass.ts @@ -0,0 +1,9 @@ +/** Kind of prompt ssh is asking for, mirrored from the Rust askpass module. */ +export type SshAskpassKind = "secret" | "confirm" | "notify"; + +/** Payload of the `ssh-askpass://request` Tauri event. */ +export interface SshAskpassRequest { + id: number; + kind: SshAskpassKind; + prompt: string; +} diff --git a/tests/hooks/useSshAskpass.test.ts b/tests/hooks/useSshAskpass.test.ts new file mode 100644 index 00000000..4f87d991 --- /dev/null +++ b/tests/hooks/useSshAskpass.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import { listen, type EventCallback } from "@tauri-apps/api/event"; +import { invoke } from "@tauri-apps/api/core"; +import { useSshAskpass } from "../../src/hooks/useSshAskpass"; +import type { SshAskpassRequest } from "../../src/types/askpass"; + +vi.mock("@tauri-apps/api/event", () => ({ + listen: vi.fn(), +})); + +const listenMock = vi.mocked(listen); +const invokeMock = vi.mocked(invoke); + +describe("useSshAskpass", () => { + let handlers: Record>; + + beforeEach(() => { + handlers = {}; + listenMock.mockReset(); + invokeMock.mockReset(); + listenMock.mockImplementation((event, handler) => { + handlers[event as string] = handler as EventCallback; + return Promise.resolve(() => {}); + }); + invokeMock.mockResolvedValue(undefined); + }); + + const emitRequest = (payload: SshAskpassRequest) => { + act(() => { + handlers["ssh-askpass://request"]({ + event: "ssh-askpass://request", + id: 1, + payload, + }); + }); + }; + + const request = (overrides: Partial = {}): SshAskpassRequest => ({ + id: 1, + kind: "secret", + prompt: "Enter PIN for key:", + ...overrides, + }); + + it("starts with no pending prompt", () => { + const { result } = renderHook(() => useSshAskpass()); + expect(result.current.current).toBeNull(); + }); + + it("exposes a prompt received via the request event", () => { + const { result } = renderHook(() => useSshAskpass()); + emitRequest(request()); + + expect(result.current.current).toEqual(request()); + }); + + it("shows prompts one at a time, oldest first", () => { + const { result } = renderHook(() => useSshAskpass()); + emitRequest(request({ id: 1, prompt: "first" })); + emitRequest(request({ id: 2, prompt: "second" })); + + expect(result.current.current?.id).toBe(1); + }); + + it("respond sends the answer to the backend and pops the prompt", async () => { + const { result } = renderHook(() => useSshAskpass()); + emitRequest(request({ id: 7 })); + + await act(async () => { + await result.current.respond(7, "1234"); + }); + + expect(invokeMock).toHaveBeenCalledWith("respond_ssh_askpass", { + id: 7, + response: "1234", + }); + expect(result.current.current).toBeNull(); + }); + + it("respond with null forwards the cancellation", async () => { + const { result } = renderHook(() => useSshAskpass()); + emitRequest(request({ id: 3 })); + + await act(async () => { + await result.current.respond(3, null); + }); + + expect(invokeMock).toHaveBeenCalledWith("respond_ssh_askpass", { + id: 3, + response: null, + }); + }); + + it("removes a prompt when the backend dismisses it", () => { + const { result } = renderHook(() => useSshAskpass()); + emitRequest(request({ id: 5, kind: "notify" })); + expect(result.current.current?.id).toBe(5); + + act(() => { + handlers["ssh-askpass://dismiss"]({ + event: "ssh-askpass://dismiss", + id: 1, + payload: 5, + }); + }); + + expect(result.current.current).toBeNull(); + }); + + it("dismiss removes a prompt locally without calling the backend", () => { + const { result } = renderHook(() => useSshAskpass()); + emitRequest(request({ id: 9, kind: "notify" })); + + act(() => { + result.current.dismiss(9); + }); + + expect(result.current.current).toBeNull(); + expect(invokeMock).not.toHaveBeenCalled(); + }); +});