Files
NetMesh/electron/bridges/terminalBridge.cjs

2562 lines
93 KiB
JavaScript
Raw Normal View History

/**
* 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<string, { resolve: (value: any) => 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: <Resources>/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/<platform-arch>/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: <Resources>/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/<platform-arch>/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,
};