/** * Terminal Bridge - Handles local shell, telnet/mosh, and serial port sessions * Extracted from main.cjs for single responsibility */ const os = require("node:os"); const fs = require("node:fs"); const net = require("node:net"); const { randomUUID } = require("node:crypto"); const { execFile, execFileSync } = require("node:child_process"); const path = require("node:path"); const { promisify } = require("node:util"); const { StringDecoder } = require("node:string_decoder"); const { ensureNodePtySpawnHelperExecutable } = require("./nodePtySpawnHelperPermissions.cjs"); const { createTerminalFlowPauseArbiter } = require("./terminalFlowPauseArbiter.cjs"); ensureNodePtySpawnHelperExecutable(); const terminalFlowPauseArbiter = createTerminalFlowPauseArbiter(); const trackedFlowPauseSenders = new WeakSet(); const pty = require("node-pty"); const { SerialPort } = require("serialport"); const { configureTerminalSessionDataEmitter, emitTerminalSessionData, } = require("./emitTerminalSessionData.cjs"); const { getRecentInterruptTrace, getSessionSnapshot, logTerminalFlowAckSample, logTerminalFlowPauseSample, logTerminalInterruptDebug, normalizeTrace, rememberInterruptTrace, resetTerminalFlowAckSample, } = require("./terminalInterruptDiagnostics.cjs"); const { clearSessionFlowState, setBufferedOutputBytes, setRendererFlowPaused, shouldAcceptSessionOutput, shouldProcessSessionOutput, trackAck, } = require("./terminalFlowAck.cjs"); const { armTerminalInterruptOutputGate, disarmTerminalInterruptOutputGate, filterTerminalInterruptOutput, shouldArmTerminalInterruptOutputGate, stashPendingInterruptOutputMeta, takePendingInterruptOutputMeta, } = require("./terminalInterruptOutputGate.cjs"); const iconv = require("iconv-lite"); const ptyProcessTree = require("./ptyProcessTree.cjs"); const sessionLogStreamManager = require("./sessionLogStreamManager.cjs"); const { detectShellKind } = require("./ai/ptyExec.cjs"); const { stripAnsi, trackSessionIdlePrompt } = require("./ai/shellUtils.cjs"); const { createZmodemSentry } = require("./zmodemHelper.cjs"); const { discoverShells } = require("./shellDiscovery.cjs"); const { isWindowsAppExecutionAliasPath } = require("../../lib/localShell.cjs"); const moshHandshake = require("./moshHandshake.cjs"); const tempDirBridge = require("./tempDirBridge.cjs"); const { createTelnetAutoLogin } = require("./telnetAutoLogin.cjs"); const telnetProtocol = require("./telnetProtocol.cjs"); const { createPtyOutputBuffer } = require("./ptyOutputBuffer.cjs"); const { enableTcpNoDelay } = require("./tcpNoDelay.cjs"); const { releaseConnectionRef } = require("./sshConnectionPool.cjs"); const { normalizeTerminalEncoding, encodeTerminalInput } = require("./terminalEncoding.cjs"); const { isTerminalReportSequence } = require("./terminalReportSequence.cjs"); const { receiveYmodemFiles, sendYmodemCancel, sendYmodemFile } = require("./ymodemTransfer.cjs"); const { getNativeOpenSshAgentSocket, getNativeOpenSshForwardingAgentSocket, prepareSystemSshAgentForAuth, } = require("./sshAuthHelper.cjs"); const execFileAsync = promisify(execFile); // Shared references let sessions = null; let electronModule = null; let terminalOutputChannel = null; let selectZmodemUploadFiles = null; let selectZmodemDownloadDirectory = null; let reportOpenedSessionActivity = null; let terminalDataPipeline = null; const terminalInputPipelineBarriers = new Map(); const DEFAULT_UTF8_LOCALE = "en_US.UTF-8"; const LOGIN_SHELLS = new Set(["bash", "zsh", "fish", "ksh"]); const POWERSHELL_SHELLS = new Set(["powershell", "powershell.exe", "pwsh", "pwsh.exe"]); function expandHomePath(targetPath) { if (!targetPath) return targetPath; if (targetPath === "~") return os.homedir(); if (targetPath.startsWith("~/")) return path.join(os.homedir(), targetPath.slice(2)); return targetPath; } function normalizeExecutablePath(targetPath) { const expanded = expandHomePath(targetPath); if (!expanded) return expanded; if (expanded.includes(path.sep) || expanded.startsWith(".")) { return path.resolve(expanded); } return expanded; } const getLoginShellArgs = (shellPath) => { if (!shellPath || process.platform === "win32") return []; const shellName = path.basename(shellPath); return LOGIN_SHELLS.has(shellName) ? ["-l"] : []; }; /** * Initialize the terminal bridge with dependencies */ function init(deps) { sessions = deps.sessions; electronModule = deps.electronModule; terminalOutputChannel = deps.terminalOutputChannel || null; selectZmodemUploadFiles = deps.selectZmodemUploadFiles || null; selectZmodemDownloadDirectory = deps.selectZmodemDownloadDirectory || null; reportOpenedSessionActivity = typeof deps.reportOpenedSessionActivity === "function" ? deps.reportOpenedSessionActivity : null; terminalDataPipeline = deps.terminalDataPipeline || null; terminalInputPipelineBarriers.clear(); configureTerminalSessionDataEmitter({ getSession: (sessionId) => sessions?.get(sessionId), outputChannel: terminalOutputChannel, onSessionActivity: reportOpenedSessionActivity, }); cleanupStaleEtTempDirs(); } function openTerminalOutputSession(sessionId, webContents) { const generation = webContents?.claimSessionGeneration?.(sessionId); const session = sessions?.get?.(sessionId); if (session && Number.isSafeInteger(generation)) { session._terminalSessionGeneration = generation; } terminalOutputChannel?.openSession?.(sessionId, webContents); } function closeTerminalOutputSession(sessionId) { terminalOutputChannel?.closeSession?.(sessionId); } /** @type {Map void, timeout: NodeJS.Timeout }>} */ const pendingTerminalSnapshots = new Map(); const pendingTerminalSnapshotApplies = new Map(); const pendingTerminalOutputDrains = new Map(); const pendingAttachedOutputRestores = new Map(); const TERMINAL_SNAPSHOT_TIMEOUT_MS = 2000; /** In-process (non-worker) attach home mapping: sessionId -> webContentsId */ const attachHomeWebContentsIds = new Map(); const { markAttachPopupClosePrepared, retryPendingAttachedSessionOutput, releaseAttachedSessionState, setRestoreAttachedSessionOutput, setAttachHomeLookup, setFanoutSessionExit, validateAttachPopupAuthorization, } = require("./terminalAttachRestore.cjs"); function isAuthorizedAttachIpc(event, payload, sessionId) { return validateAttachPopupAuthorization( payload?.authorization, sessionId, event?.sender?.id, ); } function resolveSessionHomeWebContentsId(sessionId, terminalWorkerManager = null) { if (!sessionId) return null; if (terminalWorkerManager?.getSessionWebContentsId) { const id = terminalWorkerManager.getSessionWebContentsId(sessionId); if (typeof id === "number") return id; } const session = sessions?.get?.(sessionId); if (typeof session?.webContentsId === "number") return session.webContentsId; return null; } function normalizeKittyKeyboardModeState(value) { if (!value || typeof value !== "object") return undefined; const flags = (input) => Number.isFinite(input) ? Math.max(0, Math.floor(input)) & 31 : 0; const stack = (input) => Array.isArray(input) ? input.slice(-32).map(flags) : []; return { mainFlags: flags(value.mainFlags), alternateFlags: flags(value.alternateFlags), mainStack: stack(value.mainStack), alternateStack: stack(value.alternateStack), alternateScreenActive: value.alternateScreenActive === true, }; } /** * Capture a serialize snapshot from the home renderer before rebinding output * to an observe popup, so the popup is not an empty shell. */ function requestTerminalSessionSnapshot(event, payload, terminalWorkerManager = null) { const sessionId = typeof payload?.sessionId === "string" ? payload.sessionId : ""; if (!sessionId) { return Promise.resolve({ success: false, snapshot: "", error: "Missing sessionId" }); } if (!isAuthorizedAttachIpc(event, payload, sessionId)) { return Promise.resolve({ success: false, snapshot: "", error: "Unauthorized attach request" }); } const homeId = resolveSessionHomeWebContentsId(sessionId, terminalWorkerManager); if (typeof homeId !== "number" || !electronModule?.webContents?.fromId) { return Promise.resolve({ success: false, snapshot: "", error: "Home renderer not found" }); } let home; try { home = electronModule.webContents.fromId(homeId); } catch { home = null; } if (!home || home.isDestroyed?.()) { return Promise.resolve({ success: false, snapshot: "", error: "Home renderer destroyed" }); } const requestId = randomUUID(); return new Promise((resolve) => { const timeout = setTimeout(() => { pendingTerminalSnapshots.delete(requestId); resolve({ success: false, snapshot: "", error: "timeout" }); }, TERMINAL_SNAPSHOT_TIMEOUT_MS); pendingTerminalSnapshots.set(requestId, { resolve, timeout, webContentsId: home.id }); try { home.send("netcatty:terminal:snapshot-request", { sessionId, requestId }); } catch (err) { clearTimeout(timeout); pendingTerminalSnapshots.delete(requestId); resolve({ success: false, snapshot: "", error: err?.message || String(err) }); } }); } function handleTerminalSessionSnapshotResponse(event, payload) { const requestId = typeof payload?.requestId === "string" ? payload.requestId : ""; if (!requestId) return; const pending = pendingTerminalSnapshots.get(requestId); if (!pending) return; if (pending.webContentsId !== event?.sender?.id) return; clearTimeout(pending.timeout); pendingTerminalSnapshots.delete(requestId); pending.resolve({ success: true, snapshot: typeof payload?.snapshot === "string" ? payload.snapshot : "", kittyKeyboardModeState: normalizeKittyKeyboardModeState(payload?.kittyKeyboardModeState), kittyKeyboardProtocolEnabled: typeof payload?.kittyKeyboardProtocolEnabled === "boolean" ? payload.kittyKeyboardProtocolEnabled : undefined, passwordPromptActive: typeof payload?.passwordPromptActive === "boolean" ? payload.passwordPromptActive : undefined, cwd: payload?.cwd === null ? null : typeof payload?.cwd === "string" ? payload.cwd : undefined, title: payload?.title === null ? null : typeof payload?.title === "string" ? payload.title : undefined, }); } function requestTerminalOutputDrain(sessionId, terminalWorkerManager = null) { const targetId = resolveSessionHomeWebContentsId(sessionId, terminalWorkerManager); if (typeof targetId !== "number") { return Promise.resolve({ success: false, error: "Display renderer not found" }); } const requestId = randomUUID(); return new Promise((resolve) => { const timeout = setTimeout(() => { pendingTerminalOutputDrains.delete(requestId); resolve({ success: false, error: "timeout" }); }, TERMINAL_SNAPSHOT_TIMEOUT_MS); pendingTerminalOutputDrains.set(requestId, { resolve, timeout, webContentsId: targetId }); const sent = terminalWorkerManager ? terminalWorkerManager.drainOutputSession?.(sessionId, requestId) : terminalOutputChannel?.drainSession?.(sessionId, requestId); if (!sent) { clearTimeout(timeout); pendingTerminalOutputDrains.delete(requestId); resolve({ success: false, error: "Output drain unavailable" }); } }); } function handleTerminalOutputDrainResponse(event, payload) { const pending = pendingTerminalOutputDrains.get(payload?.requestId); if (!pending || pending.webContentsId !== event?.sender?.id) return; clearTimeout(pending.timeout); pendingTerminalOutputDrains.delete(payload.requestId); pending.resolve({ success: true }); } /** * Push the observe-popup terminal state back to the home renderer before * restoring the display route, so reopen/attach doesn't show a stale view. */ function applyTerminalSessionSnapshot(event, payload, terminalWorkerManager = null) { const sessionId = typeof payload?.sessionId === "string" ? payload.sessionId : ""; const hasSnapshot = typeof payload?.snapshot === "string"; const snapshot = hasSnapshot ? payload.snapshot : ""; const hasContextSnapshot = typeof payload?.contextSnapshot === "string"; const contextSnapshot = hasContextSnapshot ? payload.contextSnapshot : ""; const hasContextViewportSnapshot = typeof payload?.contextViewportSnapshot === "string"; const contextViewportSnapshot = hasContextViewportSnapshot ? payload.contextViewportSnapshot : ""; const hasContextScrollbackSnapshot = typeof payload?.contextScrollbackSnapshot === "string"; const contextScrollbackSnapshot = hasContextScrollbackSnapshot ? payload.contextScrollbackSnapshot : ""; const hasAlternateScreen = typeof payload?.alternateScreen === "boolean"; const alternateScreen = payload?.alternateScreen === true; const kittyKeyboardModeState = normalizeKittyKeyboardModeState(payload?.kittyKeyboardModeState); const kittyKeyboardProtocolEnabled = typeof payload?.kittyKeyboardProtocolEnabled === "boolean" ? payload.kittyKeyboardProtocolEnabled : undefined; const passwordPromptActive = typeof payload?.passwordPromptActive === "boolean" ? payload.passwordPromptActive : undefined; const cwd = payload?.cwd === null ? null : typeof payload?.cwd === "string" ? payload.cwd : undefined; const title = payload?.title === null ? null : typeof payload?.title === "string" ? payload.title : undefined; if ( !sessionId || !hasSnapshot || !hasContextSnapshot || !hasContextViewportSnapshot || !hasContextScrollbackSnapshot || !hasAlternateScreen ) { return Promise.resolve({ success: false, error: "Missing sessionId or snapshot" }); } if (!isAuthorizedAttachIpc(event, payload, sessionId)) { return Promise.resolve({ success: false, error: "Unauthorized attach request" }); } let homeId = null; if (terminalWorkerManager?.getAttachHomeWebContentsId) { homeId = terminalWorkerManager.getAttachHomeWebContentsId(sessionId); } if (homeId == null) { homeId = attachHomeWebContentsIds.get(sessionId) ?? null; } if (typeof homeId !== "number") { return Promise.resolve({ success: false, error: "Home renderer not found" }); } try { const home = findRegisteredMainWebContents(homeId); if (!home) { return Promise.resolve({ success: false, error: "Home renderer unavailable" }); } const requestId = randomUUID(); return new Promise((resolve) => { const timeout = setTimeout(() => { pendingTerminalSnapshotApplies.delete(requestId); resolve({ success: false, error: "timeout" }); }, TERMINAL_SNAPSHOT_TIMEOUT_MS); pendingTerminalSnapshotApplies.set(requestId, { resolve, timeout, webContentsId: home.id }); try { home.send("netcatty:terminal:apply-snapshot", { sessionId, snapshot, contextSnapshot, contextViewportSnapshot, contextScrollbackSnapshot, alternateScreen, kittyKeyboardModeState, kittyKeyboardProtocolEnabled, passwordPromptActive, cwd, title, requestId, }); } catch (err) { clearTimeout(timeout); pendingTerminalSnapshotApplies.delete(requestId); resolve({ success: false, error: err?.message || String(err) }); } }); } catch (err) { return Promise.resolve({ success: false, error: err?.message || String(err) }); } } function handleTerminalSessionApplySnapshotResponse(event, payload) { const pending = pendingTerminalSnapshotApplies.get(payload?.requestId); if (!pending || pending.webContentsId !== event?.sender?.id) return; clearTimeout(pending.timeout); pendingTerminalSnapshotApplies.delete(payload.requestId); pending.resolve(payload?.success === false ? { success: false, error: payload?.error || "Snapshot apply failed" } : { success: true }); } /** * Rebind a live session's output MessagePort to another renderer (e.g. AI * silent-session observe popup). Keeps the same PTY/stream; only the display * route moves. Returns the previous webContentsId so the caller can restore. * * Worker mode: sessions live in the utilityProcess; display routing is owned * by terminalWorkerManager (sessionWebContentsIds + output ports). * In-process mode: sessions Map in this bridge owns webContentsId. */ async function rebindTerminalSessionOutput(event, payload, terminalWorkerManager = null) { const sessionId = typeof payload?.sessionId === "string" ? payload.sessionId : ""; if (!sessionId) { return { success: false, error: "Missing sessionId" }; } const sender = event?.sender; if (!sender || sender.isDestroyed?.()) { return { success: false, error: "Invalid sender" }; } if (!isAuthorizedAttachIpc(event, payload, sessionId)) { return { success: false, error: "Unauthorized attach request" }; } if (terminalWorkerManager) { try { const result = await terminalWorkerManager.rebindOutputSession(sessionId, sender.id); if (result?.success && sender.isDestroyed?.()) { await restoreAttachedSessionOutput(sessionId, terminalWorkerManager); return { success: false, error: "Attach window closed during rebind" }; } return result; } catch (err) { return { success: false, error: err?.message || String(err) }; } } const session = sessions?.get?.(sessionId); if (!session) { return { success: false, error: "Session not found" }; } const previousWebContentsId = typeof session.webContentsId === "number" ? session.webContentsId : null; try { if ( previousWebContentsId != null && previousWebContentsId !== sender.id && !attachHomeWebContentsIds.has(sessionId) ) { attachHomeWebContentsIds.set(sessionId, previousWebContentsId); } openTerminalOutputSession(sessionId, sender); session.webContentsId = sender.id; if (sender.isDestroyed?.()) { await restoreAttachedSessionOutput(sessionId, terminalWorkerManager); return { success: false, error: "Attach window closed during rebind" }; } return { success: true, previousWebContentsId, webContentsId: sender.id, }; } catch (err) { return { success: false, error: err?.message || String(err) }; } } function fanoutSessionLifecycleEvent( sessionId, primaryWebContentsId, channel, payload, terminalSessionGeneration, ) { const targets = new Set(); if (typeof primaryWebContentsId === "number") targets.add(primaryWebContentsId); const homeId = attachHomeWebContentsIds.get(sessionId); if (typeof homeId === "number") targets.add(homeId); for (const id of targets) { try { const contents = electronModule?.webContents?.fromId?.(id); contents?.send?.(channel, Number.isSafeInteger(terminalSessionGeneration) ? { ...payload, _terminalSessionGeneration: terminalSessionGeneration } : payload); } catch { // ignore destroyed renderers } } attachHomeWebContentsIds.delete(sessionId); } function findRegisteredMainWebContents(preferredId) { try { const wm = require("./windowManager.cjs"); const mains = typeof wm.getMainWindows === "function" ? wm.getMainWindows() : (typeof wm.getMainWindow === "function" ? [wm.getMainWindow()].filter(Boolean) : []); const liveContents = []; for (const win of mains) { const contents = win?.webContents; if (contents && !contents.isDestroyed?.()) liveContents.push(contents); } if (typeof preferredId === "number") { const preferred = liveContents.find((contents) => contents.id === preferredId); if (preferred) return preferred; } return liveContents[0] || null; } catch { // ignore unavailable window manager during isolated tests/startup } return null; } async function restoreAttachedSessionOutputOnce( sessionId, terminalWorkerManager = null, preferredHomeWebContentsId = null, ) { if (!sessionId) return { success: false, restored: false }; const restorePauseOwner = "main:attach-restore"; const paused = terminalFlowPauseArbiter.setDirectPaused( sessionId, restorePauseOwner, true, ); await applyEffectiveSessionFlowPauseAndWait( { sender: { id: undefined } }, { sessionId, paused }, terminalWorkerManager, ); let result; if (terminalWorkerManager?.restoreAttachHome) { result = await terminalWorkerManager.restoreAttachHome(sessionId, preferredHomeWebContentsId); } else { const homeId = attachHomeWebContentsIds.get(sessionId); if (homeId == null) { result = { success: true, restored: false }; } else { const session = sessions?.get?.(sessionId); if (!session) { attachHomeWebContentsIds.delete(sessionId); result = { success: true, restored: false }; } else { try { const home = findRegisteredMainWebContents(preferredHomeWebContentsId ?? homeId); if (!home) { result = { success: false, restored: false, error: "Home renderer unavailable" }; } else { openTerminalOutputSession(sessionId, home); session.webContentsId = home.id; attachHomeWebContentsIds.delete(sessionId); result = { success: true, restored: true, webContentsId: home.id }; } } catch (err) { result = { success: false, restored: false, error: err?.message || String(err) }; } } } } // Resume only after output has a live destination. If no main renderer is // currently available, keep the source paused and the home mapping intact so // a later attach/recovery can safely reclaim the session. if (result?.success) { const effectivePaused = terminalFlowPauseArbiter.setDirectPaused( sessionId, restorePauseOwner, false, ); applyEffectiveSessionFlowPause( { sender: { id: undefined } }, { sessionId, paused: effectivePaused }, terminalWorkerManager, ); } return result; } function restoreAttachedSessionOutput( sessionId, terminalWorkerManager = null, preferredHomeWebContentsId = null, ) { if (!sessionId) return Promise.resolve({ success: false, restored: false }); const existing = pendingAttachedOutputRestores.get(sessionId); if (existing) return existing; const operation = restoreAttachedSessionOutputOnce( sessionId, terminalWorkerManager, preferredHomeWebContentsId, ); pendingAttachedOutputRestores.set(sessionId, operation); const clearPendingRestore = () => { if (pendingAttachedOutputRestores.get(sessionId) === operation) { pendingAttachedOutputRestores.delete(sessionId); } }; void operation.then(clearPendingRestore, clearPendingRestore); return operation; } /** * Restore output to a previous renderer after an attach popup closes. * Falls back to the first live main-ish window if the home webContents is gone. */ async function restoreTerminalSessionOutput(event, payload, terminalWorkerManager = null) { const sessionId = typeof payload?.sessionId === "string" ? payload.sessionId : ""; if (!sessionId) { return { success: false, error: "Missing sessionId" }; } if (!isAuthorizedAttachIpc(event, payload, sessionId)) { return { success: false, error: "Unauthorized attach request" }; } const homeId = payload?.webContentsId; const target = findRegisteredMainWebContents(homeId); if (!target) { return { success: false, error: "No live renderer to restore output to" }; } if (terminalWorkerManager) { if (!terminalWorkerManager.hasOpenSession?.(sessionId)) { // Session already closed — nothing to restore. return { success: true, restored: false }; } try { const result = await terminalWorkerManager.rebindOutputSession(sessionId, target.id); if (!result?.success) { return { success: false, error: result?.error || "Failed to restore session output" }; } terminalWorkerManager.clearAttachHome?.(sessionId); return { success: true, restored: true, webContentsId: target.id }; } catch (err) { return { success: false, error: err?.message || String(err) }; } } const session = sessions?.get?.(sessionId); if (!session) { // Session already closed — nothing to restore. return { success: true, restored: false }; } try { openTerminalOutputSession(sessionId, target); session.webContentsId = target.id; attachHomeWebContentsIds.delete(sessionId); return { success: true, restored: true, webContentsId: target.id }; } catch (err) { return { success: false, error: err?.message || String(err) }; } } /** * Locate an executable on POSIX systems by name. * * macOS GUI Electron apps inherit launchd's minimal PATH * (`/usr/bin:/bin:/usr/sbin:/sbin`), missing Homebrew and other common * package-manager directories. `pty.spawn(name)` then either fails * synchronously with ENOENT or spawns a child that immediately exits * with no useful error surfaced to the renderer (see issue #842 for the * Mosh case). * * Returns the absolute path on success, or null when the binary cannot * be located anywhere we know to look. Win32 callers should keep using * findExecutable() which handles `where.exe` + Windows-specific paths. */ const POSIX_EXTRA_PATH_DIRS = [ "/opt/homebrew/bin", "/opt/homebrew/sbin", "/usr/local/bin", "/usr/local/sbin", "/opt/local/bin", "/opt/local/sbin", "/usr/bin", "/bin", "/usr/sbin", "/sbin", ]; function isExecutableFile(candidate) { try { const st = fs.statSync(candidate); if (!st.isFile()) return false; // Windows has no POSIX execute bit — Node returns mode 0o100666 even for // .exe / .bat / .cmd files, so 0o111 is unreliable there. Treat any // regular file as executable on Win32 and let spawn-time PATHEXT / // extension handling reject non-executables. if (process.platform === "win32") return true; return (st.mode & 0o111) !== 0; } catch { return false; } } function resolvePosixExecutable(name, opts = {}) { if (process.platform === "win32") return null; if (!name || typeof name !== "string") return null; // Already an absolute or relative path: validate as-is. if (name.includes("/")) { return isExecutableFile(name) ? name : null; } if (!/^[a-zA-Z0-9._+-]+$/.test(name)) return null; const seen = new Set(); const dirs = []; // 1. Honor the caller-supplied PATH first so callers that have already // merged a host-level environmentVariables.PATH override don't see the // fallback decline a binary that the spawned process would have found. // Falls back to the main process PATH when no override is provided. const pathOverride = Object.prototype.hasOwnProperty.call(opts, "pathOverride") ? opts.pathOverride : process.env.PATH; for (const dir of (pathOverride || "").split(":")) { if (dir && !seen.has(dir)) { seen.add(dir); dirs.push(dir); } } // 2. Add directories the GUI launcher's PATH typically misses on macOS/Linux. for (const dir of POSIX_EXTRA_PATH_DIRS) { if (!seen.has(dir)) { seen.add(dir); dirs.push(dir); } } // 3. User-scoped install locations (nix-profile, cargo, ~/.local). const home = process.env.HOME; if (home) { for (const sub of [".nix-profile/bin", ".cargo/bin", ".local/bin"]) { const dir = path.join(home, sub); if (!seen.has(dir)) { seen.add(dir); dirs.push(dir); } } } for (const dir of dirs) { const candidate = path.join(dir, name); if (isExecutableFile(candidate)) return candidate; } return null; } /** * Find executable path on Windows */ function findExecutable(name, opts = {}) { if (process.platform !== "win32") return name; const { execFileSync } = require("child_process"); try { const pathOverride = Object.prototype.hasOwnProperty.call(opts, "pathOverride") ? opts.pathOverride : process.env.PATH; const env = { ...process.env, PATH: pathOverride || "" }; const whereExe = path.join(process.env.SystemRoot || "C:\\Windows", "System32", "where.exe"); const result = execFileSync(fs.existsSync(whereExe) ? whereExe : "where.exe", [name], { encoding: "utf8", env, maxBuffer: 1024 * 1024, timeout: 3_000, windowsHide: true, }); const candidates = result .split(/\r?\n/) .map((line) => line.trim()) .filter(Boolean); // Windows App Execution Aliases (MSIX/Store installs, e.g. winget pwsh 7) // fail fs.existsSync() because they are reparse points, yet ConPTY can // spawn the alias path directly. Keep the first alias as a last resort // behind any regular executable found on the PATH. let aliasCandidate = null; for (const candidate of candidates) { if (isWindowsAppExecutionAliasPath(candidate)) { aliasCandidate ??= candidate; continue; } if (!fs.existsSync(candidate)) continue; return candidate; } if (aliasCandidate) return aliasCandidate; } catch (err) { console.warn(`Could not find ${name} via where.exe:`, err.message); } if (!/^[a-zA-Z0-9._-]+$/.test(name)) return name; const commonPaths = []; if (name === "pwsh") { commonPaths.push( path.join(process.env.ProgramFiles || "C:\\Program Files", "PowerShell", "7", "pwsh.exe"), path.join(process.env.ProgramW6432 || "C:\\Program Files", "PowerShell", "7", "pwsh.exe"), ); } if (name === "powershell") { commonPaths.push( path.join( process.env.SystemRoot || "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe", ), ); } commonPaths.push( path.join(process.env.SystemRoot || "C:\\Windows", "System32", "OpenSSH", `${name}.exe`), path.join(process.env.ProgramFiles || "C:\\Program Files", "Git", "usr", "bin", `${name}.exe`), path.join(process.env.ProgramFiles || "C:\\Program Files", "OpenSSH", `${name}.exe`), ); for (const p of commonPaths) { if (fs.existsSync(p)) return p; } return name; } function getDefaultLocalShell() { if (process.platform !== "win32") { return process.env.SHELL || "/bin/bash"; } const pwsh = findExecutable("pwsh"); if (pwsh && pwsh.toLowerCase() !== "pwsh") { return pwsh; } const powershell = findExecutable("powershell"); if (powershell && powershell.toLowerCase() !== "powershell") { return powershell; } return "powershell.exe"; } function getLocalShellArgs(shellPath) { if (!shellPath) return []; if (process.platform !== "win32") { return getLoginShellArgs(shellPath); } const shellName = path.basename(shellPath).toLowerCase(); if (POWERSHELL_SHELLS.has(shellName)) { return ["-NoLogo"]; } return []; } const isUtf8Locale = (value) => typeof value === "string" && /utf-?8/i.test(value); const isEmptyLocale = (value) => { if (value === undefined || value === null) return true; const trimmed = String(value).trim(); if (!trimmed) return true; return trimmed === "C" || trimmed === "POSIX"; }; const applyLocaleDefaults = (env) => { const hasUtf8 = isUtf8Locale(env.LC_ALL) || isUtf8Locale(env.LC_CTYPE) || isUtf8Locale(env.LANG); if (hasUtf8) return env; const hasAnyLocale = !isEmptyLocale(env.LC_ALL) || !isEmptyLocale(env.LC_CTYPE) || !isEmptyLocale(env.LANG); if (hasAnyLocale) return env; return { ...env, LANG: DEFAULT_UTF8_LOCALE, LC_CTYPE: DEFAULT_UTF8_LOCALE, LC_ALL: DEFAULT_UTF8_LOCALE, }; }; /** * Start a local terminal session */ function startLocalSession(event, payload) { const sessionId = payload?.sessionId || randomUUID(); const defaultShell = getDefaultLocalShell(); // payload.shell may be a discovered shell ID (e.g., "wsl-ubuntu") — resolve it let resolvedShell = payload?.shell; let resolvedArgs = payload?.shellArgs; if (resolvedShell && !/[/\\]/.test(resolvedShell)) { // Looks like a shell ID, not a path — try to resolve from discovery cache const shells = discoverShells(); const match = shells.find((s) => s.id === resolvedShell); if (match) { resolvedShell = match.command; resolvedArgs = resolvedArgs ?? match.args; } } const shell = normalizeExecutablePath(resolvedShell) || defaultShell; const shellArgs = resolvedArgs ?? getLocalShellArgs(shell); const shellKind = detectShellKind(shell); const { buildTerminalProcessEnv } = require("./httpNetworkProxyBridge.cjs"); const env = applyLocaleDefaults({ ...buildTerminalProcessEnv(process.env), ...(payload?.env || {}), TERM: "xterm-256color", COLORTERM: "truecolor", }); // Determine the starting directory // Default to home directory if not specified or if specified path is invalid const defaultCwd = os.homedir(); let cwd = defaultCwd; if (payload?.cwd) { try { // Resolve to absolute path and check if it exists and is a directory const resolvedPath = path.resolve(expandHomePath(payload.cwd)); if (fs.existsSync(resolvedPath) && fs.statSync(resolvedPath).isDirectory()) { cwd = resolvedPath; } else { console.warn(`[Terminal] Specified cwd "${payload.cwd}" is not a valid directory, using home directory`); } } catch (err) { console.warn(`[Terminal] Error validating cwd "${payload.cwd}":`, err.message); } } const proc = pty.spawn(shell, shellArgs, { name: env.TERM || "xterm-256color", cols: payload?.cols || 80, rows: payload?.rows || 24, env, cwd, encoding: null, // Return Buffer for ZMODEM binary support // node-pty can only clear ConPTY through its bundled conpty.dll. useConptyDll: process.platform === "win32", }); const session = { proc, pty: proc, type: "local", protocol: "local", webContentsId: event.sender.id, hostname: "localhost", username: (() => { try { return os.userInfo().username || "local"; } catch { return "local"; } })(), label: "Local Terminal", shellExecutable: shell, shellKind, flushPendingData: null, lastIdlePrompt: "", lastIdlePromptAt: 0, _promptTrackTail: "", }; const { claimSessionSlot } = require("./sessionBootEpoch.cjs"); const claim = claimSessionSlot(sessions, sessionId, session, payload?.bootEpoch); if (!claim.ok) { try { proc.kill(); } catch { /* ignore */ } const supersededError = new Error("Local session superseded by a newer reconnect"); supersededError.code = "NETCATTY_BOOT_SUPERSEDED"; throw supersededError; } openTerminalOutputSession(sessionId, event.sender); ptyProcessTree.registerPid(sessionId, proc.pid); // Start real-time session log stream if configured. The token returned // by startStream is captured so the corresponding stopStream below only // tears down THIS stream — a stale exit event from a previous session // that reused this sessionId would no-op instead of killing a freshly // started stream after a "Restart" reconnect (issue #916). let logStreamToken = null; if (payload?.sessionLog?.enabled && payload?.sessionLog?.directory) { logStreamToken = sessionLogStreamManager.startStream(sessionId, { hostLabel: "Local", hostname: "localhost", directory: payload.sessionLog.directory, format: payload.sessionLog.format || "txt", timestampsEnabled: Boolean(payload.sessionLog.timestampsEnabled), startTime: Date.now(), }); session.logStreamToken = logStreamToken; } const { bufferData: bufferLocalData, flushPaced: flushLocalPaced, takePendingEntry: takePendingLocal, discard: discardLocal, } = createPtyOutputBuffer((data, meta) => { const contents = electronModule.webContents.fromId(session.webContentsId); emitTerminalSessionData(contents, sessionId, data, { session, cols: session.cols, rows: session.rows, meta, }); }, { onPendingBytesChange: (bytes) => setBufferedOutputBytes(session, bytes), shouldAcceptOutput: () => shouldAcceptSessionOutput(session), }); session.flushPendingData = flushLocalPaced; session.takePendingData = takePendingLocal; session.discardPendingData = discardLocal; // On Windows, node-pty ignores encoding: null and still emits UTF-8 // strings, making raw-byte ZMODEM impossible for local PTY sessions. // Only wire up the sentry on platforms where encoding: null works. if (process.platform !== "win32") { const localDecoder = new StringDecoder("utf8"); const zmodemSentry = createZmodemSentry({ sessionId, onData(buf) { const str = localDecoder.write(buf); if (!str) return; trackSessionIdlePrompt(session, str); bufferLocalData(str); sessionLogStreamManager.appendData(sessionId, str); }, writeToRemote(buf) { try { return proc.write(buf); } catch { return true; } }, getWebContents() { return electronModule.webContents.fromId(session.webContentsId); }, selectUploadFiles: selectZmodemUploadFiles ? () => selectZmodemUploadFiles(session.webContentsId, sessionId) : undefined, selectDownloadDirectory: selectZmodemDownloadDirectory ? () => selectZmodemDownloadDirectory(session.webContentsId, sessionId) : undefined, label: "Local", }); session.zmodemSentry = zmodemSentry; proc.onData((data) => { if (sessions.get(sessionId) !== session) return; if (!shouldProcessSessionOutput(session, zmodemSentry)) return; zmodemSentry.consume(data); }); } else { proc.onData((data) => { if (sessions.get(sessionId) !== session) return; if (!shouldProcessSessionOutput(session)) return; trackSessionIdlePrompt(session, data); bufferLocalData(data); sessionLogStreamManager.appendData(sessionId, data); }); } let localExitFinalized = false; proc.onExit((evt) => { const finalizeExit = () => { if (localExitFinalized) return; localExitFinalized = true; sessionLogStreamManager.stopStream(sessionId, logStreamToken); if (sessions.get(sessionId) !== session) return; ptyProcessTree.unregisterPid(sessionId); sessions.delete(sessionId); if (session.closed) return; // Signal present = killed externally (show disconnected UI). // No signal = the process exited and the renderer decides whether to // auto-close based on the reported exit code. const reason = evt.signal ? "error" : "exited"; fanoutSessionLifecycleEvent( sessionId, session.webContentsId, "netcatty:exit", { sessionId, ...evt, reason }, session._terminalSessionGeneration, ); }; flushLocalPaced(finalizeExit); }); return { sessionId }; } /** * Start a Telnet session using native Node.js net module */ const { createTelnetSessionApi } = require("./terminalBridge/telnetSession.cjs"); const telnetSessionApi = createTelnetSessionApi({ get sessions() { return sessions; }, get electronModule() { return electronModule; }, net, randomUUID, StringDecoder, iconv, Buffer, console, setTimeout, clearTimeout, normalizeTerminalEncoding, encodeTerminalInput, createTelnetAutoLogin, telnetProtocol, createPtyOutputBuffer, sessionLogStreamManager, createZmodemSentry, ptyProcessTree, enableTcpNoDelay, trackSessionIdlePrompt, stripAnsi, clearPendingAutomatedWrites, openTerminalOutputSession, closeTerminalOutputSession, get selectZmodemUploadFiles() { return selectZmodemUploadFiles; }, get selectZmodemDownloadDirectory() { return selectZmodemDownloadDirectory; }, }); const { startTelnetSession } = telnetSessionApi; /** * Resolve Netcatty's bundled bare `mosh-client` binary. * * Returns the absolute path or null. */ const { createMoshSessionApi } = require("./terminalBridge/moshSession.cjs"); const moshSessionApi = createMoshSessionApi({ get sessions() { return sessions; }, get electronModule() { return electronModule; }, os, fs, net, path, pty, iconv, Buffer, StringDecoder, process, console, setTimeout, clearTimeout, setInterval, clearInterval, randomUUID, execFileAsync, ptyProcessTree, sessionLogStreamManager, stripAnsi, trackSessionIdlePrompt, createZmodemSentry, moshHandshake, tempDirBridge, createPtyOutputBuffer, enableTcpNoDelay, normalizeTerminalEncoding, resolvePosixExecutable, findExecutable, isExecutableFile, openTerminalOutputSession, closeTerminalOutputSession, get selectZmodemUploadFiles() { return selectZmodemUploadFiles; }, get selectZmodemDownloadDirectory() { return selectZmodemDownloadDirectory; }, ensureMoshStatsConnection: (...args) => require("./sshBridge.cjs").ensureMoshStatsConnection(...args), getAvailableAgentSocket: getNativeOpenSshAgentSocket, getAvailableForwardingAgentSocket: getNativeOpenSshForwardingAgentSocket, prepareSystemSshAgentForAuth, bundledMoshClient: (...args) => bundledMoshClient(...args), }); const { resolveBareMoshClient, addBundledMoshRuntimeEnv, createMoshUtf8Decoder, buildMoshSshAuthArgs, cleanupMoshAuthTempFiles, startMoshSessionViaHandshake, swapToMoshClient, resolveLangFromCharsetForMosh, startMoshSession, } = moshSessionApi; /** * EternalTerminal session API. `et` is a self-contained client that performs * its own SSH bootstrap + ET protocol handshake, so Netcatty just spawns the * bundled `et` binary as a PTY (no Node handshake wrapper like Mosh needs). */ const { createEtSessionApi } = require("./terminalBridge/etSession.cjs"); const etSessionApi = createEtSessionApi({ get sessions() { return sessions; }, get electronModule() { return electronModule; }, os, fs, path, pty, process, console, randomUUID, execFile, execFileSync, StringDecoder, sessionLogStreamManager, tempDirBridge, createZmodemSentry, trackSessionIdlePrompt, createPtyOutputBuffer, findExecutable, openTerminalOutputSession, closeTerminalOutputSession, getAvailableAgentSocket: getNativeOpenSshAgentSocket, getAvailableForwardingAgentSocket: getNativeOpenSshForwardingAgentSocket, prepareSystemSshAgentForAuth, get selectZmodemUploadFiles() { return selectZmodemUploadFiles; }, get selectZmodemDownloadDirectory() { return selectZmodemDownloadDirectory; }, bundledEtClient: (...args) => bundledEtClient(...args), }); const { startEtSession, execOnEtSession, cleanupStaleEtTempDirs, cleanupSessionExternalAuthArtifacts, } = etSessionApi; /** * List available serial ports (hardware only) */ async function listSerialPorts() { try { const ports = await SerialPort.list(); return ports.map(port => ({ path: port.path, manufacturer: port.manufacturer || '', serialNumber: port.serialNumber || '', vendorId: port.vendorId || '', productId: port.productId || '', pnpId: port.pnpId || '', type: 'hardware', })); } catch (err) { console.error("[Serial] Failed to list ports:", err.message); return []; } } /** * Start a serial port session (supports both hardware serial ports and PTY devices) * Note: SerialPort library can open PTY devices directly, they just won't appear in list() */ async function startSerialSession(event, options) { const sessionId = options.sessionId || randomUUID(); const portPath = options.path; const baudRate = options.baudRate || 115200; const dataBits = options.dataBits || 8; const stopBits = options.stopBits || 1; const parity = options.parity || 'none'; const flowControl = options.flowControl || 'none'; console.log(`[Serial] Starting connection to ${portPath} at ${baudRate} baud`); return new Promise((resolve, reject) => { // Token for the log stream we open on this connection. Captured here so // the close/error handlers can pass it to stopStream and avoid // tearing down a freshly started stream after a "Restart" reconnect on // the same sessionId (issue #916). let logStreamToken = null; const { registerPendingBootAbort, clearPendingBootAbort, } = require("./sessionBootEpoch.cjs"); const pendingBootAbort = registerPendingBootAbort(sessionId, options?.bootEpoch); let settled = false; const settleReject = (err) => { if (settled) return; settled = true; clearPendingBootAbort(sessionId, pendingBootAbort); reject(err); }; const settleResolve = (value) => { if (settled) return; settled = true; clearPendingBootAbort(sessionId, pendingBootAbort); resolve(value); }; try { const serialPort = new SerialPort({ path: portPath, baudRate: baudRate, dataBits: dataBits, stopBits: stopBits, parity: parity, rtscts: flowControl === 'rts/cts', xon: flowControl === 'xon/xoff', xoff: flowControl === 'xon/xoff', autoOpen: false, }); const abortPendingOpen = () => { try { serialPort.close(); } catch { /* ignore */ } const supersededError = new Error("Connection superseded by a newer reconnect"); supersededError.code = "NETCATTY_BOOT_SUPERSEDED"; settleReject(supersededError); }; if (pendingBootAbort.signal.aborted) { abortPendingOpen(); return; } pendingBootAbort.signal.addEventListener("abort", abortPendingOpen, { once: true }); serialPort.open((err) => { if (settled) { try { serialPort.close(); } catch { /* ignore */ } return; } if (pendingBootAbort.signal.aborted) { abortPendingOpen(); return; } if (err) { console.error(`[Serial] Failed to open port ${portPath}:`, err.message); settleReject(new Error(`Failed to open serial port: ${err.message}`)); return; } console.log(`[Serial] Connected to ${portPath}`); const initialSerialEncoding = normalizeTerminalEncoding(options.charset); const serialDecoderRef = { current: iconv.getDecoder(initialSerialEncoding) }; const session = { serialPort, type: 'serial', protocol: 'serial', shellKind: 'raw', encoding: initialSerialEncoding, // Kept for backward compatibility with aiBridge / mcpServerBridge // which read session.serialEncoding for exec calls. serialEncoding: initialSerialEncoding, decoderRef: serialDecoderRef, webContentsId: event.sender.id, }; { const { claimSessionSlot } = require("./sessionBootEpoch.cjs"); const claim = claimSessionSlot(sessions, sessionId, session, options?.bootEpoch); if (!claim.ok) { try { serialPort.close(); } catch { /* ignore */ } settleReject(new Error("Connection superseded by a newer reconnect")); return; } } openTerminalOutputSession(sessionId, event.sender); // Start real-time session log stream if configured if (options.sessionLog?.enabled && options.sessionLog?.directory) { logStreamToken = sessionLogStreamManager.startStream(sessionId, { hostLabel: options.label || portPath, hostname: portPath, directory: options.sessionLog.directory, format: options.sessionLog.format || "txt", timestampsEnabled: Boolean(options.sessionLog.timestampsEnabled), startTime: Date.now(), }); session.logStreamToken = logStreamToken; } const serialZmodemSentry = createZmodemSentry({ sessionId, onData(buf) { const decoded = serialDecoderRef.current.write(buf); if (!decoded) return; const contents = electronModule.webContents.fromId(session.webContentsId); emitTerminalSessionData(contents, sessionId, decoded, { session, cols: session.cols, rows: session.rows, }); sessionLogStreamManager.appendData(sessionId, decoded); }, writeToRemote(buf) { try { return serialPort.write(buf); } catch { return true; } }, getWebContents() { return electronModule.webContents.fromId(session.webContentsId); }, selectUploadFiles: selectZmodemUploadFiles ? () => selectZmodemUploadFiles(session.webContentsId, sessionId) : undefined, selectDownloadDirectory: selectZmodemDownloadDirectory ? () => selectZmodemDownloadDirectory(session.webContentsId, sessionId) : undefined, label: "Serial", }); session.zmodemSentry = serialZmodemSentry; serialPort.on('data', (data) => { if (sessions.get(sessionId) !== session) return; if (session.ymodemActive) return; if (!shouldProcessSessionOutput(session, serialZmodemSentry)) return; // data is already Buffer from serialport — feed to sentry serialZmodemSentry.consume(data); }); let serialExitFinalized = false; const finalizeSerialExit = ({ exitCode, error, reason }) => { if (serialExitFinalized || sessions.get(sessionId) !== session) return; serialExitFinalized = true; session.zmodemSentry?.cancel(); session.ymodemAbortController?.abort(); sessionLogStreamManager.stopStream(sessionId, logStreamToken); const primaryId = session.webContentsId; ptyProcessTree.unregisterPid(sessionId); sessions.delete(sessionId); if (session.closed) return; fanoutSessionLifecycleEvent( sessionId, primaryId, "netcatty:exit", { sessionId, exitCode, ...(error ? { error } : {}), reason }, session._terminalSessionGeneration, ); }; serialPort.on('error', (err) => { console.error(`[Serial] Port error: ${err.message}`); finalizeSerialExit({ exitCode: 1, error: err.message, reason: "error" }); }); serialPort.on('close', () => { console.log(`[Serial] Port closed`); finalizeSerialExit({ exitCode: 0, reason: "closed" }); }); settleResolve({ sessionId }); }); } catch (err) { console.error("[Serial] Failed to start serial session:", err.message); settleReject(err); } }); } /** * Write data to a session */ function cancelActiveYmodemSession(session) { if (!session?.ymodemActive) return; void sendYmodemCancel(session.serialPort); session.ymodemAbortController?.abort(); } function pauseSshOutputForInterrupt(session, trace) { const stream = session?.stream; if (!stream || typeof stream.pause !== "function") return false; const flowState = session.flowState; let alreadyPaused = Boolean(flowState?.appliedPause || flowState?.rendererPaused); try { if (typeof stream.isPaused === "function") { alreadyPaused = alreadyPaused || stream.isPaused(); } } catch { // Treat unreadable pause state as not paused; a best-effort pause is fine. } if (alreadyPaused) return false; logTerminalInterruptDebug("interrupt-output-pause-before-write-start", { session: getSessionSnapshot(session), }, trace); try { stream.pause(); logTerminalInterruptDebug("interrupt-output-pause-before-write-done", { session: getSessionSnapshot(session), }, trace); return true; } catch (err) { logTerminalInterruptDebug("interrupt-output-pause-before-write-failed", { error: err?.message || String(err), code: err?.code, session: getSessionSnapshot(session), }, trace); return false; } } function clearPendingAutomatedWrites(session) { const timers = session?.pendingAutomatedWriteTimers; if (!Array.isArray(timers) || timers.length === 0) return; for (const timer of timers) clearTimeout(timer); session.pendingAutomatedWriteTimers = []; } function splitTerminalInputIntoLineWrites(data) { if (typeof data !== "string") return [data]; const chunks = []; let line = ""; for (let index = 0; index < data.length; index += 1) { const char = data[index]; if (char === "\r" || char === "\n") { if (char === "\r" && data[index + 1] === "\n") index += 1; chunks.push(`${line}\r`); line = ""; continue; } line += char; } if (line.length > 0) chunks.push(line); return chunks.length > 0 ? chunks : [data]; } function getAutomatedLineDelayMs(payload) { if (!payload?.automated) return 0; const lineDelayMs = Number(payload.lineDelayMs); return Number.isFinite(lineDelayMs) && lineDelayMs > 0 ? Math.min(lineDelayMs, 2000) : 0; } function shouldBlockSessionInput(session, data) { if (session.ymodemActive) { if (data === '\x03') { cancelActiveYmodemSession(session); } return true; } // During ZMODEM transfer, block terminal input (Ctrl+C cancels the transfer) if (session.zmodemSentry?.isActive()) { if (data === '\x03') { session.zmodemSentry.cancel(); } return true; } return false; } function writeToSessionNow(payload, data, logRewrite = payload.logRewrite) { const session = sessions.get(payload.sessionId); const trace = payload.interruptTrace || null; if (!session) { logTerminalInterruptDebug("write-session-missing", { sessionId: payload.sessionId, dataCode: data === "\x03" ? "ETX" : undefined, }, trace); return; } if (shouldBlockSessionInput(session, data)) { logTerminalInterruptDebug("write-session-blocked-by-transfer", { sessionId: payload.sessionId, dataCode: data === "\x03" ? "ETX" : undefined, session: getSessionSnapshot(session), }, trace); return; } if (data !== "\x03" && !payload.automated && !isTerminalReportSequence(data)) { disarmTerminalInterruptOutputGate(session); } try { if (session.type === 'telnet-native' && !payload.automated) { session.autoLogin?.handleUserInput(); } // Encode keystrokes with the SAME charset the output path decodes with so // input and output stay symmetric on non-UTF-8 devices (issue #1216). // session.encoding is the normalized iconv identifier; it is only set on // sessions whose output is iconv-decoded (SSH / telnet / serial). Mosh and // local PTY leave it unset, so encodeTerminalInput returns the original // UTF-8 string for them. For UTF-8 it also returns the string unchanged, so // the transport's native string serialization keeps handling that case. sessionLogStreamManager.registerSudoAutofillInput(payload.sessionId, data); sessionLogStreamManager.registerProgrammaticCommandLogRewrite(payload.sessionId, logRewrite); const inputData = session.type === 'telnet-native' ? telnetProtocol.normalizeNvtNewlines(data) : data; const outgoing = encodeTerminalInput(inputData, session.encoding); if (session.stream) { const shouldLogInterruptWrite = data === "\x03" || trace; if (shouldLogInterruptWrite) { logTerminalInterruptDebug("ssh-stream-write-start", { outgoingBytes: Buffer.isBuffer(outgoing) ? outgoing.length : Buffer.byteLength(String(outgoing)), dataCode: data === "\x03" ? "ETX" : undefined, session: getSessionSnapshot(session), }, trace); } const writeResult = session.stream.write(outgoing); if ( session.blockUntargetedCwdProbe && !payload.automated && payload.sensitive !== true && !isTerminalReportSequence(data) && /[\r\n]/.test(String(data || "")) ) { // Arm recovery only after this generation's interactive stream really // accepted a submitted user command. Blocked transfers and failed or // superseded async input never reach this point. session.pendingCwdRecoveryAfterUserCommand = true; } if (shouldLogInterruptWrite) { logTerminalInterruptDebug("ssh-stream-write-done", { writeResult, session: getSessionSnapshot(session), }, trace); } } else if (session.proc) { session.proc.write(outgoing); } else if (session.socket) { // Telnet only: any 0xFF byte going out the wire must be doubled, or // the peer will treat it as the start of an IAC command sequence and // eat the next byte (RFC 854 §"Data Stream"). UTF-8 keyboard input // never produces 0xFF, but paste of binary content and some legacy // encodings do. Cheap no-op when there is no 0xFF. let wireData = outgoing; if (session.type === 'telnet-native' && session.telnetProtocolActive) { if (typeof wireData === 'string') { wireData = Buffer.from(wireData, 'utf8'); } wireData = telnetProtocol.escapeIacForWire(wireData); } session.socket.write(wireData); } else if (session.serialPort) { session.serialPort.write(outgoing); } } catch (err) { logTerminalInterruptDebug("write-session-error", { sessionId: payload.sessionId, error: err?.message || String(err), code: err?.code, session: getSessionSnapshot(session), }, trace); if (err.code !== 'EPIPE' && err.code !== 'ERR_STREAM_DESTROYED') { console.warn("Write failed", err); } } } function writeToSessionWithInterception( payload, data, logRewrite = payload.logRewrite, expectedSession = sessions.get(payload.sessionId), ) { const bypass = payload?.sensitive === true || isTerminalReportSequence(data); const hasInterceptor = Boolean( terminalDataPipeline?.interceptInput && terminalDataPipeline.has?.(payload.sessionId, "input"), ); const previous = terminalInputPipelineBarriers.get(payload.sessionId); if (!hasInterceptor && !previous) { writeToSessionNow(payload, data, logRewrite); return; } const writeIfCurrent = (nextData) => { const current = sessions.get(payload.sessionId); if (!current || current !== expectedSession || current.closed) return; writeToSessionNow(payload, nextData, logRewrite); }; const write = async () => { if (!hasInterceptor) { writeIfCurrent(data); return; } try { const transformed = await terminalDataPipeline.interceptInput(payload.sessionId, data, { sensitive: payload?.sensitive === true, bypass, }); writeIfCurrent(transformed); } catch { writeIfCurrent(data); } }; const operation = previous ? previous.then(write, write) : Promise.resolve().then(write); terminalInputPipelineBarriers.set(payload.sessionId, operation); void operation.finally(() => { if (terminalInputPipelineBarriers.get(payload.sessionId) === operation) { terminalInputPipelineBarriers.delete(payload.sessionId); } }); } function writeToSession(event, payload) { const session = sessions.get(payload.sessionId); if (!session) return; try { reportOpenedSessionActivity?.({ sessionId: payload.sessionId, phase: "touch" }); } catch { // Activity tracking must not interfere with terminal input. } if (!payload.automated && !isTerminalReportSequence(payload.data)) { clearPendingAutomatedWrites(session); } if (shouldBlockSessionInput(session, payload.data)) { return; } const lineDelayMs = getAutomatedLineDelayMs(payload); const lineChunks = lineDelayMs > 0 ? splitTerminalInputIntoLineWrites(payload.data) : [payload.data]; if (lineDelayMs > 0 && lineChunks.length > 1) { clearPendingAutomatedWrites(session); session.pendingAutomatedWriteTimers = []; lineChunks.forEach((chunk, index) => { const sendChunk = () => { const current = sessions.get(payload.sessionId); if (!current) return; writeToSessionWithInterception( { ...payload, lineDelayMs: undefined }, chunk, index === 0 ? payload.logRewrite : undefined, current, ); }; if (index === 0) { sendChunk(); return; } const timer = setTimeout(sendChunk, index * lineDelayMs); session.pendingAutomatedWriteTimers.push(timer); }); return; } writeToSessionWithInterception(payload, payload.data, payload.logRewrite, session); } function drainPendingOutputForInterrupt(sessionId, session, trace) { if (typeof session?.takePendingData !== "function") return; const pendingEntry = session.takePendingData(); const pending = typeof pendingEntry === "string" ? pendingEntry : pendingEntry?.data; const pendingMeta = typeof pendingEntry === "string" ? undefined : pendingEntry?.meta; if (!pending) return; const output = filterTerminalInterruptOutput(session, pending); if (!output.accepted || output.droppedBytes > 0) { logTerminalInterruptDebug("interrupt-pending-output-filtered", { session: getSessionSnapshot(session), droppedBytes: output.droppedBytes, reason: output.reason, accepted: output.accepted, }, trace); } if (!output.accepted || !output.data) { stashPendingInterruptOutputMeta(session, pendingMeta); return; } const outputMeta = takePendingInterruptOutputMeta(session, pendingMeta); const contents = electronModule.webContents.fromId(session.webContentsId); emitTerminalSessionData(contents, sessionId, output.data, { session, cols: session.cols, rows: session.rows, meta: outputMeta, }); } function interruptSession(event, payload) { const session = sessions.get(payload.sessionId); const trace = normalizeTrace(payload); if (!session) { logTerminalInterruptDebug("interrupt-session-missing", { sessionId: payload.sessionId, senderId: event?.sender?.id, }, trace); return; } rememberInterruptTrace(session, trace); resetTerminalFlowAckSample(session); logTerminalInterruptDebug("interrupt-session-received", { sessionId: payload.sessionId, senderId: event?.sender?.id, rendererPriority: trace?.rendererPriority, session: getSessionSnapshot(session), }, trace); clearPendingAutomatedWrites(session); const shouldDrainOldOutput = shouldArmTerminalInterruptOutputGate(session); const pausedForInterrupt = shouldDrainOldOutput ? pauseSshOutputForInterrupt(session, trace) : false; if (shouldDrainOldOutput) { armTerminalInterruptOutputGate(session); logTerminalInterruptDebug("interrupt-output-drain-armed", { session: getSessionSnapshot(session), }, trace); drainPendingOutputForInterrupt(payload.sessionId, session, trace); } logTerminalInterruptDebug("interrupt-clear-flow-start", { session: getSessionSnapshot(session), }, trace); clearSessionFlowState(session, { resume: !shouldDrainOldOutput }); logTerminalInterruptDebug("interrupt-clear-flow-done", { session: getSessionSnapshot(session), }, trace); writeToSessionNow({ sessionId: payload.sessionId, interruptTrace: trace }, "\x03"); if (shouldDrainOldOutput || pausedForInterrupt) { queueMicrotask(() => { if (sessions.get(payload.sessionId) !== session) return; try { session.stream?.resume?.(); logTerminalInterruptDebug("interrupt-output-resumed-after-write", { session: getSessionSnapshot(session), }, trace); } catch (err) { logTerminalInterruptDebug("interrupt-output-resume-after-write-failed", { error: err?.message || String(err), code: err?.code, session: getSessionSnapshot(session), }, trace); } }); } } async function sendSerialYmodem(_event, payload) { const session = sessions.get(payload?.sessionId); if (!session || !session.serialPort || session.type !== 'serial') { return { success: false, error: "YMODEM send requires an active serial session" }; } if (session.ymodemActive) { return { success: false, error: "A YMODEM transfer is already in progress" }; } if (session.zmodemSentry?.isActive()) { return { success: false, error: "Another serial file transfer is already in progress" }; } if (!payload?.filePath || typeof payload.filePath !== "string") { return { success: false, error: "No file selected" }; } const abortController = new AbortController(); session.ymodemActive = true; session.ymodemAbortController = abortController; try { const result = await sendYmodemFile(session.serialPort, payload.filePath, { abortSignal: abortController.signal, timeoutMs: Number.isFinite(payload.timeoutMs) ? payload.timeoutMs : undefined, }); return { success: true, ...result }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : String(error), code: error?.code, }; } finally { session.ymodemActive = false; session.ymodemAbortController = null; } } async function receiveSerialYmodem(_event, payload) { const session = sessions.get(payload?.sessionId); if (!session || !session.serialPort || session.type !== 'serial') { return { success: false, error: "YMODEM receive requires an active serial session" }; } if (session.ymodemActive) { return { success: false, error: "A YMODEM transfer is already in progress" }; } if (session.zmodemSentry?.isActive()) { return { success: false, error: "Another serial file transfer is already in progress" }; } if (!payload?.destinationDir || typeof payload.destinationDir !== "string") { return { success: false, error: "No destination directory selected" }; } const abortController = new AbortController(); session.ymodemActive = true; session.ymodemAbortController = abortController; try { const result = await receiveYmodemFiles(session.serialPort, { destinationDir: payload.destinationDir, abortSignal: abortController.signal, timeoutMs: Number.isFinite(payload.timeoutMs) ? payload.timeoutMs : undefined, }); return { success: true, ...result }; } catch (error) { if (error?.code !== "YMODEM_CANCELLED" && error?.code !== "YMODEM_REMOTE_CANCELLED") { await sendYmodemCancel(session.serialPort); } return { success: false, error: error instanceof Error ? error.message : String(error), code: error?.code, }; } finally { session.ymodemActive = false; session.ymodemAbortController = null; } } /** * Pause or resume a session's source stream for output back-pressure. * The renderer asks for this when its write backlog crosses a watermark, so a * flooding source can't outrun the terminal renderer. Works across session * kinds: ssh2 channel (stream), node-pty (proc), telnet socket, serial port — * all expose pause()/resume(). */ function setSessionFlowPaused(event, payload) { const session = sessions.get(payload.sessionId); if (!session) { logTerminalInterruptDebug("flow-paused-session-missing", { sessionId: payload.sessionId, paused: Boolean(payload.paused), senderId: event?.sender?.id, }, normalizeTrace(payload)); return; } const trace = getRecentInterruptTrace(session); setRendererFlowPaused(session, payload.paused); if (!payload.paused) { session.flushPendingData?.(); } if (trace) { logTerminalFlowPauseSample(session, { sessionId: payload.sessionId, paused: Boolean(payload.paused), senderId: event?.sender?.id, }); } } function applyEffectiveSessionFlowPause(event, payload, terminalWorkerManager) { if (terminalWorkerManager) { terminalWorkerManager.send("netcatty:flow", { ...payload, _flowArbitrated: true }, { webContentsId: event?.sender?.id, }); return; } setSessionFlowPaused(event, payload); } async function applyEffectiveSessionFlowPauseAndWait(event, payload, terminalWorkerManager) { if (terminalWorkerManager) { await terminalWorkerManager.request("netcatty:terminal:setFlowPausedAndWait", { ...payload, _flowArbitrated: true, }, { webContentsId: event?.sender?.id, }); return; } setSessionFlowPaused(event, payload); } function registerFlowPauseSenderCleanup(event, terminalWorkerManager) { const sender = event?.sender; if (!sender || trackedFlowPauseSenders.has(sender) || typeof sender.once !== "function") return; trackedFlowPauseSenders.add(sender); const senderId = sender.id; sender.once("destroyed", () => { for (const change of terminalFlowPauseArbiter.clearSender(senderId)) { applyEffectiveSessionFlowPause( { sender: { id: senderId } }, change, terminalWorkerManager, ); } }); } function applyRendererFlowPauseRequest(event, payload, terminalWorkerManager) { const sessionId = typeof payload?.sessionId === "string" ? payload.sessionId : ""; if (!sessionId) return; registerFlowPauseSenderCleanup(event, terminalWorkerManager); const paused = terminalFlowPauseArbiter.setDirectPaused( sessionId, event?.sender?.id, Boolean(payload.paused), ); applyEffectiveSessionFlowPause(event, { ...payload, sessionId, paused }, terminalWorkerManager); } function ackSessionFlow(event, payload) { const session = sessions.get(payload.sessionId); if (!session) return; logTerminalFlowAckSample(session, { sessionId: payload.sessionId, bytes: Number(payload.bytes), senderId: event?.sender?.id, }); trackAck(session, Number(payload.bytes), payload.sessionId); session.flushPendingData?.(); } /** * Resize a session terminal */ function resizeSession(event, payload) { const session = sessions.get(payload.sessionId); if (!session) return; if (Number.isFinite(payload.cols)) session.cols = payload.cols; if (Number.isFinite(payload.rows)) session.rows = payload.rows; try { if (session.stream) { session.stream.setWindow(payload.rows, payload.cols, 0, 0); } else if (session.proc) { session.proc.resize(payload.cols, payload.rows); } else if (session.socket && session.type === 'telnet-native') { session.cols = payload.cols; session.rows = payload.rows; // Only push a NAWS update once Telnet is active and the peer has enabled // NAWS with DO NAWS; partial console wrappers may leak SB payload bytes. if (session.telnetProtocolActive) { session.sendTelnetWindowSize?.(); } } } catch (err) { if (err.code !== 'EPIPE' && err.code !== 'ERR_STREAM_DESTROYED') { console.warn("Resize failed", err); } } } /** * Sync ConPTY's internal buffer after the renderer clears the xterm viewport. * Without this, Windows shells (especially PowerShell) keep the old cursor * row and the next Enter reprints a tall blank gap. No-op on SSH/Unix PTYs. */ function clearSessionPtyBuffer(event, payload) { const session = sessions.get(payload?.sessionId); if (!session?.proc || typeof session.proc.clear !== "function") return; try { session.proc.clear(); } catch (err) { if (err?.code !== "EPIPE" && err?.code !== "ERR_STREAM_DESTROYED") { console.warn("PTY clear failed", err); } } } function shouldRevokeOpenedSessionOwnership(payload) { // Disconnect / reconnect tear down the transport but keep the tab and session // id. Those closes must not drop host_open ownership. return payload?.retainOwnership !== true; } function reportOpenedSessionClosed(sessionId, payload) { if (!shouldRevokeOpenedSessionOwnership(payload)) return; try { reportOpenedSessionActivity?.({ sessionId, phase: "closed" }); } catch { // Ownership cleanup must not interfere with session teardown. } } /** * Close a session */ function closeSession(event, payload) { const session = sessions.get(payload.sessionId); const { abortPendingBoot, forgetBootEpoch, hasNewerBootEpoch, hasPendingBootAfter, sessionMatchesBootEpoch, } = require("./sessionBootEpoch.cjs"); const passphraseHandler = require("./passphraseHandler.cjs"); // Abort in-flight SSH passphrase prompts even before a registry slot exists. abortPendingBoot(payload.sessionId, payload?.bootEpoch); passphraseHandler.cancelPassphraseRequestsForSession?.( payload.sessionId, "session-closed", payload?.bootEpoch, ); if (session && !sessionMatchesBootEpoch(session, payload?.bootEpoch)) { return { skipped: true, reason: "boot-epoch-mismatch" }; } if (!session) { // A direct-mode backend can remove a naturally exited session before the // renderer closes its tab. That later close is still authoritative for AI // ownership, unless it belongs to an older boot than a pending reconnect. if ( hasPendingBootAfter(payload.sessionId, payload?.bootEpoch) || hasNewerBootEpoch(payload.sessionId, payload?.bootEpoch) ) { return { skipped: true, reason: "boot-epoch-mismatch" }; } releaseAttachedSessionState(payload.sessionId); reportOpenedSessionClosed(payload.sessionId, payload); forgetBootEpoch(payload.sessionId, payload?.bootEpoch); return { closed: false, reason: "missing" }; } releaseAttachedSessionState(payload.sessionId); terminalFlowPauseArbiter.clearSession(payload.sessionId); session.closed = true; fanoutSessionLifecycleEvent( payload.sessionId, session.webContentsId, "netcatty:exit", { sessionId: payload.sessionId, exitCode: 0, reason: "closed" }, session._terminalSessionGeneration, ); terminalInputPipelineBarriers.delete(payload.sessionId); closeTerminalOutputSession(payload.sessionId); try { clearSessionFlowState(session, { resume: false }); cancelActiveYmodemSession(session); clearPendingAutomatedWrites(session); session.zmodemSentry?.cancel(); session.discardPendingData?.(); cleanupSessionExternalAuthArtifacts(session); session.releaseTelnetGeneration?.(); if (session.stream) { // Snapshot multiplexing state *before* closing the channel: closing the // stream can synchronously fire its "close" handler, which nulls // session.connRef (and may already release the shared connection). Reading // session.connRef afterwards would then wrongly fall into the legacy path // and end the shared connection a second time. const isMultiplexed = !!session.connRef; // Always close this session's own shell channel. session.stream.close(); if (isMultiplexed) { // Multiplexed SSH shell (issue #1204): several tabs may share one // authenticated connection. Closing this tab must only tear the shared // transport (and jump-host chain) down once the last channel is gone, // so route teardown through the reference-counted descriptor instead of // ending the connection directly. releaseConnectionRef is idempotent and // ends the chain connections itself when the count reaches zero — so it // is safe even if the stream "close" handler above already released. releaseConnectionRef(session); } else { // Legacy / non-multiplexed path: this session owns its connection. session.conn?.end(); if (session.chainConnections) { for (const c of session.chainConnections) { try { c.end(); } catch {} } } } } else if (session.proc) { session.proc.kill(); // Mosh sessions may also carry a companion ssh2 connection opened // lazily for host-info stats (issue #1198). ET can use the same pattern. // Close companions here to avoid leaking them. try { session.moshStatsConn?.end(); } catch { /* ignore */ } try { session.etStatsConn?.end(); } catch { /* ignore */ } } else if (session.socket) { session.socket.destroy(); } else if (session.serialPort) { session.serialPort.close(); } else if (session.chainConnections) { // Non-stream session still carrying a jump-host chain (defensive). for (const c of session.chainConnections) { try { c.end(); } catch {} } } } catch (err) { console.warn("Close failed", err); } finally { cleanupMoshAuthTempFiles(session.moshAuthTempFiles); } ptyProcessTree.unregisterPid(payload.sessionId); sessions.delete(payload.sessionId); forgetBootEpoch(payload.sessionId, payload?.bootEpoch); reportOpenedSessionClosed(payload.sessionId, payload); return { closed: true }; } /** * Set terminal decoder encoding for an active telnet or serial session. * SSH sessions are handled by sshBridge's own setEncoding IPC — this one * only responds to sessions that carry a decoderRef (telnet + serial). */ function setSessionEncoding(_event, { sessionId, encoding }) { const session = sessions?.get(sessionId); if (!session || !session.decoderRef) { return { ok: false, encoding: encoding || 'utf-8' }; } const enc = normalizeTerminalEncoding(encoding); if (!iconv.encodingExists(enc)) { return { ok: false, encoding: enc }; } session.encoding = enc; // Keep serialEncoding mirror in sync so aiBridge / mcpServerBridge exec // calls pick up the new encoding too. if (session.type === 'serial') { session.serialEncoding = enc; } // iconv stateful decoders carry partial-byte state from the previous // encoding, so swap in a fresh decoder rather than reconfiguring. session.decoderRef.current = iconv.getDecoder(enc); return { ok: true, encoding: enc }; } function getTelnetEchoMode(_event, { sessionId }) { const mode = sessions?.get(sessionId)?.telnetEchoMode; return mode ? { success: true, ...mode } : { success: false, error: "Telnet echo mode unavailable" }; } /** * Register IPC handlers for terminal operations */ function registerWorkerHandle(ipcMain, terminalWorkerManager, channel) { ipcMain.handle(channel, (event, payload) => { return terminalWorkerManager.request(channel, payload, { webContentsId: event?.sender?.id, }); }); } function registerWorkerSend(ipcMain, terminalWorkerManager, channel) { ipcMain.on(channel, (event, payload) => { terminalWorkerManager.send(channel, payload, { webContentsId: event?.sender?.id, }); }); } function registerHandlers(ipcMain, options = {}) { const terminalWorkerManager = options.terminalWorkerManager || null; // Attach/observe popups rebind display routing in the main process even when // PTY I/O is owned by the terminal worker. Always register these handlers. ipcMain.handle("netcatty:terminal:rebindOutput", (event, payload) => rebindTerminalSessionOutput(event, payload, terminalWorkerManager)); ipcMain.handle("netcatty:terminal:restoreOutput", (event, payload) => restoreTerminalSessionOutput(event, payload, terminalWorkerManager)); ipcMain.handle("netcatty:terminal:requestSnapshot", (event, payload) => requestTerminalSessionSnapshot(event, payload, terminalWorkerManager)); ipcMain.handle("netcatty:terminal:applySnapshot", (event, payload) => applyTerminalSessionSnapshot(event, payload, terminalWorkerManager)); ipcMain.handle("netcatty:terminal:setFlowPausedAndWait", async (event, payload) => { const sessionId = typeof payload?.sessionId === "string" ? payload.sessionId : ""; if (!sessionId) return { success: false, error: "Missing terminal session" }; if (payload?._flowArbitrated === true && !terminalWorkerManager) { setSessionFlowPaused(event, payload); return { success: true }; } registerFlowPauseSenderCleanup(event, terminalWorkerManager); const paused = terminalFlowPauseArbiter.setDirectPaused( sessionId, event?.sender?.id, Boolean(payload.paused), ); await applyEffectiveSessionFlowPauseAndWait( event, { ...payload, sessionId, paused }, terminalWorkerManager, ); if (!payload?.paused) return { success: true }; return requestTerminalOutputDrain(sessionId, terminalWorkerManager); }); ipcMain.handle("netcatty:terminal:acquireFlowPauseLease", async (event, payload) => { const sessionId = typeof payload?.sessionId === "string" ? payload.sessionId : ""; if (!sessionId) return { success: false, error: "Missing terminal session" }; registerFlowPauseSenderCleanup(event, terminalWorkerManager); const lease = terminalFlowPauseArbiter.acquire(sessionId, event?.sender?.id); try { await applyEffectiveSessionFlowPauseAndWait( event, { sessionId, paused: true }, terminalWorkerManager, ); } catch (error) { const rolledBack = terminalFlowPauseArbiter.release( sessionId, event?.sender?.id, lease.leaseId, ); applyEffectiveSessionFlowPause( event, { sessionId, paused: rolledBack.paused }, terminalWorkerManager, ); throw error; } return { success: true, leaseId: lease.leaseId }; }); ipcMain.handle("netcatty:terminal:waitFlowPauseLease", async (event, payload) => { const sessionId = typeof payload?.sessionId === "string" ? payload.sessionId : ""; const leaseId = typeof payload?.leaseId === "string" ? payload.leaseId : ""; if (!terminalFlowPauseArbiter.owns(sessionId, event?.sender?.id, leaseId)) { return { success: false, error: "Invalid terminal flow pause lease" }; } await applyEffectiveSessionFlowPauseAndWait( event, { sessionId, paused: true }, terminalWorkerManager, ); return requestTerminalOutputDrain(sessionId, terminalWorkerManager); }); ipcMain.handle("netcatty:terminal:releaseFlowPauseLease", async (event, payload) => { const sessionId = typeof payload?.sessionId === "string" ? payload.sessionId : ""; const leaseId = typeof payload?.leaseId === "string" ? payload.leaseId : ""; const released = terminalFlowPauseArbiter.release( sessionId, event?.sender?.id, leaseId, { keepPaused: payload?.keepPaused === true }, ); if (!released.success) { return { success: false, error: "Invalid terminal flow pause lease" }; } applyEffectiveSessionFlowPause( event, { sessionId, paused: released.paused }, terminalWorkerManager, ); return { success: true }; }); ipcMain.handle("netcatty:terminal:markAttachClosePrepared", (event, payload) => { const sessionId = typeof payload?.sessionId === "string" ? payload.sessionId : ""; const success = markAttachPopupClosePrepared( payload?.authorization, sessionId, event?.sender?.id, ); return success ? { success: true } : { success: false, error: "Unauthorized attach request" }; }); ipcMain.on("netcatty:terminal:snapshot-response", handleTerminalSessionSnapshotResponse); ipcMain.on("netcatty:terminal:output-drain-response", handleTerminalOutputDrainResponse); ipcMain.on("netcatty:terminal:apply-snapshot-response", handleTerminalSessionApplySnapshotResponse); ipcMain.on("netcatty:terminal:display-ready", (event, payload) => { const sessionId = typeof payload?.sessionId === "string" ? payload.sessionId : ""; const readyMain = findRegisteredMainWebContents(event?.sender?.id); if (!sessionId || readyMain?.id !== event?.sender?.id) return; retryPendingAttachedSessionOutput(sessionId, event.sender.id); }); setRestoreAttachedSessionOutput((sessionId, preferredHomeWebContentsId) => restoreAttachedSessionOutput(sessionId, terminalWorkerManager, preferredHomeWebContentsId)); setAttachHomeLookup((sessionId) => { if (terminalWorkerManager?.getAttachHomeWebContentsId) { return terminalWorkerManager.getAttachHomeWebContentsId(sessionId); } return attachHomeWebContentsIds.get(sessionId) ?? null; }); setFanoutSessionExit((sessionId, primaryWebContentsId, payload) => { terminalFlowPauseArbiter.clearSession(sessionId); fanoutSessionLifecycleEvent(sessionId, primaryWebContentsId, "netcatty:exit", payload); }); if (terminalWorkerManager) { terminalWorkerManager.onSessionClosed?.(({ sessionId }) => { terminalFlowPauseArbiter.clearSession(sessionId); }); [ "netcatty:local:start", "netcatty:telnet:start", "netcatty:mosh:start", "netcatty:et:start", "netcatty:serial:start", "netcatty:serial:list", "netcatty:serial:ymodem-send", "netcatty:serial:ymodem-receive", "netcatty:local:defaultShell", "netcatty:local:validatePath", "netcatty:shells:discover", "netcatty:terminal:setEncoding", "netcatty:telnet:getEchoMode", ].forEach((channel) => registerWorkerHandle(ipcMain, terminalWorkerManager, channel)); ipcMain.handle("netcatty:close:await", async (event, payload) => { const result = await terminalWorkerManager.request("netcatty:close:await", payload, { webContentsId: event?.sender?.id, }); // A skipped epoch-mismatch close must not drop the replacement's // flow-pause lease; only clear after the owned epoch was closed (or the // session was already gone / returned without skipped). if (!result?.skipped) { terminalFlowPauseArbiter.clearSession(payload?.sessionId); } return result; }); ipcMain.on("netcatty:write", (event, payload) => { // Session log streams started in the main process (manual/script logs) // sanitize sudo-autofill markers and programmatic command echoes based // on the *input* that produced them. In worker mode the real write // handler runs in the utilityProcess, so mirror the rewrite // registrations into the main-process stream manager before // forwarding. Both calls are no-ops without an active main-process // stream for the session. sessionLogStreamManager.registerSudoAutofillInput(payload?.sessionId, payload?.data); sessionLogStreamManager.registerProgrammaticCommandLogRewrite(payload?.sessionId, payload?.logRewrite); try { reportOpenedSessionActivity?.({ sessionId: payload?.sessionId, phase: "touch" }); } catch { // Activity tracking must not interfere with terminal input. } terminalWorkerManager.send("netcatty:write", payload, { webContentsId: event?.sender?.id, }); }); [ "netcatty:interrupt", "netcatty:resize", "netcatty:pty:clear", "netcatty:flow:ack", ].forEach((channel) => registerWorkerSend(ipcMain, terminalWorkerManager, channel)); ipcMain.on("netcatty:flow", (event, payload) => { if (payload?._flowArbitrated === true) { setSessionFlowPaused(event, payload); } else { applyRendererFlowPauseRequest(event, payload, terminalWorkerManager); } }); ipcMain.on("netcatty:close", (event, payload) => { terminalWorkerManager.send("netcatty:close", payload, { webContentsId: event?.sender?.id, }); // Epoch-scoped closes may be no-ops in the worker; clearing here would // orphan the replacement's pause lease. Unscoped closes still clear // eagerly; owned closes also clear via onSessionClosed. if (!Number.isFinite(payload?.bootEpoch)) { terminalFlowPauseArbiter.clearSession(payload?.sessionId); } }); return; } ipcMain.handle("netcatty:local:start", startLocalSession); ipcMain.handle("netcatty:telnet:start", startTelnetSession); ipcMain.handle("netcatty:mosh:start", startMoshSession); ipcMain.handle("netcatty:et:start", startEtSession); ipcMain.handle("netcatty:serial:start", startSerialSession); ipcMain.handle("netcatty:serial:list", listSerialPorts); ipcMain.handle("netcatty:serial:ymodem-send", sendSerialYmodem); ipcMain.handle("netcatty:serial:ymodem-receive", receiveSerialYmodem); ipcMain.handle("netcatty:local:defaultShell", getDefaultShell); ipcMain.handle("netcatty:local:validatePath", validatePath); ipcMain.handle("netcatty:shells:discover", () => discoverShells()); ipcMain.handle("netcatty:terminal:setEncoding", setSessionEncoding); ipcMain.handle("netcatty:telnet:getEchoMode", getTelnetEchoMode); ipcMain.on("netcatty:write", writeToSession); ipcMain.on("netcatty:interrupt", interruptSession); ipcMain.on("netcatty:resize", resizeSession); ipcMain.on("netcatty:pty:clear", clearSessionPtyBuffer); ipcMain.on("netcatty:flow", (event, payload) => { if (payload?._flowArbitrated === true) { setSessionFlowPaused(event, payload); } else { applyRendererFlowPauseRequest(event, payload, null); } }); ipcMain.on("netcatty:flow:ack", ackSessionFlow); ipcMain.on("netcatty:close", closeSession); ipcMain.handle("netcatty:close:await", closeSession); } /** * Get the default shell for the current platform */ const { createPathValidationApi } = require("./terminalBridge/pathValidation.cjs"); const pathValidationApi = createPathValidationApi({ getDefaultLocalShell, expandHomePath, path, fs, process, console, findExecutable, }); const { getDefaultShell, validatePath } = pathValidationApi; /** * Locate the mosh-client binary bundled by electron-builder via * `extraResources` (see electron-builder.config.cjs and * binaricat/MoshCatty releases). * * Returns an absolute path when the binary is on disk, otherwise null. * In dev / non-packaged runs the path is computed against the project * root so the helper is testable without packaging the app. * * Note this returns the network-protocol `mosh-client`, not the `mosh` * wrapper script. Netcatty drives the SSH bootstrap itself and then * launches this bundled client directly. */ function bundledMoshClient(opts = {}) { const isWin = (opts.platform || process.platform) === "win32"; const basename = isWin ? "mosh-client.exe" : "mosh-client"; // Packaged: /mosh/mosh-client[.exe] const resourcesPath = opts.resourcesPath || process.resourcesPath; if (resourcesPath) { const packaged = path.join(resourcesPath, "mosh", basename); if (fs.existsSync(packaged) && isExecutableFile(packaged)) return packaged; } // Dev fallback: resources/mosh//mosh-client[.exe] under // the project root. Useful for `npm run start` after running // `npm run fetch:mosh` locally. const projectRoot = opts.projectRoot || path.resolve(__dirname, "..", ".."); const platform = opts.platform || process.platform; const arch = opts.arch || process.arch; const candidates = []; if (platform === "darwin") { candidates.push(path.join(projectRoot, "resources", "mosh", "darwin-universal", basename)); } else { candidates.push(path.join(projectRoot, "resources", "mosh", `${platform}-${arch}`, basename)); } for (const c of candidates) { if (fs.existsSync(c) && isExecutableFile(c)) return c; } return null; } /** * Locate the EternalTerminal `et` client bundled by electron-builder via * `extraResources` (see electron-builder.config.cjs and * .github/workflows/build-et-binaries.yml). * * Returns an absolute path when the binary is on disk, otherwise null. In * dev / non-packaged runs the path is computed against the project root so * the helper is testable without packaging the app. * * `et` is a self-contained client that performs its own SSH bootstrap and * EternalTerminal protocol handshake; Netcatty just spawns it as a PTY. */ function bundledEtClient(opts = {}) { const isWin = (opts.platform || process.platform) === "win32"; const basename = isWin ? "et.exe" : "et"; // Packaged: /et/et[.exe] const resourcesPath = opts.resourcesPath || process.resourcesPath; if (resourcesPath) { const packaged = path.join(resourcesPath, "et", basename); if (fs.existsSync(packaged) && isExecutableFile(packaged)) return packaged; } // Dev fallback: resources/et//et[.exe] under the project // root. Useful for `npm run start` after running `npm run fetch:et` locally. const projectRoot = opts.projectRoot || path.resolve(__dirname, "..", ".."); const platform = opts.platform || process.platform; const arch = opts.arch || process.arch; const candidates = []; if (platform === "darwin") { candidates.push(path.join(projectRoot, "resources", "et", "darwin-universal", basename)); } else { candidates.push(path.join(projectRoot, "resources", "et", `${platform}-${arch}`, basename)); } for (const c of candidates) { if (fs.existsSync(c) && isExecutableFile(c)) return c; } return null; } /** * Cleanup all sessions - call before app quit */ function cleanupAllSessions() { console.log(`[Terminal] Cleaning up ${sessions.size} sessions before quit`); for (const [sessionId, session] of sessions) { try { session.zmodemSentry?.cancel(); cancelActiveYmodemSession(session); clearPendingAutomatedWrites(session); clearSessionFlowState(session, { resume: false }); session.discardPendingData?.(); closeTerminalOutputSession(sessionId); cleanupSessionExternalAuthArtifacts(session); session.releaseTelnetGeneration?.(); if (session.stream) { session.stream.close(); session.conn?.end(); } else if (session.proc) { // For node-pty on Windows, we need to kill more gracefully try { session.proc.kill(); } catch (e) { // Ignore errors during cleanup } // Tear down a Mosh stats companion ssh2 connection if one was opened // (issue #1198), and the equivalent ET companion when present. try { session.moshStatsConn?.end(); } catch (e) { /* ignore */ } try { session.etStatsConn?.end(); } catch (e) { /* ignore */ } } else if (session.socket) { session.socket.destroy(); } else if (session.serialPort) { try { session.serialPort.close(); } catch (e) { // Ignore errors during cleanup } } if (session.chainConnections) { for (const c of session.chainConnections) { try { c.end(); } catch {} } } } catch (err) { // Ignore cleanup errors } } for (const [sessionId] of sessions) { ptyProcessTree.unregisterPid(sessionId); } sessions.clear(); terminalOutputChannel?.closeAll?.(); } module.exports = { init, registerHandlers, findExecutable, getDefaultLocalShell, startLocalSession, startTelnetSession, startMoshSession, bundledMoshClient, resolveBareMoshClient, addBundledMoshRuntimeEnv, createMoshUtf8Decoder, startEtSession, execOnEtSession, bundledEtClient, startSerialSession, sendSerialYmodem, receiveSerialYmodem, listSerialPorts, writeToSession, setSessionEncoding, resizeSession, clearSessionPtyBuffer, setSessionFlowPaused, ackSessionFlow, closeSession, interruptSession, cleanupAllSessions, getDefaultShell, validatePath, };