import type { FitAddon } from "@xterm/addon-fit"; import type { SerializeAddon } from "@xterm/addon-serialize"; import type { Terminal as XTerm } from "@xterm/xterm"; import type { Dispatch, RefObject, SetStateAction } from "react"; import { logger } from "../../../lib/logger"; import type { Host, Identity, SerialConfig, SSHKey, TerminalSession, TerminalSettings } from "../../../types"; import { resolveHostAuth } from "../../../domain/sshAuth"; type TerminalBackendApi = { backendAvailable: () => boolean; telnetAvailable: () => boolean; moshAvailable: () => boolean; localAvailable: () => boolean; serialAvailable: () => boolean; execAvailable: () => boolean; startSSHSession: (options: NetcattySSHOptions) => Promise; startTelnetSession: ( options: Parameters>[0], ) => Promise; startMoshSession: ( options: Parameters>[0], ) => Promise; startLocalSession: ( options: Parameters>[0], ) => Promise; startSerialSession: ( options: Parameters>[0], ) => Promise; execCommand: (options: Parameters[0]) => Promise<{ stdout?: string; stderr?: string; }>; onSessionData: (sessionId: string, cb: (data: string) => void) => () => void; onSessionExit: ( sessionId: string, cb: (evt: { exitCode?: number; signal?: number }) => void, ) => () => void; onChainProgress: ( cb: (hop: number, total: number, label: string, status: string) => void, ) => (() => void) | undefined; writeToSession: (sessionId: string, data: string) => void; resizeSession: (sessionId: string, cols: number, rows: number) => void; }; export type PendingAuth = { authMethod: "password" | "key" | "certificate"; username: string; password?: string; keyId?: string; passphrase?: string; } | null; type ChainProgressState = { currentHop: number; totalHops: number; currentHostLabel: string; } | null; export type TerminalSessionStartersContext = { host: Host; keys: SSHKey[]; identities?: Identity[]; resolvedChainHosts: Host[]; sessionId: string; startupCommand?: string; terminalSettings?: TerminalSettings; terminalBackend: TerminalBackendApi; serialConfig?: SerialConfig; sessionRef: RefObject; hasConnectedRef: RefObject; hasRunStartupCommandRef: RefObject; disposeDataRef: RefObject<(() => void) | null>; disposeExitRef: RefObject<(() => void) | null>; fitAddonRef: RefObject; serializeAddonRef: RefObject; pendingAuthRef: RefObject; updateStatus: (next: TerminalSession["status"]) => void; setStatus: Dispatch>; setError: Dispatch>; setNeedsAuth: Dispatch>; setAuthRetryMessage: Dispatch>; setAuthPassword: Dispatch>; setProgressLogs: Dispatch>; setProgressValue: Dispatch>; setChainProgress: Dispatch>; onSessionExit?: (sessionId: string) => void; onTerminalDataCapture?: (sessionId: string, data: string) => void; onOsDetected?: (hostId: string, distro: string) => void; onCommandExecuted?: ( command: string, hostId: string, hostLabel: string, sessionId: string, ) => void; }; const buildTermEnv = (host: Host, terminalSettings?: TerminalSettings) => { const env: Record = { TERM: terminalSettings?.terminalEmulationType ?? "xterm-256color", }; if (host.environmentVariables) { for (const { name, value } of host.environmentVariables) { if (name) env[name] = value; } } return env; }; const attachSessionToTerminal = ( ctx: TerminalSessionStartersContext, term: XTerm, id: string, opts?: { onExitMessage?: (evt: { exitCode?: number; signal?: number }) => string; onConnected?: () => void; // For serial: convert lone LF to CRLF to avoid "staircase effect" convertLfToCrlf?: boolean; }, ) => { ctx.sessionRef.current = id; ctx.disposeDataRef.current = ctx.terminalBackend.onSessionData(id, (chunk) => { let data = chunk; // Convert lone LF (\n) to CRLF (\r\n) for proper terminal display // This prevents the "staircase effect" common in serial terminals if (opts?.convertLfToCrlf) { // Replace \n that is not preceded by \r with \r\n data = data.replace(/(? { if (!ctx.fitAddonRef.current) return; try { ctx.fitAddonRef.current.fit(); if (ctx.sessionRef.current) { ctx.terminalBackend.resizeSession(ctx.sessionRef.current, term.cols, term.rows); } } catch (err) { logger.warn("Post-connect fit failed", err); } }, 100); } }); ctx.disposeExitRef.current = ctx.terminalBackend.onSessionExit(id, (evt) => { ctx.updateStatus("disconnected"); term.writeln(opts?.onExitMessage?.(evt) ?? "\r\n[session closed]"); if (ctx.onTerminalDataCapture && ctx.serializeAddonRef.current) { try { const terminalData = ctx.serializeAddonRef.current.serialize(); ctx.onTerminalDataCapture(ctx.sessionId, terminalData); } catch (err) { logger.warn("Failed to serialize terminal data:", err); } } ctx.onSessionExit?.(ctx.sessionId); }); }; const runDistroDetection = async ( ctx: TerminalSessionStartersContext, auth: { username: string; password?: string; key?: SSHKey }, ) => { if (!ctx.terminalBackend.execAvailable()) return; try { const res = await ctx.terminalBackend.execCommand({ hostname: ctx.host.hostname, username: auth.username || "root", port: ctx.host.port || 22, password: auth.password, privateKey: auth.key?.privateKey, command: "cat /etc/os-release 2>/dev/null || uname -a", timeout: 8000, }); const data = `${res.stdout || ""}\n${res.stderr || ""}`; const idMatch = data.match(/ID=([\\w\\-]+)/i); const distro = idMatch ? idMatch[1].replace(/"/g, "") : (data.split(/\s+/)[0] || "").toLowerCase(); if (distro) ctx.onOsDetected?.(ctx.host.id, distro); } catch (err) { logger.warn("OS probe failed", err); } }; export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContext) => { const startSSH = async (term: XTerm) => { try { term.clear?.(); } catch (err) { logger.warn("Failed to clear terminal before connect", err); } if (!ctx.terminalBackend.backendAvailable()) { ctx.setError("Native SSH bridge unavailable. Launch via Electron app."); term.writeln( "\r\n[netcatty SSH bridge unavailable. Please run the desktop build to connect.]", ); ctx.updateStatus("disconnected"); return; } const pendingAuth = ctx.pendingAuthRef.current; const resolvedAuth = resolveHostAuth({ host: ctx.host, keys: ctx.keys, identities: ctx.identities, override: pendingAuth ? { authMethod: pendingAuth.authMethod, username: pendingAuth.username, password: pendingAuth.password, keyId: pendingAuth.keyId, passphrase: pendingAuth.passphrase, } : null, }); const effectiveUsername = resolvedAuth.username || "root"; const effectivePassword = resolvedAuth.password; const key = resolvedAuth.key; const effectivePassphrase = resolvedAuth.passphrase; let usedKey: SSHKey | undefined; let usedPassword: string | undefined; const isAuthError = (err: unknown): boolean => { if (!(err instanceof Error)) return false; const msg = err.message.toLowerCase(); return ( msg.includes("authentication") || msg.includes("auth") || msg.includes("password") || msg.includes("permission denied") ); }; const proxyConfig = ctx.host.proxyConfig ? { type: ctx.host.proxyConfig.type, host: ctx.host.proxyConfig.host, port: ctx.host.proxyConfig.port, username: ctx.host.proxyConfig.username, password: ctx.host.proxyConfig.password, } : undefined; const jumpHosts = ctx.resolvedChainHosts.map((jumpHost) => { const jumpAuth = resolveHostAuth({ host: jumpHost, keys: ctx.keys, identities: ctx.identities, }); const jumpKey = jumpAuth.key; return { hostname: jumpHost.hostname, port: jumpHost.port || 22, username: jumpAuth.username || "root", password: jumpAuth.password, privateKey: jumpKey?.privateKey, certificate: jumpKey?.certificate, passphrase: jumpAuth.passphrase || jumpKey?.passphrase, publicKey: jumpKey?.publicKey, keyId: jumpAuth.keyId, keySource: jumpKey?.source, label: jumpHost.label, }; }); const totalHops = jumpHosts.length + 1; let unsubscribeChainProgress: (() => void) | undefined; if (jumpHosts.length > 0) { ctx.setChainProgress({ currentHop: 1, totalHops, currentHostLabel: jumpHosts[0]?.label || jumpHosts[0]?.hostname || ctx.host.hostname, }); ctx.setProgressLogs((prev) => [ ...prev, `Starting chain connection (${totalHops} hops)...`, ]); const unsub = ctx.terminalBackend.onChainProgress((hop, total, label, status) => { ctx.setChainProgress({ currentHop: hop, totalHops: total, currentHostLabel: label, }); ctx.setProgressLogs((prev) => [ ...prev, `Chain ${hop} of ${total}: ${label} - ${status}`, ]); const hopProgress = (hop / total) * 80 + 10; ctx.setProgressValue(Math.min(95, hopProgress)); }); if (unsub) unsubscribeChainProgress = unsub; } try { const termEnv = buildTermEnv(ctx.host, ctx.terminalSettings); // DEBUG: Log key info for troubleshooting console.log("[Terminal] Starting SSH session with key info:", { keyId: key?.id, keyLabel: key?.label, keySource: key?.source, hasPublicKey: !!key?.publicKey, hasPrivateKey: !!key?.privateKey, }); const startAttempt = async (attempt: { password?: string; key?: SSHKey; }): Promise => { return ctx.terminalBackend.startSSHSession({ sessionId: ctx.sessionId, hostname: ctx.host.hostname, username: effectiveUsername, port: ctx.host.port || 22, password: attempt.password, privateKey: attempt.key?.privateKey, certificate: attempt.key?.certificate, publicKey: attempt.key?.publicKey, keyId: attempt.key?.id, keySource: attempt.key?.source, passphrase: attempt.key ? (effectivePassphrase || attempt.key.passphrase) : undefined, agentForwarding: ctx.host.agentForwarding, cols: term.cols, rows: term.rows, charset: ctx.host.charset, env: termEnv, proxy: proxyConfig, jumpHosts: jumpHosts.length > 0 ? jumpHosts : undefined, keepaliveInterval: ctx.terminalSettings?.keepaliveInterval, }); }; let id: string; // Respect explicit auth method selection - don't use key if password auth was explicitly selected const authMethod = resolvedAuth.authMethod; const hasKeyMaterial = !!key?.privateKey && authMethod !== 'password'; const hasPassword = !!effectivePassword; if (hasKeyMaterial) { try { id = await startAttempt({ key }); usedKey = key; } catch (err) { if (isAuthError(err) && hasPassword) { ctx.setProgressLogs((prev) => [ ...prev, "Key auth failed. Trying password...", ]); id = await startAttempt({ password: effectivePassword }); usedPassword = effectivePassword; } else { throw err; } } } else { id = await startAttempt({ password: effectivePassword }); usedPassword = effectivePassword; } if (unsubscribeChainProgress) unsubscribeChainProgress(); attachSessionToTerminal(ctx, term, id, { onConnected: () => ctx.setChainProgress(null), onExitMessage: (evt) => `\r\n[session closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`, }); const commandToRun = ctx.startupCommand || ctx.host.startupCommand; if (commandToRun && !ctx.hasRunStartupCommandRef.current) { ctx.hasRunStartupCommandRef.current = true; setTimeout(() => { if (!ctx.sessionRef.current) return; ctx.terminalBackend.writeToSession(ctx.sessionRef.current, `${commandToRun}\r`); if (ctx.onCommandExecuted) { ctx.onCommandExecuted(commandToRun, ctx.host.id, ctx.host.label, ctx.sessionId); } }, 600); } } catch (err) { const message = err instanceof Error ? err.message : String(err); const authError = isAuthError(err); if (authError) { ctx.setError(null); ctx.setNeedsAuth(true); ctx.setAuthRetryMessage( "Authentication failed. Please check your credentials and try again.", ); ctx.setAuthPassword(""); ctx.setProgressLogs((prev) => [ ...prev, "Authentication failed. Please try again.", ]); ctx.setStatus("connecting"); } else { ctx.setError(message); term.writeln(`\r\n[Failed to start SSH: ${message}]`); ctx.updateStatus("disconnected"); } ctx.setChainProgress(null); if (unsubscribeChainProgress) unsubscribeChainProgress(); } setTimeout( () => void runDistroDetection(ctx, { username: effectiveUsername, password: usedPassword, key: usedKey, }), 600, ); }; const startTelnet = async (term: XTerm) => { try { term.clear?.(); } catch (err) { logger.warn("Failed to clear terminal before connect", err); } if (!ctx.terminalBackend.telnetAvailable()) { ctx.setError("Telnet bridge unavailable. Please run the desktop build."); term.writeln("\r\n[Telnet bridge unavailable. Please run the desktop build.]"); ctx.updateStatus("disconnected"); return; } try { const telnetEnv = buildTermEnv(ctx.host, ctx.terminalSettings); const id = await ctx.terminalBackend.startTelnetSession({ sessionId: ctx.sessionId, hostname: ctx.host.hostname, port: ctx.host.telnetPort || ctx.host.port || 23, cols: term.cols, rows: term.rows, charset: ctx.host.charset, env: telnetEnv, }); attachSessionToTerminal(ctx, term, id, { onExitMessage: (evt) => `\r\n[Telnet session closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`, }); } catch (err) { const message = err instanceof Error ? err.message : String(err); ctx.setError(message); term.writeln(`\r\n[Failed to start Telnet: ${message}]`); ctx.updateStatus("disconnected"); } }; const startMosh = async (term: XTerm) => { try { term.clear?.(); } catch (err) { logger.warn("Failed to clear terminal before connect", err); } if (!ctx.terminalBackend.moshAvailable()) { ctx.setError("Mosh bridge unavailable. Please run the desktop build."); term.writeln("\r\n[Mosh bridge unavailable. Please run the desktop build.]"); ctx.updateStatus("disconnected"); return; } try { const moshEnv = buildTermEnv(ctx.host, ctx.terminalSettings); const id = await ctx.terminalBackend.startMoshSession({ sessionId: ctx.sessionId, hostname: ctx.host.hostname, username: ctx.host.username || "root", port: ctx.host.port || 22, moshServerPath: ctx.host.moshServerPath, agentForwarding: ctx.host.agentForwarding, cols: term.cols, rows: term.rows, charset: ctx.host.charset, env: moshEnv, }); attachSessionToTerminal(ctx, term, id, { onExitMessage: (evt) => `\r\n[Mosh session closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`, }); const commandToRun = ctx.startupCommand || ctx.host.startupCommand; if (commandToRun && !ctx.hasRunStartupCommandRef.current) { ctx.hasRunStartupCommandRef.current = true; setTimeout(() => { if (!ctx.sessionRef.current) return; ctx.terminalBackend.writeToSession(ctx.sessionRef.current, `${commandToRun}\r`); if (ctx.onCommandExecuted) { ctx.onCommandExecuted(commandToRun, ctx.host.id, ctx.host.label, ctx.sessionId); } }, 600); } } catch (err) { const message = err instanceof Error ? err.message : String(err); ctx.setError(message); term.writeln(`\r\n[Failed to start Mosh: ${message}]`); ctx.updateStatus("disconnected"); } }; const startLocal = async (term: XTerm) => { try { term.clear?.(); } catch (err) { logger.warn("Failed to clear terminal before connect", err); } if (!ctx.terminalBackend.localAvailable()) { ctx.setError("Local shell bridge unavailable. Please run the desktop build."); term.writeln( "\r\n[Local shell bridge unavailable. Please run the desktop build to spawn a local terminal.]", ); ctx.updateStatus("disconnected"); return; } try { // Get local shell configuration from terminal settings const localShell = ctx.terminalSettings?.localShell; const localStartDir = ctx.terminalSettings?.localStartDir; const id = await ctx.terminalBackend.startLocalSession({ sessionId: ctx.sessionId, cols: term.cols, rows: term.rows, shell: localShell, cwd: localStartDir, env: { TERM: ctx.terminalSettings?.terminalEmulationType ?? "xterm-256color", }, }); ctx.sessionRef.current = id; ctx.disposeDataRef.current = ctx.terminalBackend.onSessionData(id, (chunk) => { term.write(chunk); if (!ctx.hasConnectedRef.current) { ctx.updateStatus("connected"); setTimeout(() => { if (!ctx.fitAddonRef.current) return; try { ctx.fitAddonRef.current.fit(); if (ctx.sessionRef.current) { ctx.terminalBackend.resizeSession(ctx.sessionRef.current, term.cols, term.rows); } } catch (err) { logger.warn("Post-connect fit failed", err); } }, 100); } }); ctx.disposeExitRef.current = ctx.terminalBackend.onSessionExit(id, (evt) => { ctx.updateStatus("disconnected"); term.writeln( `\r\n[session closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`, ); logger.info("[Terminal] Session exit, capturing data", { sessionId: ctx.sessionId, hasCallback: !!ctx.onTerminalDataCapture, hasSerializeAddon: !!ctx.serializeAddonRef.current, }); if (ctx.onTerminalDataCapture && ctx.serializeAddonRef.current) { try { const terminalData = ctx.serializeAddonRef.current.serialize(); logger.info("[Terminal] Serialized terminal data", { sessionId: ctx.sessionId, dataLength: terminalData.length, }); ctx.onTerminalDataCapture(ctx.sessionId, terminalData); } catch (err) { logger.warn("Failed to serialize terminal data:", err); } } ctx.onSessionExit?.(ctx.sessionId); }); } catch (err) { const message = err instanceof Error ? err.message : String(err); ctx.setError(message); term.writeln(`\r\n[Failed to start local shell: ${message}]`); ctx.updateStatus("disconnected"); } }; // Start Serial session const startSerial = async (term: XTerm) => { if (!ctx.serialConfig) { ctx.setError("No serial configuration provided"); term.writeln("\r\n[Error: No serial configuration provided]"); ctx.updateStatus("disconnected"); return; } try { logger.info("[Serial] Starting serial session", { port: ctx.serialConfig.path, baudRate: ctx.serialConfig.baudRate, }); const id = await ctx.terminalBackend.startSerialSession({ sessionId: ctx.sessionId, path: ctx.serialConfig.path, baudRate: ctx.serialConfig.baudRate, dataBits: ctx.serialConfig.dataBits, stopBits: ctx.serialConfig.stopBits, parity: ctx.serialConfig.parity, flowControl: ctx.serialConfig.flowControl, }); // Serial connection is established immediately when session starts // Update status right away since serial ports don't require handshake ctx.updateStatus("connected"); ctx.setProgressValue(100); term.writeln(`[Connected to ${ctx.serialConfig.path} at ${ctx.serialConfig.baudRate} baud]`); attachSessionToTerminal(ctx, term, id, { onExitMessage: (evt) => `\r\n[serial port closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`, // Convert lone LF to CRLF to prevent "staircase effect" in serial terminals convertLfToCrlf: true, }); } catch (err) { const message = err instanceof Error ? err.message : String(err); ctx.setError(message); term.writeln(`\r\n[Failed to connect to serial port: ${message}]`); ctx.updateStatus("disconnected"); } }; return { startSSH, startTelnet, startMosh, startLocal, startSerial }; };