feat: move opkssh and warpgate into plugins (C3)

This commit is contained in:
LukeGus committed 2026-09-24 19:28:19 -05:00
1 parent 925da4a661
commit 98d367d7e2
336 files changed
+22380 -4746

No files matched your search

-2
View File
@@ -40,8 +40,6 @@
"linkDialogTitle": "Open Link",
"maxReconnectAttemptsReached": "Maximum reconnection attempts reached",
"noHostSelected": "No host selected",
"opksshAuthFailed": "Authentication failed. Please check your credentials and try again.",
"opksshTimeout": "Authentication timed out. Please try again.",
"passwordPromptFillTitle": "Fill the saved password into this prompt?",
"reconnect": "Reconnect",
"reconnected": "Reconnected successfully",
@@ -40,8 +40,6 @@
"linkDialogTitle": "Maak skakel oop",
"maxReconnectAttemptsReached": "Maksimum herverbindingspogings bereik",
"noHostSelected": "Geen gasheer gekies nie",
"opksshAuthFailed": "Verifikasie het misluk. Kontroleer asseblief jou geloofsbriewe en probeer weer.",
"opksshTimeout": "Verifikasie het verstryk. Probeer asseblief weer.",
"passwordPromptFillTitle": "Vul die gestoorde wagwoord in hierdie aanwysingsprompt in?",
"reconnect": "Herkoppel",
"reconnected": "Herverbind suksesvol",
@@ -40,8 +40,6 @@
"linkDialogTitle": "فتح الرابط",
"maxReconnectAttemptsReached": "تم الوصول إلى الحد الأقصى لمحاولات إعادة الاتصال",
"noHostSelected": "لم يتم تحديد مضيف",
"opksshAuthFailed": "فشلت المصادقة. يرجى التحقق من بيانات الاعتماد والمحاولة مرة أخرى.",
"opksshTimeout": "انتهت مهلة المصادقة. يرجى المحاولة مرة أخرى.",
"passwordPromptFillTitle": "هل تريد ملء كلمة المرور المحفوظة في هذه النافذة؟",
"reconnect": "إعادة الاتصال",
"reconnected": "تمت إعادة الاتصال بنجاح",
@@ -40,8 +40,6 @@
"linkDialogTitle": "Отвори линка",
"maxReconnectAttemptsReached": "Достигнат е максималният брой опити за повторно свързване",
"noHostSelected": "Няма избран хост",
"opksshAuthFailed": "Удостоверяването не бе успешно. Моля, проверете идентификационните си данни и опитайте отново.",
"opksshTimeout": "Времето за изчакване на удостоверяването изтече. Моля, опитайте отново.",
"passwordPromptFillTitle": "Да се попълни ли запазената парола в този ред?",
"reconnect": "Свържете се отново",
"reconnected": "Успешно повторно свързване",
@@ -40,8 +40,6 @@
"linkDialogTitle": "লিঙ্ক খুলুন",
"maxReconnectAttemptsReached": "পুনঃসংযোগের সর্বোচ্চ প্রচেষ্টা শেষ হয়েছে",
"noHostSelected": "কোন হোস্ট নির্বাচন করা হয়নি",
"opksshAuthFailed": "প্রমাণীকরণ ব্যর্থ হয়েছে। অনুগ্রহ করে আপনার পরিচয়পত্র যাচাই করে আবার চেষ্টা করুন।",
"opksshTimeout": "প্রমাণীকরণের সময়সীমা শেষ হয়ে গেছে। অনুগ্রহ করে আবার চেষ্টা করুন।",
"passwordPromptFillTitle": "সংরক্ষিত পাসওয়ার্ডটি এই প্রম্পটে পূরণ করুন?",
"reconnect": "পুনরায় সংযোগ করুন",
"reconnected": "সফলভাবে পুনরায় সংযুক্ত হয়েছে",
@@ -40,8 +40,6 @@
"linkDialogTitle": "Obre l'enllaç",
"maxReconnectAttemptsReached": "S'ha arribat al màxim d'intents de reconnexió",
"noHostSelected": "No s'ha seleccionat cap amfitrió",
"opksshAuthFailed": "L'autenticació ha fallat. Si us plau, comproveu les vostres credencials i torneu-ho a intentar.",
"opksshTimeout": "S'ha esgotat el temps d'autenticació. Torna-ho a provar.",
"passwordPromptFillTitle": "Voleu introduir la contrasenya desada en aquesta sol·licitud?",
"reconnect": "Reconnecta",
"reconnected": "S'ha reconnectat correctament",
@@ -40,8 +40,6 @@
"linkDialogTitle": "Otevřít odkaz",
"maxReconnectAttemptsReached": "Dosažen maximální počet pokusů o opětovné připojení",
"noHostSelected": "Nebyl vybrán žádný hostitel",
"opksshAuthFailed": "Ověření se nezdařilo. Zkontrolujte prosím své přihlašovací údaje a zkuste to znovu.",
"opksshTimeout": "Časový limit ověření vypršel. Zkuste to prosím znovu.",
"passwordPromptFillTitle": "Vyplňte uložené heslo do tohoto řádku?",
"reconnect": "Znovu se připojte",
"reconnected": "Úspěšné opětovné připojení",
@@ -40,8 +40,6 @@
"linkDialogTitle": "Åbn link",
"maxReconnectAttemptsReached": "Maksimalt antal forsøg på genoprettelse nået",
"noHostSelected": "Ingen vært valgt",
"opksshAuthFailed": "Godkendelse mislykkedes. Tjek dine loginoplysninger, og prøv igen.",
"opksshTimeout": "Godkendelsen udløb. Prøv igen.",
"passwordPromptFillTitle": "Udfyld den gemte adgangskode i denne prompt?",
"reconnect": "Genopret forbindelse",
"reconnected": "Genoprettet forbindelse",
@@ -40,8 +40,6 @@
"linkDialogTitle": "Link öffnen",
"maxReconnectAttemptsReached": "Maximale Wiederverbindungsversuche erreicht",
"noHostSelected": "Kein Host ausgewählt",
"opksshAuthFailed": "Authentifizierung fehlgeschlagen. Bitte überprüfen Sie Ihre Anmeldeinformationen und versuchen Sie es erneut.",
"opksshTimeout": "Authentifizierung abgelaufen. Bitte versuchen Sie es erneut.",
"passwordPromptFillTitle": "Gespeichertes Passwort in diese Eingabeaufforderung einfügen?",
"reconnect": "Erneut verbinden",
"reconnected": "Erfolgreich wieder verbunden",
@@ -40,8 +40,6 @@
"linkDialogTitle": "Άνοιγμα συνδέσμου",
"maxReconnectAttemptsReached": "Επιτεύχθηκε ο μέγιστος αριθμός προσπαθειών επανασύνδεσης",
"noHostSelected": "Δεν έχει επιλεγεί κεντρικός υπολογιστής",
"opksshAuthFailed": "Ο έλεγχος ταυτότητας απέτυχε. Ελέγξτε τα διαπιστευτήριά σας και προσπαθήστε ξανά.",
"opksshTimeout": "Το χρονικό όριο ελέγχου ταυτότητας έληξε. Δοκιμάστε ξανά.",
"passwordPromptFillTitle": "Συμπληρώστε τον αποθηκευμένο κωδικό πρόσβασης σε αυτήν την ερώτηση;",
"reconnect": "Επανασύνδεση",
"reconnected": "Επανασύνδεση με επιτυχία",
@@ -40,8 +40,6 @@
"linkDialogTitle": "Abrir enlace",
"maxReconnectAttemptsReached": "Número máximo de intentos de reconexión alcanzado",
"noHostSelected": "Ningún host seleccionado",
"opksshAuthFailed": "La autenticación falló. Revisa tus credenciales e inténtalo de nuevo.",
"opksshTimeout": "La autenticación expiró. Inténtalo de nuevo.",
"passwordPromptFillTitle": "¿Rellenar este campo con la contraseña guardada?",
"reconnect": "Reconectar",
"reconnected": "Reconectado correctamente",
@@ -40,8 +40,6 @@
"linkDialogTitle": "Avaa linkki",
"maxReconnectAttemptsReached": "Yhteyden muodostamisen uudelleenyritysten enimmäismäärä saavutettu",
"noHostSelected": "Ei valittua isäntää",
"opksshAuthFailed": "Todennus epäonnistui. Tarkista tunnistetietosi ja yritä uudelleen.",
"opksshTimeout": "Todennus aikakatkaistiin. Yritä uudelleen.",
"passwordPromptFillTitle": "Täytä tallennettu salasana tähän kehotteeseen?",
"reconnect": "Yhdistä uudelleen",
"reconnected": "Yhdistäminen uudelleen onnistui",
@@ -40,8 +40,6 @@
"linkDialogTitle": "Ouvrir le lien",
"maxReconnectAttemptsReached": "Nombre maximal de tentatives de reconnexion atteint",
"noHostSelected": "Aucun hôte sélectionné",
"opksshAuthFailed": "Échec de l'authentification. Veuillez vérifier vos identifiants et réessayer.",
"opksshTimeout": "L'authentification a expiré. Veuillez réessayer.",
"passwordPromptFillTitle": "Remplir ce champ avec le mot de passe enregistré ?",
"reconnect": "Reconnecter",
"reconnected": "Reconnexion réussie",
@@ -40,8 +40,6 @@
"linkDialogTitle": "פתח קישור",
"maxReconnectAttemptsReached": "הגעת למספר המקסימלי של ניסיונות חיבור מחדש",
"noHostSelected": "לא נבחר מארח",
"opksshAuthFailed": "האימות נכשל. אנא בדוק את פרטי הגישה שלך ונסה שוב.",
"opksshTimeout": "הזמן שהוקצב לאימות הסתיים. אנא נסה שוב.",
"passwordPromptFillTitle": "למלא את הסיסמה השמורה בשורת הפקודה הזו?",
"reconnect": "התחבר מחדש",
"reconnected": "התחבר מחדש בהצלחה",
@@ -40,8 +40,6 @@
"linkDialogTitle": "लिंक खोलें",
"maxReconnectAttemptsReached": "अधिकतम पुनः कनेक्ट प्रयास पूर्ण हो गए",
"noHostSelected": "कोई होस्ट चयनित नहीं",
"opksshAuthFailed": "प्रमाणीकरण विफल। कृपया अपने क्रेडेंशियल जाँचें और पुनः प्रयास करें।",
"opksshTimeout": "प्रमाणीकरण का समय समाप्त हो गया। कृपया पुनः प्रयास करें।",
"passwordPromptFillTitle": "क्या सहेजे गए पासवर्ड को इस प्रांप्ट में भरें?",
"reconnect": "पुनः कनेक्ट करें",
"reconnected": "पुनः कनेक्ट हुआ",
@@ -40,8 +40,6 @@
"linkDialogTitle": "Link megnyitása",
"maxReconnectAttemptsReached": "Elérte az újracsatlakozási kísérletek maximális számát",
"noHostSelected": "Nincs kiválasztva gazdagép",
"opksshAuthFailed": "Sikertelen hitelesítés. Kérjük, ellenőrizze a hitelesítő adatait, és próbálja újra.",
"opksshTimeout": "A hitelesítés időtúllépést okozott. Kérjük, próbálja újra.",
"passwordPromptFillTitle": "Írja be a mentett jelszót ebbe a mezőbe?",
"reconnect": "Újracsatlakozás",
"reconnected": "Sikeresen újrakapcsolódott",
@@ -40,8 +40,6 @@
"linkDialogTitle": "Buka Tautan",
"maxReconnectAttemptsReached": "Upaya penyambungan kembali maksimum telah tercapai.",
"noHostSelected": "Tidak ada pembawa acara yang dipilih.",
"opksshAuthFailed": "Autentikasi gagal. Harap periksa kredensial Anda dan coba lagi.",
"opksshTimeout": "Autentikasi habis waktu. Silakan coba lagi.",
"passwordPromptFillTitle": "Masukkan kata sandi yang tersimpan ke dalam kolom ini?",
"reconnect": "Terhubung kembali",
"reconnected": "Berhasil terhubung kembali",
@@ -40,8 +40,6 @@
"linkDialogTitle": "Apri collegamento",
"maxReconnectAttemptsReached": "Raggiunto il numero massimo di tentativi di riconnessione",
"noHostSelected": "Nessun host selezionato",
"opksshAuthFailed": "Autenticazione fallita. Controlla le credenziali e riprova.",
"opksshTimeout": "Autenticazione scaduta. Riprova.",
"passwordPromptFillTitle": "Inserire la password salvata in questa richiesta?",
"reconnect": "Riconnetti",
"reconnected": "Riconnesso con successo",
@@ -40,8 +40,6 @@
"linkDialogTitle": "リンクを開く",
"maxReconnectAttemptsReached": "再接続の最大試行回数に達しました",
"noHostSelected": "ホストが選択されていません",
"opksshAuthFailed": "認証に失敗しました。資格情報を確認して再試行してください。",
"opksshTimeout": "認証がタイムアウトしました。もう一度お試しください。",
"passwordPromptFillTitle": "保存されたパスワードをこのプロンプトに入力しますか?",
"reconnect": "再接続",
"reconnected": "再接続完了",
@@ -40,8 +40,6 @@
"linkDialogTitle": "링크 열기",
"maxReconnectAttemptsReached": "최대 재연결 시도 횟수에 도달했습니다.",
"noHostSelected": "선택된 호스트가 없습니다.",
"opksshAuthFailed": "인증에 실패했습니다. 자격 증명을 확인하고 다시 시도하세요.",
"opksshTimeout": "인증 시간이 초과되었습니다. 다시 시도하세요.",
"passwordPromptFillTitle": "저장된 비밀번호를 이 프롬프트에 입력하시겠습니까?",
"reconnect": "재연결",
"reconnected": "재연결 성공",
@@ -40,8 +40,6 @@
"linkDialogTitle": "Open link",
"maxReconnectAttemptsReached": "Maximaal aantal herverbindingspogingen bereikt",
"noHostSelected": "Geen host geselecteerd",
"opksshAuthFailed": "Authenticatie mislukt. Controleer uw inloggegevens en probeer het opnieuw.",
"opksshTimeout": "De authenticatie is mislukt vanwege een time-out. Probeer het opnieuw.",
"passwordPromptFillTitle": "Voer het opgeslagen wachtwoord in op dit veld.",
"reconnect": "Opnieuw verbinding maken",
"reconnected": "De verbinding is succesvol hersteld.",
@@ -40,8 +40,6 @@
"linkDialogTitle": "Åpne lenke",
"maxReconnectAttemptsReached": "Maksimalt antall forsøk på å koble til igjen er nådd",
"noHostSelected": "Ingen vert valgt",
"opksshAuthFailed": "Autentiseringen mislyktes. Sjekk legitimasjonen din og prøv på nytt.",
"opksshTimeout": "Autentiseringen ble tidsavbrutt. Prøv på nytt.",
"passwordPromptFillTitle": "Skriv inn det lagrede passordet i denne meldingen?",
"reconnect": "Koble til igjen",
"reconnected": "Tilkoblet på nytt",
@@ -40,8 +40,6 @@
"linkDialogTitle": "Otwórz link",
"maxReconnectAttemptsReached": "Osiągnięto maksymalną liczbę prób ponownego połączenia",
"noHostSelected": "Nie wybrano hosta",
"opksshAuthFailed": "Uwierzytelnienie nie powiodło się. Sprawdź poświadczenia i spróbuj ponownie.",
"opksshTimeout": "Upłynął limit czasu uwierzytelniania. Spróbuj ponownie.",
"passwordPromptFillTitle": "Czy wpisać zapisane hasło w tym monicie?",
"reconnect": "Połącz ponownie",
"reconnected": "Połączono ponownie pomyślnie",
@@ -40,8 +40,6 @@
"linkDialogTitle": "Abrir link",
"maxReconnectAttemptsReached": "Número máximo de tentativas de reconexão atingido",
"noHostSelected": "Nenhum host selecionado",
"opksshAuthFailed": "A autenticação falhou. Verifique suas credenciais e tente novamente.",
"opksshTimeout": "A autenticação expirou. Tente novamente.",
"passwordPromptFillTitle": "Digite a senha salva neste campo?",
"reconnect": "Reconectar",
"reconnected": "Reconectado com sucesso",
@@ -40,8 +40,6 @@
"linkDialogTitle": "Abrir Ligação",
"maxReconnectAttemptsReached": "Número máximo de tentativas de restabelecimento de ligação atingido",
"noHostSelected": "Nenhum host selecionado",
"opksshAuthFailed": "A autenticação falhou. Verifique as suas credenciais e tente novamente.",
"opksshTimeout": "A autenticação expirou. Tente novamente.",
"passwordPromptFillTitle": "Preencher a palavra-passe guardada neste pedido?",
"reconnect": "Reconectar",
"reconnected": "Ligação restabelecida com sucesso",
@@ -40,8 +40,6 @@
"linkDialogTitle": "Deschideți linkul",
"maxReconnectAttemptsReached": "S-a atins numărul maxim de încercări de reconectare",
"noHostSelected": "Nicio gazdă selectată",
"opksshAuthFailed": "Autentificarea a eșuat. Verificați acreditările și încercați din nou.",
"opksshTimeout": "Autentificarea a expirat. Vă rugăm să încercați din nou.",
"passwordPromptFillTitle": "Introduceți parola salvată în această solicitare?",
"reconnect": "Reconectați-vă",
"reconnected": "Reconectare cu succes",
@@ -40,8 +40,6 @@
"linkDialogTitle": "Открыть ссылку",
"maxReconnectAttemptsReached": "Достигнуто максимальное количество попыток переподключения",
"noHostSelected": "Не выбран хост",
"opksshAuthFailed": "Аутентификация не удалась. Проверьте учётные данные и попробуйте снова.",
"opksshTimeout": "Время аутентификации истекло. Попробуйте снова.",
"passwordPromptFillTitle": "Вставить сохраненный пароль в этот запрос?",
"reconnect": "Переподключиться",
"reconnected": "Успешно переподключено",
@@ -40,8 +40,6 @@
"linkDialogTitle": "Отвори линк",
"maxReconnectAttemptsReached": "Достигнут је максималан број покушаја поновног повезивања",
"noHostSelected": "Није изабран домаћин",
"opksshAuthFailed": "Аутентификација није успела. Проверите своје акредитиве и покушајте поново.",
"opksshTimeout": "Временско ограничење за аутентификацију је истекло. Молимо покушајте поново.",
"passwordPromptFillTitle": "Унесите сачувану лозинку у овај промпт?",
"reconnect": "Поново се повежите",
"reconnected": "Поново повезано успешно",
@@ -40,8 +40,6 @@
"linkDialogTitle": "Öppna länk",
"maxReconnectAttemptsReached": "Maximalt antal återanslutningsförsök uppnådda",
"noHostSelected": "Ingen värd vald",
"opksshAuthFailed": "Autentiseringen misslyckades. Kontrollera dina inloggningsuppgifter och försök igen.",
"opksshTimeout": "Autentiseringen har gått ut. Försök igen.",
"passwordPromptFillTitle": "Fyll i det sparade lösenordet i den här frågan?",
"reconnect": "Återanslut",
"reconnected": "Återansluten lyckades",
@@ -40,8 +40,6 @@
"linkDialogTitle": "เปิดลิงก์",
"maxReconnectAttemptsReached": "จำนวนครั้งการเชื่อมต่อใหม่สูงสุดครบแล้ว",
"noHostSelected": "ไม่ได้เลือกโฮสต์",
"opksshAuthFailed": "การตรวจสอบสิทธิ์ล้มเหลว โปรดตรวจสอบข้อมูลประจำตัวของคุณและลองอีกครั้ง",
"opksshTimeout": "การตรวจสอบสิทธิ์หมดเวลา โปรดลองอีกครั้ง",
"passwordPromptFillTitle": "กรอกรหัสผ่านที่บันทึกไว้ลงในช่องนี้ใช่หรือไม่?",
"reconnect": "เชื่อมต่อใหม่",
"reconnected": "เชื่อมต่อสำเร็จแล้ว",
@@ -40,8 +40,6 @@
"linkDialogTitle": "Bağlantıyı Aç",
"maxReconnectAttemptsReached": "Maksimum yeniden bağlanma denemesine ulaşıldı",
"noHostSelected": "Hiçbir sunucu seçilmedi",
"opksshAuthFailed": "Kimlik doğrulaması başarısız oldu. Lütfen kimlik bilgilerinizi kontrol edip tekrar deneyin.",
"opksshTimeout": "Kimlik doğrulaması zaman aşımına uğradı. Lütfen tekrar deneyin.",
"passwordPromptFillTitle": "Kayıtlı parola bu isteme doldurulsun mu?",
"reconnect": "Yeniden Bağlan",
"reconnected": "Başarıyla yeniden bağlandı",
@@ -40,8 +40,6 @@
"linkDialogTitle": "Відкрити посилання",
"maxReconnectAttemptsReached": "Досягнуто максимальної кількості спроб повторного підключення",
"noHostSelected": "Хост не вибрано",
"opksshAuthFailed": "Помилка автентифікації. Перевірте свої облікові дані та спробуйте ще раз.",
"opksshTimeout": "Час очікування автентифікації минув. Спробуйте ще раз.",
"passwordPromptFillTitle": "Введіть збережений пароль у цей запит?",
"reconnect": "Знову підключитися",
"reconnected": "Успішно відновлено підключення",
@@ -40,8 +40,6 @@
"linkDialogTitle": "Mở liên kết",
"maxReconnectAttemptsReached": "Đã đạt số lần kết nối lại tối đa",
"noHostSelected": "Chưa chọn máy chủ",
"opksshAuthFailed": "Xác thực thất bại. Vui lòng kiểm tra thông tin đăng nhập và thử lại.",
"opksshTimeout": "Xác thực đã hết thời gian chờ. Vui lòng thử lại.",
"passwordPromptFillTitle": "Điền mật khẩu đã lưu vào dòng nhắc này?",
"reconnect": "Kết nối lại",
"reconnected": "Đã kết nối lại thành công",
@@ -40,8 +40,6 @@
"linkDialogTitle": "打开链接",
"maxReconnectAttemptsReached": "已达到最大重连尝试次数",
"noHostSelected": "未选择主机",
"opksshAuthFailed": "认证失败,请检查凭据并重试。",
"opksshTimeout": "认证超时,请重试。",
"passwordPromptFillTitle": "将保存的密码填入此提示?",
"reconnect": "重新连接",
"reconnected": "重新连接成功",
@@ -40,8 +40,6 @@
"linkDialogTitle": "開啟連結",
"maxReconnectAttemptsReached": "已達最大重新連線嘗試次數",
"noHostSelected": "未選取主機",
"opksshAuthFailed": "驗證失敗。請檢查您的憑證並再試一次。",
"opksshTimeout": "驗證逾時。請再試一次。",
"passwordPromptFillTitle": "將已儲存的密碼填入此提示?",
"reconnect": "重新連線",
"reconnected": "已成功重新連線",
@@ -3,12 +3,13 @@
*
* Core's classifier (ctx.ssh.classifyKeyboardInteractive) decides what a
* round is; this keeps the terminal's message protocol (totp_required,
* totp_retry, password_required, warpgate_auth_required) and its timeouts
* exactly as the client expects.
* totp_retry, password_required, "<handler>_auth_required" for a browser
* sign-in round) and its timeouts exactly as the client expects.
*/
import type { WebSocket } from "ws";
import type {
PluginBrowserSignInRound,
PluginKeyboardInteractivePrompt,
PluginSsh,
PluginSshHost,
@@ -40,7 +41,6 @@ interface ResolvedCredentials {
}
interface HostFlags {
useWarpgate?: boolean;
[key: string]: unknown;
}
@@ -54,15 +54,16 @@ interface AuthContext {
keyboardInteractiveResponded: boolean;
keyboardInteractiveFinish: ((responses: string[]) => void) | null;
totpPromptSent: boolean;
warpgateAuthPromptSent: boolean;
/** The handler whose browser round is waiting for "<id>_auth_continue". */
browserSignInId: string | null;
totpTimeout: NodeJS.Timeout | null;
warpgateAuthTimeout: NodeJS.Timeout | null;
browserSignInTimeout: NodeJS.Timeout | null;
totpAttempts: number;
}
const TOTP_TIMEOUT_MS = 180000;
const PUSH_TIMEOUT_MS = 300000;
const WARPGATE_TIMEOUT_MS = 300000;
const BROWSER_SIGN_IN_TIMEOUT_MS = 300000;
export class SSHAuthManager {
public context: AuthContext;
@@ -101,8 +102,8 @@ export class SSHAuthManager {
case "auto":
finish(decision.responses);
return;
case "warpgate":
this.handleWarpgate(decision, finish);
case "browser":
this.handleBrowserSignIn(decision, finish);
return;
case "totp":
this.handleTotp(
@@ -124,40 +125,48 @@ export class SSHAuthManager {
}
}
private handleWarpgate(
decision: { url: string | null; securityKey: string; instructions: string },
/**
* A round finished in a browser. The message is named after the handler
* that claimed it (Termix-Mobile listens for these names), and the client
* answers with "<id>_auth_continue".
*/
private handleBrowserSignIn(
round: PluginBrowserSignInRound,
finish: (responses: string[]) => void,
): void {
this.context.keyboardInteractiveFinish = () => {
finish([""]);
};
this.context.warpgateAuthPromptSent = true;
this.sendLog("auth", "info", "Warpgate authentication required");
this.context.browserSignInId = round.id;
this.sendLog("auth", "info", `${round.label} sign-in required`);
this.context.ws.send(
JSON.stringify({
type: "warpgate_auth_required",
url: decision.url,
securityKey: decision.securityKey,
instructions: decision.instructions,
type: `${round.id}_auth_required`,
kind: "browser",
label: round.label,
url: round.url,
securityKey: round.code,
instructions: round.instructions,
}),
);
this.context.warpgateAuthTimeout = setTimeout(() => {
this.context.browserSignInTimeout = setTimeout(() => {
if (this.context.keyboardInteractiveFinish) {
this.context.keyboardInteractiveFinish = null;
this.context.warpgateAuthPromptSent = false;
this.context.log.warn("Warpgate authentication timeout", {
operation: "warpgate_timeout",
this.context.browserSignInId = null;
this.context.log.warn("Browser sign-in timeout", {
operation: "browser_sign_in_timeout",
handler: round.id,
hostId: this.context.hostId,
});
this.context.ws.send(
JSON.stringify({
type: "error",
message: "Warpgate authentication timeout. Please reconnect.",
message: `${round.label} sign-in timed out. Please reconnect.`,
}),
);
}
}, WARPGATE_TIMEOUT_MS);
}, BROWSER_SIGN_IN_TIMEOUT_MS);
}
private handleTotp(
@@ -289,13 +298,13 @@ export class SSHAuthManager {
clearTimeout(this.context.totpTimeout);
this.context.totpTimeout = null;
}
if (this.context.warpgateAuthTimeout) {
clearTimeout(this.context.warpgateAuthTimeout);
this.context.warpgateAuthTimeout = null;
if (this.context.browserSignInTimeout) {
clearTimeout(this.context.browserSignInTimeout);
this.context.browserSignInTimeout = null;
}
this.context.keyboardInteractiveFinish = null;
this.context.totpPromptSent = false;
this.context.warpgateAuthPromptSent = false;
this.context.browserSignInId = null;
this.context.keyboardInteractiveResponded = false;
}
}
@@ -64,7 +64,6 @@ interface ConnectToHostData {
credentialId?: number;
userId?: string;
forceKeyboardInteractive?: boolean;
useWarpgate?: boolean;
jumpHosts?: Array<{ hostId: number }>;
useSocks5?: boolean;
socks5Host?: string;
@@ -341,8 +340,9 @@ export function createTerminalSocket(deps: TerminalSocketDeps) {
let isCleaningUp = false;
let isShellInitializing = false;
let isDuplicateConnDiscarded = false;
let warpgateAuthPromptSent = false;
let warpgateAuthTimeout: NodeJS.Timeout | null = null;
// The keyboard-interactive handler whose browser sign-in is pending.
let browserSignInId: string | null = null;
let browserSignInTimeout: NodeJS.Timeout | null = null;
let isAwaitingAuthCredentials = false;
let wsAlive = true;
@@ -418,7 +418,7 @@ export function createTerminalSocket(deps: TerminalSocketDeps) {
keyboardInteractiveResponded = false;
keyboardInteractiveFinish = null;
totpPromptSent = false;
warpgateAuthPromptSent = false;
browserSignInId = null;
}
ws.on(
@@ -907,19 +907,6 @@ export function createTerminalSocket(deps: TerminalSocketDeps) {
break;
}
case "warpgate_auth_continue": {
if (keyboardInteractiveFinish) {
if (warpgateAuthTimeout) {
clearTimeout(warpgateAuthTimeout);
warpgateAuthTimeout = null;
}
keyboardInteractiveFinish([""]);
keyboardInteractiveFinish = null;
warpgateAuthPromptSent = false;
}
break;
}
case "reconnect_with_credentials": {
const credentialsData = data as {
cols: number;
@@ -1004,178 +991,6 @@ export function createTerminalSocket(deps: TerminalSocketDeps) {
break;
}
case "opkssh_start_auth": {
const opksshData = data as { hostId: number };
try {
await startAuthInteraction("opkssh", opksshData.hostId, data);
} catch (error) {
sshLogger.error("Failed to start OPKSSH auth", error, {
operation: "opkssh_start_auth_error",
userId,
hostId: opksshData.hostId,
});
ws.send(
JSON.stringify({
type: "opkssh_error",
requestId: "",
error:
error instanceof PluginSshInteractionError
? error.message
: "Failed to start OPKSSH authentication",
}),
);
}
break;
}
case "opkssh_cancel": {
const cancelData = data as { requestId: string };
try {
await ctx.ssh.cancelInteraction("opkssh", {
requestId: String(cancelData?.requestId ?? ""),
});
resetConnectionState();
} catch (error) {
sshLogger.error("Failed to cancel OPKSSH auth", error, {
operation: "opkssh_cancel_error",
userId,
});
}
break;
}
case "opkssh_browser_opened": {
break;
}
case "opkssh_auth_completed": {
const completedData = data as {
hostId: number;
cols?: number;
rows?: number;
hostConfig?: ConnectToHostData["hostConfig"];
};
resetConnectionState();
const reconnectConfig: ConnectToHostData = {
cols: completedData.cols || 80,
rows: completedData.rows || 24,
hostConfig:
completedData.hostConfig ||
({
id: completedData.hostId,
ip: "",
port: 22,
username: "",
userId,
} as ConnectToHostData["hostConfig"]),
};
handleConnectToHost(reconnectConfig).catch((error) => {
sshLogger.error(
"Failed to reconnect after OPKSSH auth",
error,
{
operation: "opkssh_reconnect_error",
userId,
hostId: completedData.hostId,
},
);
ws.send(
JSON.stringify({
type: "error",
message:
"Failed to connect after authentication: " +
getErrorMessage(error),
}),
);
});
break;
}
case "vault_start_auth": {
const vaultData = data as { hostId: number };
try {
await startAuthInteraction("vault", vaultData.hostId, data);
} catch (error) {
sshLogger.error("Failed to start Vault auth", error, {
operation: "vault_start_auth_error",
userId,
hostId: vaultData.hostId,
});
ws.send(
JSON.stringify({
type: "vault_error",
hostId: vaultData.hostId,
error: getErrorMessage(
error,
"Failed to start Vault authentication",
),
}),
);
}
break;
}
case "vault_cancel": {
const cancelData = data as { hostId: number };
try {
await ctx.ssh.cancelInteraction("vault", {
hostId: cancelData.hostId,
});
resetConnectionState();
} catch (error) {
sshLogger.error("Failed to cancel Vault auth", error, {
operation: "vault_cancel_error",
userId,
});
}
break;
}
case "vault_auth_completed": {
const completedData = data as {
hostId: number;
cols?: number;
rows?: number;
hostConfig?: ConnectToHostData["hostConfig"];
};
resetConnectionState();
const reconnectConfig: ConnectToHostData = {
cols: completedData.cols || 80,
rows: completedData.rows || 24,
hostConfig:
completedData.hostConfig ||
({
id: completedData.hostId,
ip: "",
port: 22,
username: "",
userId,
} as ConnectToHostData["hostConfig"]),
};
handleConnectToHost(reconnectConfig).catch((error) => {
sshLogger.error("Failed to reconnect after Vault auth", error, {
operation: "vault_reconnect_error",
userId,
hostId: completedData.hostId,
});
ws.send(
JSON.stringify({
type: "error",
message:
"Failed to connect after authentication: " +
getErrorMessage(error),
}),
);
});
break;
}
case "collab_subscribe": {
const { roomId } = (data ?? {}) as { roomId?: string };
if (typeof roomId !== "string" || !roomId) break;
@@ -1296,6 +1111,7 @@ export function createTerminalSocket(deps: TerminalSocketDeps) {
}
default:
if (await handleSignInMessage(type, data)) break;
sshLogger.warn("Unknown message type received", {
operation: "websocket_message_unknown_type",
userId,
@@ -1340,6 +1156,122 @@ export function createTerminalSocket(deps: TerminalSocketDeps) {
});
}
/**
* Messages a sign-in's UI sends: "<interaction>_start_auth", "_cancel"
* and "_auth_completed" for a provider's browser step, and
* "<handler>_auth_continue" for a pending keyboard-interactive browser
* round. False for anything else.
*/
async function handleSignInMessage(
type: string,
data: unknown,
): Promise<boolean> {
const match =
/^([a-z0-9-]+)_(start_auth|cancel|auth_completed|auth_continue)$/.exec(
type,
);
if (!match) return false;
const [, name, action] = match;
const payload = (data ?? {}) as Record<string, unknown>;
const hostId = Number(payload.hostId);
if (action === "start_auth") {
try {
await startAuthInteraction(name, hostId, payload);
} catch (error) {
sshLogger.error("Failed to start sign-in", error, {
operation: "sign_in_start_error",
interaction: name,
userId,
hostId,
});
ws.send(
JSON.stringify({
type: `${name}_error`,
requestId: "",
hostId,
error:
error instanceof PluginSshInteractionError
? error.message
: getErrorMessage(error, "Failed to start authentication"),
}),
);
}
return true;
}
if (action === "cancel") {
try {
await ctx.ssh.cancelInteraction(name, {
requestId:
typeof payload.requestId === "string"
? payload.requestId
: undefined,
hostId: Number.isInteger(hostId) ? hostId : undefined,
});
resetConnectionState();
} catch (error) {
sshLogger.error("Failed to cancel sign-in", error, {
operation: "sign_in_cancel_error",
interaction: name,
userId,
});
}
return true;
}
if (action === "auth_continue") {
if (name === browserSignInId && keyboardInteractiveFinish) {
if (browserSignInTimeout) {
clearTimeout(browserSignInTimeout);
browserSignInTimeout = null;
}
keyboardInteractiveFinish([""]);
keyboardInteractiveFinish = null;
browserSignInId = null;
}
return true;
}
const completed = payload as {
hostId: number;
cols?: number;
rows?: number;
hostConfig?: ConnectToHostData["hostConfig"];
};
resetConnectionState();
const reconnectConfig: ConnectToHostData = {
cols: completed.cols || 80,
rows: completed.rows || 24,
hostConfig:
completed.hostConfig ||
({
id: completed.hostId,
ip: "",
port: 22,
username: "",
userId,
} as ConnectToHostData["hostConfig"]),
};
handleConnectToHost(reconnectConfig).catch((error) => {
sshLogger.error("Failed to reconnect after sign-in", error, {
operation: "sign_in_reconnect_error",
interaction: name,
userId,
hostId: completed.hostId,
});
ws.send(
JSON.stringify({
type: "error",
message:
"Failed to connect after authentication: " +
getErrorMessage(error),
}),
);
});
return true;
}
async function handleConnectToHost(data: ConnectToHostData) {
const { hostConfig, initialPath, executeCommand, tmuxAttachSession } =
data;
@@ -2360,7 +2292,7 @@ export function createTerminalSocket(deps: TerminalSocketDeps) {
port,
username,
authType: resolvedCredentials.authType,
warpgateAuthPromptSent,
browserSignInId,
isKeyboardInteractive,
hasKeyboardInteractiveFinish: !!keyboardInteractiveFinish,
keyboardInteractiveResponded,
@@ -2680,9 +2612,9 @@ export function createTerminalSocket(deps: TerminalSocketDeps) {
keyboardInteractiveResponded,
keyboardInteractiveFinish,
totpPromptSent,
warpgateAuthPromptSent,
browserSignInId,
totpTimeout,
warpgateAuthTimeout,
browserSignInTimeout,
totpAttempts: 0,
});
@@ -2709,7 +2641,9 @@ export function createTerminalSocket(deps: TerminalSocketDeps) {
resolvedCredentials as unknown as Parameters<
typeof sshAuthManager.handleKeyboardInteractive
>[5],
hostConfig,
// The resolved host carries the plugin host settings that
// keyboard-interactive handlers read.
connectTarget ?? hostConfig,
);
isKeyboardInteractive =
@@ -2719,10 +2653,9 @@ export function createTerminalSocket(deps: TerminalSocketDeps) {
keyboardInteractiveFinish =
sshAuthManager.context.keyboardInteractiveFinish;
totpPromptSent = sshAuthManager.context.totpPromptSent;
warpgateAuthPromptSent =
sshAuthManager.context.warpgateAuthPromptSent;
browserSignInId = sshAuthManager.context.browserSignInId;
totpTimeout = sshAuthManager.context.totpTimeout;
warpgateAuthTimeout = sshAuthManager.context.warpgateAuthTimeout;
browserSignInTimeout = sshAuthManager.context.browserSignInTimeout;
},
),
);
@@ -2759,7 +2692,6 @@ export function createTerminalSocket(deps: TerminalSocketDeps) {
keyType: resolvedCredentials.keyType,
certPublicKey: resolvedCredentials.certPublicKey,
forceKeyboardInteractive: hostConfig.forceKeyboardInteractive,
useWarpgate: hostConfig.useWarpgate,
terminalConfig: hostConfig.terminalConfig,
jumpHosts: hostConfig.jumpHosts,
useSocks5: hostConfig.useSocks5,
@@ -2946,9 +2878,9 @@ export function createTerminalSocket(deps: TerminalSocketDeps) {
totpTimeout = null;
}
if (warpgateAuthTimeout) {
clearTimeout(warpgateAuthTimeout);
warpgateAuthTimeout = null;
if (browserSignInTimeout) {
clearTimeout(browserSignInTimeout);
browserSignInTimeout = null;
}
sshStream = null;
@@ -88,8 +88,7 @@ import {
TOTPDialog,
SSHAuthDialog,
PassphraseDialog,
WarpgateDialog,
OPKSSHDialog,
BrowserSignInDialog,
HostKeyVerificationDialog,
DEFAULT_TERMINAL_CONFIG,
TERMINAL_FONTS,
@@ -314,22 +313,15 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
>("no_keyboard");
const [showPassphraseDialog, setShowPassphraseDialog] = useState(false);
const [, setKeyboardInteractiveDetected] = useState(false);
const [warpgateAuthRequired, setWarpgateAuthRequired] = useState(false);
const [warpgateAuthUrl, setWarpgateAuthUrl] = useState<string>("");
const [warpgateSecurityKey, setWarpgateSecurityKey] = useState<string>("");
const warpgateTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const [opksshDialog, setOpksshDialog] = useState<{
isOpen: boolean;
authUrl: string;
requestId: string;
stage: "chooser" | "waiting" | "authenticating" | "completed" | "error";
error?: string;
providers?: Array<{ alias: string; issuer: string }>;
/** Which issuer is asking (OPKSSH by default, "Step CA", ...). */
label?: string;
// A keyboard-interactive round finished in a browser, named after the
// handler that claimed it (see "<id>_auth_required").
const [browserSignIn, setBrowserSignIn] = useState<{
id: string;
label: string;
url: string;
code: string;
} | null>(null);
const opksshTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const browserSignInTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Overlays contributed by plugins (a Tailscale check, say) hear the
// session's messages and may hold the connect timeout while they wait.
@@ -347,7 +339,6 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
}
};
const opksshFailedRef = useRef(false);
const currentHostIdRef = useRef<number | null>(null);
const currentHostConfigRef = useRef<TerminalHostConfig | null>(null);
@@ -839,8 +830,19 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
webSocketRef.current?.close();
},
disconnect: () => webSocketRef.current?.close(),
send: (type, data) => {
if (webSocketRef.current?.readyState === WebSocket.OPEN) {
webSocketRef.current.send(JSON.stringify({ type, data }));
}
},
connectPayload: () => ({
hostId: currentHostIdRef.current,
cols: terminal?.cols || 80,
rows: terminal?.rows || 24,
hostConfig: currentHostConfigRef.current,
}),
}),
[host, backgroundColor, updateConnectionError],
[host, backgroundColor, updateConnectionError, terminal],
);
const activityLoggingRef = useRef(false);
@@ -1087,38 +1089,33 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
if (onClose) onClose();
}
function handleWarpgateContinue() {
if (webSocketRef.current) {
if (warpgateTimeoutRef.current) {
clearTimeout(warpgateTimeoutRef.current);
warpgateTimeoutRef.current = null;
}
webSocketRef.current.send(
JSON.stringify({
type: "warpgate_auth_continue",
data: {},
}),
);
setWarpgateAuthRequired(false);
setWarpgateAuthUrl("");
setWarpgateSecurityKey("");
function clearBrowserSignIn() {
if (browserSignInTimeoutRef.current) {
clearTimeout(browserSignInTimeoutRef.current);
browserSignInTimeoutRef.current = null;
}
setBrowserSignIn(null);
}
function handleWarpgateCancel() {
if (warpgateTimeoutRef.current) {
clearTimeout(warpgateTimeoutRef.current);
warpgateTimeoutRef.current = null;
}
setWarpgateAuthRequired(false);
setWarpgateAuthUrl("");
setWarpgateSecurityKey("");
function handleBrowserSignInContinue() {
if (!browserSignIn || !webSocketRef.current) return;
webSocketRef.current.send(
JSON.stringify({
type: `${browserSignIn.id}_auth_continue`,
data: {},
}),
);
clearBrowserSignIn();
}
function handleBrowserSignInCancel() {
clearBrowserSignIn();
if (onClose) onClose();
}
function handleWarpgateOpenUrl() {
if (warpgateAuthUrl) {
window.open(warpgateAuthUrl, "_blank", "noopener,noreferrer");
function handleBrowserSignInOpenUrl() {
if (browserSignIn?.url) {
window.open(browserSignIn.url, "_blank", "noopener,noreferrer");
}
}
@@ -1356,9 +1353,9 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
clearTimeout(totpTimeoutRef.current);
totpTimeoutRef.current = null;
}
if (warpgateTimeoutRef.current) {
clearTimeout(warpgateTimeoutRef.current);
warpgateTimeoutRef.current = null;
if (browserSignInTimeoutRef.current) {
clearTimeout(browserSignInTimeoutRef.current);
browserSignInTimeoutRef.current = null;
}
if (webSocketRef.current?.readyState === WebSocket.OPEN) {
webSocketRef.current.send(JSON.stringify({ type: "disconnect" }));
@@ -1912,7 +1909,6 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
updateConnectionError(errorMessage);
setIsConnecting(false);
} else if (msg.type === "connected") {
opksshFailedRef.current = false;
vaultFailedRef.current = false;
wasConnectedRef.current = true;
setIsConnected(true);
@@ -2084,51 +2080,30 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
},
isPush ? 300000 : 180000,
);
} else if (msg.type === "warpgate_auth_required") {
setWarpgateAuthRequired(true);
setWarpgateAuthUrl(msg.url || "");
setWarpgateSecurityKey(msg.securityKey || "N/A");
} else if (
msg.kind === "browser" &&
typeof msg.type === "string" &&
msg.type.endsWith("_auth_required")
) {
setBrowserSignIn({
id: msg.type.slice(0, -"_auth_required".length),
label: typeof msg.label === "string" ? msg.label : "",
url: msg.url || "",
code: msg.securityKey || "",
});
if (connectionTimeoutRef.current) {
clearTimeout(connectionTimeoutRef.current);
connectionTimeoutRef.current = null;
}
if (warpgateTimeoutRef.current) {
clearTimeout(warpgateTimeoutRef.current);
if (browserSignInTimeoutRef.current) {
clearTimeout(browserSignInTimeoutRef.current);
}
warpgateTimeoutRef.current = setTimeout(() => {
setWarpgateAuthRequired(false);
browserSignInTimeoutRef.current = setTimeout(() => {
setBrowserSignIn(null);
if (webSocketRef.current) {
webSocketRef.current.close();
}
}, 300000);
} else if (msg.type === "opkssh_auth_required") {
if (connectionTimeoutRef.current) {
clearTimeout(connectionTimeoutRef.current);
connectionTimeoutRef.current = null;
}
if (opksshFailedRef.current) {
setOpksshDialog(null);
if (opksshTimeoutRef.current) {
clearTimeout(opksshTimeoutRef.current);
opksshTimeoutRef.current = null;
}
updateConnectionError(t("terminal.opksshAuthFailed"));
addLog({
type: "error",
stage: "auth",
message: t("terminal.opksshAuthFailed"),
});
} else {
opksshFailedRef.current = true;
if (webSocketRef.current) {
webSocketRef.current.send(
JSON.stringify({
type: "opkssh_start_auth",
data: { hostId: msg.hostId },
}),
);
}
}
} else if (msg.type === "vault_auth_required") {
if (connectionTimeoutRef.current) {
clearTimeout(connectionTimeoutRef.current);
@@ -2209,98 +2184,6 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
}
setVaultDialog({ stage: "error", error: msg.error });
setIsConnecting(false);
} else if (msg.type === "opkssh_status") {
if (connectionErrorRef.current) return;
if (msg.stage === "chooser") {
setOpksshDialog({
isOpen: true,
authUrl: msg.url || "",
requestId: msg.requestId || "",
stage: "chooser",
providers: msg.providers,
label: typeof msg.label === "string" ? msg.label : undefined,
});
if (opksshTimeoutRef.current) {
clearTimeout(opksshTimeoutRef.current);
}
opksshTimeoutRef.current = setTimeout(() => {
setOpksshDialog(null);
if (webSocketRef.current) {
webSocketRef.current.close();
}
}, 300000);
} else {
setOpksshDialog((prev) =>
prev ? { ...prev, stage: msg.stage } : null,
);
}
} else if (msg.type === "opkssh_completed") {
if (opksshTimeoutRef.current) {
clearTimeout(opksshTimeoutRef.current);
opksshTimeoutRef.current = null;
}
setOpksshDialog(null);
if (webSocketRef.current && terminal) {
webSocketRef.current.send(
JSON.stringify({
type: "opkssh_auth_completed",
data: {
hostId: currentHostIdRef.current,
cols: terminal.cols || 80,
rows: terminal.rows || 24,
hostConfig: currentHostConfigRef.current,
},
}),
);
}
} else if (msg.type === "opkssh_error") {
if (connectionErrorRef.current) return;
opksshFailedRef.current = true;
if (opksshDialog) {
setOpksshDialog((prev) =>
prev ? { ...prev, stage: "error", error: msg.error } : null,
);
} else {
setOpksshDialog({
isOpen: true,
authUrl: "",
requestId: msg.requestId || "",
stage: "error",
error: msg.error,
});
}
setIsConnecting(false);
} else if (msg.type === "opkssh_timeout") {
if (connectionErrorRef.current) return;
opksshFailedRef.current = true;
if (opksshDialog) {
setOpksshDialog((prev) =>
prev
? {
...prev,
stage: "error",
error: t("terminal.opksshTimeout"),
}
: null,
);
} else {
setOpksshDialog({
isOpen: true,
authUrl: "",
requestId: msg.requestId || "",
stage: "error",
error: t("terminal.opksshTimeout"),
});
}
setIsConnecting(false);
} else if (msg.type === "opkssh_config_error") {
setOpksshDialog({
isOpen: true,
authUrl: "",
requestId: msg.requestId || "",
stage: "error",
error: msg.instructions || msg.error,
});
} else if (msg.type === "keyboard_interactive_available") {
setKeyboardInteractiveDetected(true);
setIsConnecting(false);
@@ -2357,7 +2240,6 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
}
} else if (msg.type === "sessionAttached") {
isAttachingSessionRef.current = false;
opksshFailedRef.current = false;
vaultFailedRef.current = false;
wasConnectedRef.current = true;
setIsConnected(true);
@@ -3985,71 +3867,17 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
backgroundColor={backgroundColor}
/>
<WarpgateDialog
isOpen={warpgateAuthRequired}
url={warpgateAuthUrl}
securityKey={warpgateSecurityKey}
onContinue={handleWarpgateContinue}
onCancel={handleWarpgateCancel}
onOpenUrl={handleWarpgateOpenUrl}
<BrowserSignInDialog
isOpen={browserSignIn !== null}
label={browserSignIn?.label ?? ""}
url={browserSignIn?.url ?? ""}
code={browserSignIn?.code ?? ""}
onContinue={handleBrowserSignInContinue}
onCancel={handleBrowserSignInCancel}
onOpenUrl={handleBrowserSignInOpenUrl}
backgroundColor={backgroundColor}
/>
{opksshDialog?.isOpen && (
<OPKSSHDialog
isOpen={opksshDialog.isOpen}
authUrl={opksshDialog.authUrl}
requestId={opksshDialog.requestId}
stage={opksshDialog.stage}
error={opksshDialog.error}
providers={opksshDialog.providers}
label={opksshDialog.label}
onCancel={() => {
if (webSocketRef.current) {
webSocketRef.current.send(
JSON.stringify({
type: "opkssh_cancel",
data: { requestId: opksshDialog.requestId },
}),
);
}
setOpksshDialog(null);
if (opksshTimeoutRef.current) {
clearTimeout(opksshTimeoutRef.current);
opksshTimeoutRef.current = null;
}
}}
onOpenUrl={() => {
window.open(opksshDialog.authUrl, "_blank");
if (webSocketRef.current) {
webSocketRef.current.send(
JSON.stringify({
type: "opkssh_browser_opened",
data: { requestId: opksshDialog.requestId },
}),
);
}
}}
onSelectProvider={(alias) => {
if (!opksshDialog.authUrl) return;
const selectUrl = `${opksshDialog.authUrl}/select?op=${encodeURIComponent(alias)}`;
window.open(selectUrl, "_blank");
if (webSocketRef.current) {
webSocketRef.current.send(
JSON.stringify({
type: "opkssh_browser_opened",
data: { requestId: opksshDialog.requestId },
}),
);
}
setOpksshDialog((prev) =>
prev ? { ...prev, stage: "waiting" } : null,
);
}}
backgroundColor={backgroundColor}
/>
)}
<ComponentSlot
slotId={TERMINAL_OVERLAY_SLOT}
when={{ host }}
@@ -10,7 +10,7 @@ import type { Host } from "../types";
* `openSidePanel(id)`, rendered with TerminalSidePanelProps.
* - "terminal.overlay" (components): always mounted over the terminal and
* given the session's server messages, for connection flows a plugin owns
* (a Tailscale check, say).
* (a Tailscale check, an OPKSSH sign-in). It can answer on the socket.
*
* Every contribution's `when` sees `{ host }`.
*/
@@ -79,4 +79,11 @@ export interface TerminalOverlayProps {
fail: (message: string) => void;
/** Closes the session's connection. */
disconnect: () => void;
/** Sends a message on the session's socket, as { type, data }. */
send: (type: string, data?: unknown) => void;
/**
* What the server needs to connect again after a sign-in finished: send it
* as the data of "<interaction>_auth_completed".
*/
connectPayload: () => Record<string, unknown>;
}
@@ -27,11 +27,14 @@ function classify(
},
host: PluginSshHost,
): PluginKeyboardInteractiveDecision {
if (/warpgate/i.test(round.name)) {
// Stands in for a plugin's keyboard-interactive handler.
if (/gateway sign-in/i.test(round.name)) {
return {
kind: "warpgate",
kind: "browser",
id: "gateway",
label: "Gateway",
url: round.instructions.match(/https?:\/\/\S+/)?.[0] ?? null,
securityKey: round.instructions.match(/Security key: (\S+)/)?.[1] ?? "",
code: round.instructions.match(/Security key: (\S+)/)?.[1] ?? "",
instructions: round.instructions,
};
}
@@ -72,9 +75,9 @@ function createManager() {
keyboardInteractiveResponded: false,
keyboardInteractiveFinish: null,
totpPromptSent: false,
warpgateAuthPromptSent: false,
browserSignInId: null,
totpTimeout: null,
warpgateAuthTimeout: null,
browserSignInTimeout: null,
totpAttempts: 0,
});
return { manager, sent };
@@ -258,13 +261,13 @@ describe("SSHAuthManager.handleKeyboardInteractive", () => {
expect(finish).toHaveBeenCalledWith(["push"]);
});
it("routes Warpgate prompts to the warpgate flow, not the generic path", () => {
it("sends a claimed browser round under its handler's name", () => {
const { manager, sent } = createManager();
const finish = vi.fn();
manager.handleKeyboardInteractive(
"Warpgate Authentication",
"Visit https://warpgate.example.com/auth to continue. Security key: AB12",
"Gateway sign-in",
"Visit https://gateway.example.com/auth to continue. Security key: AB12",
"",
[{ prompt: "Press enter once done: ", echo: true }],
finish,
@@ -277,16 +280,19 @@ describe("SSHAuthManager.handleKeyboardInteractive", () => {
data: {
stage: "auth",
level: "info",
message: "Warpgate authentication required",
message: "Gateway sign-in required",
},
},
{
type: "warpgate_auth_required",
url: "https://warpgate.example.com/auth",
type: "gateway_auth_required",
kind: "browser",
label: "Gateway",
url: "https://gateway.example.com/auth",
securityKey: "AB12",
instructions:
"Visit https://warpgate.example.com/auth to continue. Security key: AB12",
"Visit https://gateway.example.com/auth to continue. Security key: AB12",
},
]);
expect(manager.context.browserSignInId).toBe("gateway");
});
});