985 lines
40 KiB
JavaScript
985 lines
40 KiB
JavaScript
|
|
/* eslint-disable no-undef */
|
||
|
|
const { execFile } = require("node:child_process");
|
||
|
|
const { promisify } = require("node:util");
|
||
|
|
const { emitTerminalSessionData } = require("../emitTerminalSessionData.cjs");
|
||
|
|
const {
|
||
|
|
setBufferedOutputBytes,
|
||
|
|
shouldAcceptSessionOutput,
|
||
|
|
shouldProcessSessionOutput,
|
||
|
|
} = require("../terminalFlowAck.cjs");
|
||
|
|
const { createSshConnExecProbe } = require("../ai/sessionShellKind.cjs");
|
||
|
|
const { orderSshIdentityNames, SSH_KEY_PATTERN } = require("../sshAuthHelper.cjs");
|
||
|
|
const { fanoutSessionExit } = require("../terminalAttachRestore.cjs");
|
||
|
|
const {
|
||
|
|
isUntrustedTerminalInputPrompt,
|
||
|
|
} = require("../../../domain/terminalPromptSecurity.shared.cjs");
|
||
|
|
const {
|
||
|
|
buildAuthoritativeKnownHostsContent,
|
||
|
|
buildExternalHostKeySshOptions,
|
||
|
|
vaultPinsConnectionHosts,
|
||
|
|
} = require("../externalSshHostKeyPolicy.cjs");
|
||
|
|
|
||
|
|
const execFileAsync = promisify(execFile);
|
||
|
|
|
||
|
|
// MoshCatty normally emits this cleanup together with an alternate-screen
|
||
|
|
// exit. Netcatty keeps the primary screen, so restore only terminal modes that
|
||
|
|
// can leak from a full-screen remote program and leave scrollback untouched.
|
||
|
|
const MOSH_PRIMARY_SCREEN_RESET = "\x1b[?1l\x1b[0m\x1b[?25h"
|
||
|
|
+ "\x1b[?1003l\x1b[?1002l\x1b[?1001l\x1b[?1000l"
|
||
|
|
+ "\x1b[?1015l\x1b[?1006l\x1b[?1005l";
|
||
|
|
|
||
|
|
// The interactive SSH bootstrap can paint password prompts, MOTD text, and
|
||
|
|
// mosh-server diagnostics into Netcatty's primary screen. MoshCatty then
|
||
|
|
// reconstructs only the cells present in the remote Mosh state, so untouched
|
||
|
|
// bootstrap cells would remain visible around the new shell. Clear the current
|
||
|
|
// viewport at the successful handoff while preserving primary-screen scrollback.
|
||
|
|
const MOSH_HANDSHAKE_VIEWPORT_RESET = "\x1b[2J\x1b[H";
|
||
|
|
|
||
|
|
function withShellProbeTimeout(promise, timeoutMs) {
|
||
|
|
const ms = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : 3000;
|
||
|
|
let timer = null;
|
||
|
|
return Promise.race([
|
||
|
|
Promise.resolve(promise),
|
||
|
|
new Promise((resolve) => {
|
||
|
|
timer = setTimeout(() => resolve(null), ms);
|
||
|
|
}),
|
||
|
|
]).finally(() => {
|
||
|
|
if (timer) clearTimeout(timer);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
function createMoshSessionApi(ctx) {
|
||
|
|
with (ctx) {
|
||
|
|
function resolveBareMoshClient(_options, opts = {}) {
|
||
|
|
return bundledMoshClient(opts);
|
||
|
|
}
|
||
|
|
|
||
|
|
// MoshCatty is a pure single binary (no Cygwin DLL bag, no terminfo).
|
||
|
|
// Runtime env only needs MOSH_KEY / TERM / LANG from the handshake path.
|
||
|
|
function addBundledMoshRuntimeEnv(env, _bareClient, _opts = {}) {
|
||
|
|
return env;
|
||
|
|
}
|
||
|
|
|
||
|
|
function createMoshUtf8Decoder() {
|
||
|
|
const decoder = new StringDecoder("utf8");
|
||
|
|
return (chunk) => {
|
||
|
|
if (Buffer.isBuffer(chunk)) return decoder.write(chunk);
|
||
|
|
if (chunk instanceof Uint8Array) return decoder.write(Buffer.from(chunk));
|
||
|
|
return chunk == null ? "" : String(chunk);
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function stripMoshPromptControls(text) {
|
||
|
|
// eslint-disable-next-line no-control-regex
|
||
|
|
return stripAnsi(text).replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "");
|
||
|
|
}
|
||
|
|
|
||
|
|
function isMoshPassphrasePrompt(tail) {
|
||
|
|
return /(^|[\r\n]).*passphrase.*:\s*$/i.test(stripMoshPromptControls(tail));
|
||
|
|
}
|
||
|
|
|
||
|
|
function isMoshPasswordPrompt(tail) {
|
||
|
|
return /(^|[\r\n]).*password:\s*$/i.test(stripMoshPromptControls(tail));
|
||
|
|
}
|
||
|
|
|
||
|
|
function isMoshHostKeyConfirmationPrompt(tail) {
|
||
|
|
return /are you sure you want to continue connecting \(yes\/no(?:\/\[fingerprint\])?\)\?\s*$/i
|
||
|
|
.test(stripMoshPromptControls(tail));
|
||
|
|
}
|
||
|
|
|
||
|
|
function isMoshMfaPrompt(tail) {
|
||
|
|
return /(?:verification code|one[- ]time (?:password|code)|otp|passcode|authentication code|duo passcode|token):\s*$/i
|
||
|
|
.test(stripMoshPromptControls(tail));
|
||
|
|
}
|
||
|
|
|
||
|
|
function createMoshSshPasswordResponder(sshPty, password, passphrase) {
|
||
|
|
let answeredPassword = false;
|
||
|
|
let answeredPassphrase = false;
|
||
|
|
let tail = "";
|
||
|
|
|
||
|
|
return (chunk) => {
|
||
|
|
const text = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk || "");
|
||
|
|
if (!text) return false;
|
||
|
|
|
||
|
|
tail = (tail + text).slice(-512);
|
||
|
|
const passphrasePrompt = isMoshPassphrasePrompt(tail);
|
||
|
|
const mfaPrompt = isMoshMfaPrompt(tail);
|
||
|
|
const passwordPrompt = !mfaPrompt && isMoshPasswordPrompt(tail);
|
||
|
|
if (typeof passphrase === "string" && passphrase.length > 0 && !answeredPassphrase && passphrasePrompt) {
|
||
|
|
answeredPassphrase = true;
|
||
|
|
sshPty.write(`${passphrase}\r`);
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (typeof password === "string" && password.length > 0 && !answeredPassword && passwordPrompt) {
|
||
|
|
answeredPassword = true;
|
||
|
|
sshPty.write(`${password}\r`);
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
return passphrasePrompt
|
||
|
|
|| passwordPrompt
|
||
|
|
|| isMoshHostKeyConfirmationPrompt(tail)
|
||
|
|
|| isUntrustedTerminalInputPrompt(tail);
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function normalizeMoshIdentityPath(keyPath) {
|
||
|
|
if (typeof keyPath !== "string") return null;
|
||
|
|
const trimmed = keyPath.trim();
|
||
|
|
if (!trimmed) return null;
|
||
|
|
if (trimmed === "~") return os.homedir();
|
||
|
|
if (trimmed.startsWith("~/")) return path.join(os.homedir(), trimmed.slice(2));
|
||
|
|
return trimmed;
|
||
|
|
}
|
||
|
|
|
||
|
|
function discoverMoshIdentityPaths() {
|
||
|
|
const sshDir = path.join(os.homedir(), ".ssh");
|
||
|
|
try {
|
||
|
|
const names = fs.readdirSync(sshDir, { withFileTypes: true })
|
||
|
|
.filter((entry) => (entry.isFile() || entry.isSymbolicLink()) && SSH_KEY_PATTERN.test(entry.name))
|
||
|
|
.map((entry) => entry.name);
|
||
|
|
return orderSshIdentityNames(names).map((name) => path.join(sshDir, name));
|
||
|
|
} catch {
|
||
|
|
return [];
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function prepareMoshSshAgentOptions(options) {
|
||
|
|
if (options?.useSshAgent !== true && !options?.agentForwarding) return options;
|
||
|
|
let prepared = options;
|
||
|
|
if (options.useSshAgent === true) {
|
||
|
|
await prepareSystemSshAgentForAuth(options, "[Mosh]");
|
||
|
|
const loginSocketPath = await getAvailableAgentSocket(options.identityAgent, options);
|
||
|
|
if (!loginSocketPath) {
|
||
|
|
throw new Error("System SSH agent is unavailable. Start or unlock it, or configure a valid agent socket.");
|
||
|
|
}
|
||
|
|
prepared = { ...prepared, _resolvedSshAgentSocket: loginSocketPath };
|
||
|
|
}
|
||
|
|
if (options.agentForwarding) {
|
||
|
|
const forwardingSocketPath = await getAvailableForwardingAgentSocket(options.identityAgent, options);
|
||
|
|
if (forwardingSocketPath) {
|
||
|
|
prepared = { ...prepared, _resolvedForwardingAgentSocket: forwardingSocketPath };
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return prepared;
|
||
|
|
}
|
||
|
|
|
||
|
|
function applyMoshSshAgentEnvironment(env, options) {
|
||
|
|
delete env.SSH_AUTH_SOCK;
|
||
|
|
if (
|
||
|
|
process.platform === "win32"
|
||
|
|
&& options?.agentForwarding
|
||
|
|
&& options._resolvedForwardingAgentSocket
|
||
|
|
) {
|
||
|
|
// Windows OpenSSH reparses backslashes in a direct ForwardAgent value.
|
||
|
|
// Keep the named pipe intact by expanding the standard socket variable.
|
||
|
|
env.SSH_AUTH_SOCK = options._resolvedForwardingAgentSocket;
|
||
|
|
return env;
|
||
|
|
}
|
||
|
|
if (options?.useSshAgent === false) {
|
||
|
|
return env;
|
||
|
|
}
|
||
|
|
const socketPath = options?._resolvedSshAgentSocket || process.env.SSH_AUTH_SOCK;
|
||
|
|
if (socketPath) env.SSH_AUTH_SOCK = socketPath;
|
||
|
|
return env;
|
||
|
|
}
|
||
|
|
|
||
|
|
function safeMoshAuthFileName(sessionId, keyId, suffix) {
|
||
|
|
const safeId = String(keyId || sessionId || randomUUID())
|
||
|
|
.replace(/[^a-zA-Z0-9_-]/g, "_")
|
||
|
|
.slice(0, 80);
|
||
|
|
return `mosh-auth-${safeId}-${randomUUID()}${suffix}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
async function writeMoshAuthTempFile(fileName, content) {
|
||
|
|
const target = tempDirBridge.getTempFilePath(fileName);
|
||
|
|
const normalized = content.endsWith("\n") ? content : `${content}\n`;
|
||
|
|
let created = false;
|
||
|
|
try {
|
||
|
|
const handle = await fs.promises.open(target, "wx", 0o600);
|
||
|
|
created = true;
|
||
|
|
await handle.close();
|
||
|
|
await restrictMoshAuthFilePermissions(target, { failClosed: true });
|
||
|
|
await fs.promises.writeFile(target, normalized, { flag: "w", mode: 0o600 });
|
||
|
|
try {
|
||
|
|
await fs.promises.chmod(target, 0o600);
|
||
|
|
} catch {
|
||
|
|
// Best effort on Windows; ACL hardening above is the security boundary.
|
||
|
|
}
|
||
|
|
} catch (err) {
|
||
|
|
if (created) cleanupMoshAuthTempFiles([target]);
|
||
|
|
throw err;
|
||
|
|
}
|
||
|
|
return target;
|
||
|
|
}
|
||
|
|
|
||
|
|
async function restrictMoshAuthFilePermissions(target, opts = {}) {
|
||
|
|
if (process.platform !== "win32") return true;
|
||
|
|
|
||
|
|
let username = process.env.USERNAME;
|
||
|
|
if (!username) {
|
||
|
|
try {
|
||
|
|
username = os.userInfo().username;
|
||
|
|
} catch {
|
||
|
|
username = "";
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if (!username) {
|
||
|
|
if (opts.failClosed) {
|
||
|
|
throw new Error("Failed to restrict private key ACLs: unable to resolve current Windows user");
|
||
|
|
}
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
const identities = [];
|
||
|
|
if (process.env.USERDOMAIN) identities.push(`${process.env.USERDOMAIN}\\${username}`);
|
||
|
|
identities.push(username);
|
||
|
|
|
||
|
|
let lastError = null;
|
||
|
|
for (const identity of identities) {
|
||
|
|
try {
|
||
|
|
const commandOptions = { timeout: 5_000, maxBuffer: 1024 * 1024, windowsHide: true };
|
||
|
|
await execFileAsync("icacls.exe", [target, "/grant:r", `${identity}:F`], commandOptions);
|
||
|
|
await execFileAsync("icacls.exe", [target, "/inheritance:r"], commandOptions);
|
||
|
|
await execFileAsync("icacls.exe", [target, "/grant:r", `${identity}:F`], commandOptions);
|
||
|
|
return true;
|
||
|
|
} catch (err) {
|
||
|
|
lastError = err;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const message = lastError?.message || String(lastError || "unknown error");
|
||
|
|
if (opts.failClosed) {
|
||
|
|
throw new Error(`Failed to restrict private key ACLs: ${message}`);
|
||
|
|
}
|
||
|
|
console.warn("[Mosh] Failed to restrict private key ACLs:", message);
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
function cleanupMoshAuthTempFiles(files) {
|
||
|
|
for (const file of files || []) {
|
||
|
|
try {
|
||
|
|
fs.unlinkSync(file);
|
||
|
|
} catch {
|
||
|
|
// Best effort cleanup; Settings > System can clear Netcatty temp files.
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function buildMoshPreferredAuthentications({ authMethod, requiresMfa = false, hasPassword = false, hasPublicKey = false }) {
|
||
|
|
if (authMethod === "password") {
|
||
|
|
return requiresMfa ? "keyboard-interactive,password" : "password,keyboard-interactive";
|
||
|
|
}
|
||
|
|
if (!requiresMfa) return "";
|
||
|
|
if (hasPublicKey && hasPassword) return "publickey,keyboard-interactive,password";
|
||
|
|
if (hasPublicKey) return "publickey,keyboard-interactive";
|
||
|
|
if (hasPassword) return "keyboard-interactive,password";
|
||
|
|
return "keyboard-interactive";
|
||
|
|
}
|
||
|
|
|
||
|
|
async function buildMoshSshAuthArgs(options, sessionId) {
|
||
|
|
const sshArgs = [];
|
||
|
|
const tempFiles = [];
|
||
|
|
const resolvedIdentityFilePaths = [];
|
||
|
|
const rememberIdentityFilePath = (keyPath) => {
|
||
|
|
if (keyPath && !resolvedIdentityFilePaths.includes(keyPath)) {
|
||
|
|
resolvedIdentityFilePaths.push(keyPath);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
try {
|
||
|
|
if (typeof options.privateKey === "string" && options.privateKey.trim().length > 0) {
|
||
|
|
const keyPath = await writeMoshAuthTempFile(
|
||
|
|
safeMoshAuthFileName(sessionId, options.keyId, ".pem"),
|
||
|
|
options.privateKey,
|
||
|
|
);
|
||
|
|
tempFiles.push(keyPath);
|
||
|
|
sshArgs.push("-i", keyPath, "-o", "IdentitiesOnly=yes");
|
||
|
|
|
||
|
|
if (typeof options.certificate === "string" && options.certificate.trim().length > 0) {
|
||
|
|
const certPath = await writeMoshAuthTempFile(
|
||
|
|
safeMoshAuthFileName(sessionId, options.keyId, "-cert.pub"),
|
||
|
|
options.certificate,
|
||
|
|
);
|
||
|
|
tempFiles.push(certPath);
|
||
|
|
sshArgs.push("-o", `CertificateFile=${certPath}`);
|
||
|
|
}
|
||
|
|
} else if (options.useSshAgent && Array.isArray(options.agentPublicKeys) && options.agentPublicKeys.length > 0) {
|
||
|
|
for (let index = 0; index < options.agentPublicKeys.length; index += 1) {
|
||
|
|
const selectorPath = await writeMoshAuthTempFile(
|
||
|
|
safeMoshAuthFileName(sessionId, `${options.keyId || "agent"}-${index}`, ".pub"),
|
||
|
|
options.agentPublicKeys[index],
|
||
|
|
);
|
||
|
|
tempFiles.push(selectorPath);
|
||
|
|
sshArgs.push("-i", selectorPath);
|
||
|
|
}
|
||
|
|
} else if (Array.isArray(options.identityFilePaths) && options.identityFilePaths.length > 0) {
|
||
|
|
for (const keyPath of options.identityFilePaths) {
|
||
|
|
const normalized = normalizeMoshIdentityPath(keyPath);
|
||
|
|
if (normalized) {
|
||
|
|
rememberIdentityFilePath(normalized);
|
||
|
|
const selector = options.useSshAgent && !normalized.toLowerCase().endsWith(".pub")
|
||
|
|
? `${normalized}.pub`
|
||
|
|
: normalized;
|
||
|
|
sshArgs.push("-i", selector);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if (sshArgs.length > 0 && (!options.useSshAgent || options.identitiesOnly)) {
|
||
|
|
sshArgs.push("-o", "IdentitiesOnly=yes");
|
||
|
|
}
|
||
|
|
if (typeof options.certificate === "string" && options.certificate.trim().length > 0) {
|
||
|
|
const certPath = await writeMoshAuthTempFile(
|
||
|
|
safeMoshAuthFileName(sessionId, options.keyId, "-cert.pub"),
|
||
|
|
options.certificate,
|
||
|
|
);
|
||
|
|
tempFiles.push(certPath);
|
||
|
|
sshArgs.push("-o", `CertificateFile=${certPath}`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (options.authMethod === "auto") {
|
||
|
|
const selectedIdentities = new Set();
|
||
|
|
const useAgentOnlySelectors = options.useSshAgent === true
|
||
|
|
&& options.identitiesOnly === true;
|
||
|
|
for (let index = 0; index < sshArgs.length - 1; index += 1) {
|
||
|
|
if (sshArgs[index] === "-i") selectedIdentities.add(sshArgs[index + 1]);
|
||
|
|
}
|
||
|
|
for (const keyPath of discoverMoshIdentityPaths()) {
|
||
|
|
rememberIdentityFilePath(keyPath);
|
||
|
|
const selector = useAgentOnlySelectors ? `${keyPath}.pub` : keyPath;
|
||
|
|
if (!selectedIdentities.has(selector)) {
|
||
|
|
sshArgs.push("-i", selector);
|
||
|
|
selectedIdentities.add(selector);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const hasSelectedIdentity = sshArgs.some((arg) => arg === "-i");
|
||
|
|
const hasPassword = typeof options.password === "string" && options.password.length > 0;
|
||
|
|
if (
|
||
|
|
(options.authMethod === "key" || options.authMethod === "certificate")
|
||
|
|
&& !hasSelectedIdentity
|
||
|
|
) {
|
||
|
|
sshArgs.push("-o", "IdentityFile=none", "-o", "IdentitiesOnly=yes");
|
||
|
|
}
|
||
|
|
|
||
|
|
if (options.useSshAgent === false) {
|
||
|
|
sshArgs.push("-o", "IdentityAgent=none");
|
||
|
|
} else if (options.useSshAgent && options._resolvedSshAgentSocket) {
|
||
|
|
sshArgs.push("-o", `IdentityAgent=${options._resolvedSshAgentSocket}`);
|
||
|
|
if (options.identitiesOnly && !sshArgs.includes("IdentitiesOnly=yes")) {
|
||
|
|
sshArgs.push("-o", "IdentitiesOnly=yes");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if (options.agentForwarding && options._resolvedForwardingAgentSocket) {
|
||
|
|
const forwardingValue = process.platform === "win32"
|
||
|
|
? "${SSH_AUTH_SOCK}"
|
||
|
|
: options._resolvedForwardingAgentSocket;
|
||
|
|
sshArgs.push("-o", `ForwardAgent=${forwardingValue}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (options.authMethod === "password") {
|
||
|
|
sshArgs.push("-o", "PubkeyAuthentication=no");
|
||
|
|
}
|
||
|
|
const preferredAuthentications = buildMoshPreferredAuthentications({
|
||
|
|
authMethod: options.authMethod,
|
||
|
|
requiresMfa: !!options.requiresMfa,
|
||
|
|
hasPassword,
|
||
|
|
hasPublicKey: Boolean(options.useSshAgent || hasSelectedIdentity),
|
||
|
|
});
|
||
|
|
if (preferredAuthentications) {
|
||
|
|
sshArgs.push("-o", `PreferredAuthentications=${preferredAuthentications}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Vault known_hosts (issue #2501): Mosh bootstraps via system OpenSSH.
|
||
|
|
// When the vault pins hosts, write an authoritative known_hosts
|
||
|
|
// snapshot (vault pins + filtered system entries) and force
|
||
|
|
// StrictHostKeyChecking=ask so a permissive user ssh_config cannot
|
||
|
|
// disable verification. Vault hosts exclude conflicting system pins
|
||
|
|
// so a rotated system key cannot override the vault.
|
||
|
|
const verifyHostKeys = options.verifyHostKeys !== false;
|
||
|
|
let authoritativeKnownHostsPath = null;
|
||
|
|
let emptyKnownHostsPath = null;
|
||
|
|
if (verifyHostKeys) {
|
||
|
|
// Only override system known_hosts when the vault pins this target
|
||
|
|
// (or a jump host). Unrelated vault entries must not force a temp
|
||
|
|
// UserKnownHostsFile for every Mosh session.
|
||
|
|
const connectionHosts = [
|
||
|
|
{ hostname: options.hostname, port: options.port || 22 },
|
||
|
|
...(Array.isArray(options.jumpHosts)
|
||
|
|
? options.jumpHosts.map((jump) => ({
|
||
|
|
hostname: jump.hostname,
|
||
|
|
port: jump.port || 22,
|
||
|
|
}))
|
||
|
|
: []),
|
||
|
|
];
|
||
|
|
if (vaultPinsConnectionHosts(options.knownHosts, connectionHosts)) {
|
||
|
|
const authoritativeContent = buildAuthoritativeKnownHostsContent({
|
||
|
|
knownHosts: options.knownHosts,
|
||
|
|
fs,
|
||
|
|
hostname: options.hostname,
|
||
|
|
port: options.port || 22,
|
||
|
|
username: options.username,
|
||
|
|
pathModule: path,
|
||
|
|
homedir: os.homedir(),
|
||
|
|
memo: new Map(),
|
||
|
|
});
|
||
|
|
if (authoritativeContent) {
|
||
|
|
authoritativeKnownHostsPath = await writeMoshAuthTempFile(
|
||
|
|
safeMoshAuthFileName(sessionId, "authoritative-known-hosts", ".txt"),
|
||
|
|
authoritativeContent,
|
||
|
|
);
|
||
|
|
tempFiles.push(authoritativeKnownHostsPath);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
emptyKnownHostsPath = await writeMoshAuthTempFile(
|
||
|
|
safeMoshAuthFileName(sessionId, "empty-known-hosts", ".txt"),
|
||
|
|
"",
|
||
|
|
);
|
||
|
|
tempFiles.push(emptyKnownHostsPath);
|
||
|
|
}
|
||
|
|
sshArgs.push(...buildExternalHostKeySshOptions({
|
||
|
|
authoritativeKnownHostsPath,
|
||
|
|
emptyKnownHostsPath,
|
||
|
|
verifyHostKeys,
|
||
|
|
protocol: "mosh",
|
||
|
|
style: "args",
|
||
|
|
}));
|
||
|
|
} catch (err) {
|
||
|
|
cleanupMoshAuthTempFiles(tempFiles);
|
||
|
|
throw err;
|
||
|
|
}
|
||
|
|
|
||
|
|
return { sshArgs, tempFiles, identityFilePaths: resolvedIdentityFilePaths };
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Phase-2 / Phase-3b path: run the SSH bootstrap ourselves *inside the
|
||
|
|
* user's terminal PTY* so password / 2FA / known-hosts prompts render
|
||
|
|
* naturally, then swap to a bare `mosh-client` once `MOSH CONNECT` is
|
||
|
|
* detected. Replaces both the upstream Mosh Perl wrapper and the
|
||
|
|
* earlier non-PTY (BatchMode-style) implementation that couldn't show
|
||
|
|
* prompts.
|
||
|
|
*
|
||
|
|
* State machine:
|
||
|
|
* ssh-spawn ──onData──▶ sniffer.feed ──visible──▶ renderer
|
||
|
|
* └──parsed──▶ remember port/key
|
||
|
|
* ssh-pty exits ─────▶ if parsed: spawn mosh-client + swap
|
||
|
|
* else: surface error
|
||
|
|
*
|
||
|
|
* The session keeps a stable sessionId across the swap. session.proc
|
||
|
|
* is updated atomically before any user input arrives at the new
|
||
|
|
* mosh-client (writeToSession / resizeSession route through
|
||
|
|
* session.proc, so they automatically address the right process). The
|
||
|
|
* ZMODEM sentry is recreated for the new proc because its
|
||
|
|
* writeToRemote closure captures the previous handle.
|
||
|
|
*
|
||
|
|
* Caller has already validated that `bareClient` and `sshExe` exist.
|
||
|
|
*/
|
||
|
|
async function startMoshSessionViaHandshake(event, options, { bareClient, sshExe }) {
|
||
|
|
const sessionId = options.sessionId || randomUUID();
|
||
|
|
const cols = options.cols || 80;
|
||
|
|
const rows = options.rows || 24;
|
||
|
|
const optionsEnv = options.env || {};
|
||
|
|
const lang = optionsEnv.LANG || resolveLangFromCharsetForMosh(options.charset);
|
||
|
|
const moshAuth = await buildMoshSshAuthArgs(options, sessionId);
|
||
|
|
|
||
|
|
const { args: sshArgs } = moshHandshake.buildSshHandshakeCommand({
|
||
|
|
host: options.hostname,
|
||
|
|
port: options.port,
|
||
|
|
username: options.username,
|
||
|
|
lang,
|
||
|
|
locales: optionsEnv,
|
||
|
|
moshServer: moshHandshake.buildMoshServerCommand(options.moshServerPath),
|
||
|
|
sshArgs: moshAuth.sshArgs,
|
||
|
|
});
|
||
|
|
|
||
|
|
const { buildTerminalProcessEnv } = require("../httpNetworkProxyBridge.cjs");
|
||
|
|
const sshEnv = { ...buildTerminalProcessEnv(process.env), ...optionsEnv, TERM: "xterm-256color" };
|
||
|
|
// Do not let ssh_config SendEnv force the local locale onto the remote
|
||
|
|
// process. The handshake passes the configured locale variables through
|
||
|
|
// mosh-server's stock `-l` fallback mechanism instead, so a minimal host
|
||
|
|
// can keep its working native C.UTF-8 locale when a requested locale is
|
||
|
|
// not installed.
|
||
|
|
for (const key of Object.keys(sshEnv)) {
|
||
|
|
if (key === "LANG" || key === "LANGUAGE" || key.startsWith("LC_")) {
|
||
|
|
delete sshEnv[key];
|
||
|
|
}
|
||
|
|
}
|
||
|
|
applyMoshSshAgentEnvironment(sshEnv, options);
|
||
|
|
|
||
|
|
let sshPty;
|
||
|
|
try {
|
||
|
|
sshPty = pty.spawn(sshExe, sshArgs, {
|
||
|
|
cols,
|
||
|
|
rows,
|
||
|
|
env: sshEnv,
|
||
|
|
cwd: os.homedir(),
|
||
|
|
encoding: null,
|
||
|
|
useConptyDll: process.platform === "win32",
|
||
|
|
});
|
||
|
|
} catch (err) {
|
||
|
|
cleanupMoshAuthTempFiles(moshAuth.tempFiles);
|
||
|
|
throw err;
|
||
|
|
}
|
||
|
|
|
||
|
|
const session = {
|
||
|
|
proc: sshPty,
|
||
|
|
pty: sshPty,
|
||
|
|
type: "mosh",
|
||
|
|
protocol: "mosh",
|
||
|
|
webContentsId: event.sender.id,
|
||
|
|
hostname: options.hostname || "",
|
||
|
|
username: options.username || "",
|
||
|
|
label: options.label || options.hostname || "Mosh Session",
|
||
|
|
// Leave unset so ensureSessionShellKind can probe via companion SSH
|
||
|
|
// exec before AI wrappers (fish login shells — issue #1854).
|
||
|
|
shellKind: undefined,
|
||
|
|
_shellKindExecProbe: async (command, timeoutMs) => {
|
||
|
|
if (typeof ensureMoshStatsConnection !== "function") return null;
|
||
|
|
const contents = electronModule?.webContents?.fromId?.(session.webContentsId);
|
||
|
|
const conn = await withShellProbeTimeout(
|
||
|
|
ensureMoshStatsConnection(session, sessionId, contents),
|
||
|
|
timeoutMs,
|
||
|
|
);
|
||
|
|
const probe = createSshConnExecProbe(conn);
|
||
|
|
return probe ? probe(command, timeoutMs) : null;
|
||
|
|
},
|
||
|
|
shellExecutable: "remote-shell",
|
||
|
|
flushPendingData: null,
|
||
|
|
lastIdlePrompt: "",
|
||
|
|
lastIdlePromptAt: 0,
|
||
|
|
_promptTrackTail: "",
|
||
|
|
cols,
|
||
|
|
rows,
|
||
|
|
moshHandshakePhase: "ssh",
|
||
|
|
moshHandshakeResult: null,
|
||
|
|
moshAuthTempFiles: moshAuth.tempFiles,
|
||
|
|
};
|
||
|
|
{
|
||
|
|
const { claimSessionSlot } = require("../sessionBootEpoch.cjs");
|
||
|
|
const claim = claimSessionSlot(sessions, sessionId, session, options.bootEpoch);
|
||
|
|
if (!claim.ok) {
|
||
|
|
try { sshPty.kill(); } catch { /* ignore */ }
|
||
|
|
cleanupMoshAuthTempFiles(moshAuth.tempFiles);
|
||
|
|
const supersededError = new Error("Connection superseded by a newer reconnect");
|
||
|
|
supersededError.code = "NETCATTY_BOOT_SUPERSEDED";
|
||
|
|
throw supersededError;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
openTerminalOutputSession?.(sessionId, event.sender);
|
||
|
|
|
||
|
|
let logStreamToken = null;
|
||
|
|
if (options.sessionLog?.enabled && options.sessionLog?.directory) {
|
||
|
|
logStreamToken = sessionLogStreamManager.startStream(sessionId, {
|
||
|
|
hostLabel: options.label || options.hostname,
|
||
|
|
hostname: options.hostname,
|
||
|
|
directory: options.sessionLog.directory,
|
||
|
|
format: options.sessionLog.format || "txt",
|
||
|
|
timestampsEnabled: Boolean(options.sessionLog.timestampsEnabled),
|
||
|
|
startTime: Date.now(),
|
||
|
|
});
|
||
|
|
}
|
||
|
|
// Expose the token so swapToMoshClient can keep using it after the
|
||
|
|
// handshake hand-off; the new mc-pty's exit handler will also rely on
|
||
|
|
// it to scope its stopStream call.
|
||
|
|
session.logStreamToken = logStreamToken;
|
||
|
|
|
||
|
|
const {
|
||
|
|
bufferData,
|
||
|
|
flush,
|
||
|
|
flushPaced,
|
||
|
|
discard,
|
||
|
|
} = createPtyOutputBuffer((data, meta) => {
|
||
|
|
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||
|
|
// Tag SSH-handshake output so the renderer does not treat the session
|
||
|
|
// as shell-ready yet. Startup commands / pending scripts must wait for
|
||
|
|
// mosh-client (issue #2199).
|
||
|
|
const handshakeMeta = session.moshHandshakePhase !== "mosh-client"
|
||
|
|
? { ...(meta || {}), moshHandshake: true }
|
||
|
|
: meta;
|
||
|
|
emitTerminalSessionData(contents, sessionId, data, {
|
||
|
|
session,
|
||
|
|
cols: session.cols,
|
||
|
|
rows: session.rows,
|
||
|
|
meta: handshakeMeta,
|
||
|
|
});
|
||
|
|
}, {
|
||
|
|
onPendingBytesChange: (bytes) => setBufferedOutputBytes(session, bytes),
|
||
|
|
shouldAcceptOutput: () => sessions.get(sessionId) === session && shouldAcceptSessionOutput(session),
|
||
|
|
});
|
||
|
|
session.flushPendingData = flushPaced;
|
||
|
|
session.discardPendingData = discard;
|
||
|
|
|
||
|
|
const sniffer = moshHandshake.createMoshConnectSniffer();
|
||
|
|
const respondToPasswordPrompt = createMoshSshPasswordResponder(sshPty, options.password, options.passphrase);
|
||
|
|
|
||
|
|
// Forward bytes from the ssh PTY to the renderer, redacting the
|
||
|
|
// MOSH CONNECT magic line. ZMODEM is intentionally not enabled
|
||
|
|
// during handshake — it can't appear during ssh login output and
|
||
|
|
// would only complicate the swap.
|
||
|
|
sshPty.onData((chunk) => {
|
||
|
|
if (sessions.get(sessionId) !== session) return;
|
||
|
|
const { visible, parsed } = sniffer.feed(chunk);
|
||
|
|
if (visible && (visible.length || (typeof visible === "string" && visible))) {
|
||
|
|
const str = Buffer.isBuffer(visible) ? visible.toString("utf8") : visible;
|
||
|
|
if (str.length > 0) {
|
||
|
|
const requiresUserInput = respondToPasswordPrompt(str);
|
||
|
|
bufferData(
|
||
|
|
str,
|
||
|
|
requiresUserInput ? { moshHandshakeRequiresUserInput: true } : undefined,
|
||
|
|
);
|
||
|
|
sessionLogStreamManager.appendData(sessionId, str);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if (parsed && session.moshHandshakePhase === "ssh") {
|
||
|
|
session.moshHandshakePhase = "parsed";
|
||
|
|
session.moshHandshakeResult = parsed;
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
sshPty.onExit(({ exitCode, signal }) => {
|
||
|
|
if (sessions.get(sessionId) !== session || session.closed) {
|
||
|
|
cleanupMoshAuthTempFiles(moshAuth.tempFiles);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
cleanupMoshAuthTempFiles(moshAuth.tempFiles);
|
||
|
|
|
||
|
|
// Final chance: ConPTY / ssh often ends the stream on the MOSH CONNECT
|
||
|
|
// line with no trailing newline. Flush the sniffer's pending buffer so
|
||
|
|
// we still swap to mosh-client (issue #2025).
|
||
|
|
if (session.moshHandshakePhase === "ssh") {
|
||
|
|
const flushed = sniffer.flush();
|
||
|
|
if (flushed.visible && (flushed.visible.length || (typeof flushed.visible === "string" && flushed.visible))) {
|
||
|
|
const str = Buffer.isBuffer(flushed.visible) ? flushed.visible.toString("utf8") : flushed.visible;
|
||
|
|
if (str.length > 0) {
|
||
|
|
bufferData(str);
|
||
|
|
sessionLogStreamManager.appendData(sessionId, str);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if (flushed.parsed) {
|
||
|
|
session.moshHandshakePhase = "parsed";
|
||
|
|
session.moshHandshakeResult = flushed.parsed;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (session.moshHandshakePhase === "parsed" && session.moshHandshakeResult) {
|
||
|
|
try {
|
||
|
|
swapToMoshClient(session, options, {
|
||
|
|
bareClient,
|
||
|
|
optionsEnv,
|
||
|
|
lang,
|
||
|
|
identityFilePaths: moshAuth.identityFilePaths,
|
||
|
|
parsed: session.moshHandshakeResult,
|
||
|
|
bufferData,
|
||
|
|
flush,
|
||
|
|
flushPaced,
|
||
|
|
sessionId,
|
||
|
|
});
|
||
|
|
} catch (err) {
|
||
|
|
flushPaced(() => {
|
||
|
|
if (sessions.get(sessionId) !== session) return;
|
||
|
|
sessionLogStreamManager.stopStream(sessionId, logStreamToken);
|
||
|
|
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||
|
|
fanoutSessionExit(sessionId, contents, {
|
||
|
|
sessionId,
|
||
|
|
reason: "error",
|
||
|
|
error: `Failed to spawn mosh-client: ${err.message}`,
|
||
|
|
_terminalSessionGeneration: session._terminalSessionGeneration,
|
||
|
|
});
|
||
|
|
closeTerminalOutputSession?.(sessionId);
|
||
|
|
sessions.delete(sessionId);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Handshake failed before MOSH CONNECT — ssh exited without parse.
|
||
|
|
// Common on Windows when ConPTY mangled the magic line (#2025) or when
|
||
|
|
// mosh-server never started. Surface an explicit hint so the banner +
|
||
|
|
// "[mosh-server detached]" alone is not mistaken for a successful
|
||
|
|
// session that immediately closed (Netcatty #2121 residual connect).
|
||
|
|
const handshakeHint =
|
||
|
|
"\r\n[Mosh handshake failed during SSH startup: did not receive a valid "
|
||
|
|
+ "MOSH CONNECT line from mosh-server. The UDP client was not started. "
|
||
|
|
+ "Confirm the SSH login succeeded and mosh-server started correctly "
|
||
|
|
+ "on the remote host.]\r\n";
|
||
|
|
try {
|
||
|
|
bufferData(handshakeHint);
|
||
|
|
sessionLogStreamManager.appendData(sessionId, handshakeHint);
|
||
|
|
} catch {
|
||
|
|
// Best-effort diagnostics; still tear the session down below.
|
||
|
|
}
|
||
|
|
flushPaced(() => {
|
||
|
|
if (sessions.get(sessionId) !== session) return;
|
||
|
|
sessionLogStreamManager.stopStream(sessionId, logStreamToken);
|
||
|
|
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||
|
|
fanoutSessionExit(sessionId, contents, {
|
||
|
|
sessionId,
|
||
|
|
exitCode,
|
||
|
|
signal,
|
||
|
|
reason: "error",
|
||
|
|
error: "Mosh SSH startup failed: no MOSH CONNECT from mosh-server (UDP client not started)",
|
||
|
|
_terminalSessionGeneration: session._terminalSessionGeneration,
|
||
|
|
});
|
||
|
|
closeTerminalOutputSession?.(sessionId);
|
||
|
|
sessions.delete(sessionId);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
return { sessionId };
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Mid-session PTY swap: replaces session.proc (currently the ssh
|
||
|
|
* handshake PTY) with a freshly-spawned mosh-client PTY, re-wiring
|
||
|
|
* the data / exit listeners and (on POSIX) recreating the ZMODEM
|
||
|
|
* sentry whose writeToRemote closure captured the previous handle.
|
||
|
|
*/
|
||
|
|
function swapToMoshClient(session, options, ctx) {
|
||
|
|
const {
|
||
|
|
bareClient,
|
||
|
|
optionsEnv,
|
||
|
|
lang,
|
||
|
|
identityFilePaths,
|
||
|
|
parsed,
|
||
|
|
bufferData,
|
||
|
|
flush,
|
||
|
|
flushPaced,
|
||
|
|
sessionId,
|
||
|
|
} = ctx;
|
||
|
|
|
||
|
|
const { buildTerminalProcessEnv } = require("../httpNetworkProxyBridge.cjs");
|
||
|
|
const env = moshHandshake.buildMoshClientEnv({
|
||
|
|
baseEnv: { ...buildTerminalProcessEnv(process.env), ...optionsEnv, TERM: "xterm-256color" },
|
||
|
|
key: parsed.key,
|
||
|
|
lang,
|
||
|
|
fallbackHost: parsed.host && parsed.host !== options.hostname
|
||
|
|
? options.hostname
|
||
|
|
: undefined,
|
||
|
|
});
|
||
|
|
// Netcatty owns the terminal buffer. Keeping MoshCatty on the primary
|
||
|
|
// screen preserves scrollback and lets renderer features such as keyword
|
||
|
|
// highlighting keep observing the active buffer.
|
||
|
|
env.MOSH_NO_TERM_INIT = "1";
|
||
|
|
addBundledMoshRuntimeEnv(env, bareClient);
|
||
|
|
applyMoshSshAgentEnvironment(env, options);
|
||
|
|
|
||
|
|
const { command, args: clientArgs } = moshHandshake.buildMoshClientCommand({
|
||
|
|
moshClientPath: bareClient,
|
||
|
|
host: parsed.host || options.hostname,
|
||
|
|
port: parsed.port,
|
||
|
|
});
|
||
|
|
|
||
|
|
const mcPty = pty.spawn(command, clientArgs, {
|
||
|
|
cols: session.cols,
|
||
|
|
rows: session.rows,
|
||
|
|
env,
|
||
|
|
cwd: os.homedir(),
|
||
|
|
encoding: null,
|
||
|
|
useConptyDll: process.platform === "win32",
|
||
|
|
});
|
||
|
|
|
||
|
|
// Atomic swap — writeToSession / resizeSession both read
|
||
|
|
// session.proc lazily, so any keystroke that arrives after this
|
||
|
|
// assignment goes to mosh-client, not the dead ssh PTY.
|
||
|
|
session.proc = mcPty;
|
||
|
|
session.pty = mcPty;
|
||
|
|
session.moshHandshakePhase = "mosh-client";
|
||
|
|
|
||
|
|
// Establish the blank terminal baseline that mosh-client expects before
|
||
|
|
// its first reconstructed frame. Keep this ordered through the same
|
||
|
|
// output buffer as both the SSH bootstrap and Mosh client data.
|
||
|
|
bufferData(MOSH_HANDSHAKE_VIEWPORT_RESET);
|
||
|
|
|
||
|
|
// Notify the renderer that the interactive mosh shell is ready. This is
|
||
|
|
// distinct from the first SSH-handshake bytes (which can mark the
|
||
|
|
// session "connected" too early and fire startup/scripts into the
|
||
|
|
// ephemeral handshake PTY — issue #2199).
|
||
|
|
try {
|
||
|
|
const readyContents = electronModule.webContents.fromId(session.webContentsId);
|
||
|
|
readyContents?.send("netcatty:mosh:ready", {
|
||
|
|
sessionId,
|
||
|
|
bootEpoch: session.bootEpoch,
|
||
|
|
});
|
||
|
|
} catch {
|
||
|
|
// Best-effort; startup-command deferral falls back if the event is missed.
|
||
|
|
}
|
||
|
|
|
||
|
|
// The SSH handshake just succeeded, so these credentials are known
|
||
|
|
// good. Stash them so sshBridge can lazily open a best-effort companion
|
||
|
|
// SSH connection for host-info stats (CPU/mem/disk), which Mosh's UDP
|
||
|
|
// channel cannot provide on its own (issue #1198). Only credentials
|
||
|
|
// Netcatty already holds are kept — a password typed interactively into
|
||
|
|
// the handshake PTY is not captured, so that case degrades gracefully.
|
||
|
|
session.moshStatsAuth = {
|
||
|
|
// Use the configured SSH host, NOT parsed.host: a `MOSH IP` line
|
||
|
|
// advertises the UDP endpoint for mosh-client, which can differ from
|
||
|
|
// the SSH endpoint on NAT / multi-homed hosts. The companion is an
|
||
|
|
// SSH connection, so it must target the same host the handshake's ssh
|
||
|
|
// did.
|
||
|
|
hostname: options.hostname,
|
||
|
|
port: options.port || 22,
|
||
|
|
username: options.username,
|
||
|
|
authMethod: options.authMethod,
|
||
|
|
password: options.password,
|
||
|
|
privateKey: options.privateKey,
|
||
|
|
passphrase: options.passphrase,
|
||
|
|
certificate: options.certificate,
|
||
|
|
keyId: options.keyId,
|
||
|
|
identityFilePaths: identityFilePaths || options.identityFilePaths,
|
||
|
|
agentPublicKeys: options.agentPublicKeys,
|
||
|
|
useSshAgent: options.useSshAgent,
|
||
|
|
identityAgent: options.identityAgent,
|
||
|
|
identitiesOnly: options.identitiesOnly,
|
||
|
|
addKeysToAgent: options.addKeysToAgent,
|
||
|
|
useKeychain: options.useKeychain,
|
||
|
|
legacyAlgorithms: options.legacyAlgorithms,
|
||
|
|
skipEcdsaHostKey: options.skipEcdsaHostKey,
|
||
|
|
algorithmOverrides: options.algorithmOverrides,
|
||
|
|
// Used to verify the host key before the companion sends a saved
|
||
|
|
// password (see moshStatsConnection.cjs). Public-key / agent auth
|
||
|
|
// does not depend on this.
|
||
|
|
knownHosts: options.knownHosts,
|
||
|
|
verifyHostKeys: options.verifyHostKeys,
|
||
|
|
hasJumpHost: Array.isArray(options.jumpHosts) && options.jumpHosts.length > 0,
|
||
|
|
hasProxy: !!options.proxy,
|
||
|
|
};
|
||
|
|
session.systemManagerSudoPassword = typeof options.sudoAutofillPassword === "string" && options.sudoAutofillPassword.length > 0
|
||
|
|
? options.sudoAutofillPassword
|
||
|
|
: undefined;
|
||
|
|
|
||
|
|
if (process.platform !== "win32") {
|
||
|
|
const decoder = new StringDecoder("utf8");
|
||
|
|
const sentry = createZmodemSentry({
|
||
|
|
sessionId,
|
||
|
|
onData(buf) {
|
||
|
|
const str = decoder.write(buf);
|
||
|
|
if (!str) return;
|
||
|
|
trackSessionIdlePrompt(session, str);
|
||
|
|
bufferData(str);
|
||
|
|
sessionLogStreamManager.appendData(sessionId, str);
|
||
|
|
},
|
||
|
|
writeToRemote(buf) {
|
||
|
|
try { return mcPty.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,
|
||
|
|
protocolLabel: "Mosh",
|
||
|
|
});
|
||
|
|
session.zmodemSentry = sentry;
|
||
|
|
mcPty.onData((data) => {
|
||
|
|
if (sessions.get(sessionId) !== session) return;
|
||
|
|
if (!shouldProcessSessionOutput(session, sentry)) return;
|
||
|
|
sentry.consume(data);
|
||
|
|
});
|
||
|
|
} else {
|
||
|
|
const decodeMoshOutput = createMoshUtf8Decoder();
|
||
|
|
mcPty.onData((data) => {
|
||
|
|
if (sessions.get(sessionId) !== session) return;
|
||
|
|
if (!shouldProcessSessionOutput(session)) return;
|
||
|
|
const str = decodeMoshOutput(data);
|
||
|
|
if (!str) return;
|
||
|
|
trackSessionIdlePrompt(session, str);
|
||
|
|
bufferData(str);
|
||
|
|
sessionLogStreamManager.appendData(sessionId, str);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
mcPty.onExit(({ exitCode, signal }) => {
|
||
|
|
if (sessions.get(sessionId) !== session || session.closed) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
// Tear down the host-info stats companion ssh2 connection (issue
|
||
|
|
// #1198) if one was opened — it lives on moshStatsConn and outlives
|
||
|
|
// the mosh-client PTY otherwise.
|
||
|
|
try { session.moshStatsConn?.end(); } catch { /* ignore */ }
|
||
|
|
bufferData(MOSH_PRIMARY_SCREEN_RESET);
|
||
|
|
flushPaced(() => {
|
||
|
|
if (sessions.get(sessionId) !== session) return;
|
||
|
|
sessionLogStreamManager.stopStream(sessionId, session.logStreamToken);
|
||
|
|
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||
|
|
fanoutSessionExit(sessionId, contents, {
|
||
|
|
sessionId,
|
||
|
|
exitCode,
|
||
|
|
signal,
|
||
|
|
reason: exitCode !== 0 ? "error" : "exited",
|
||
|
|
_terminalSessionGeneration: session._terminalSessionGeneration,
|
||
|
|
});
|
||
|
|
closeTerminalOutputSession?.(sessionId);
|
||
|
|
sessions.delete(sessionId);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
function resolveLangFromCharsetForMosh(charset) {
|
||
|
|
if (!charset) return "en_US.UTF-8";
|
||
|
|
const trimmed = String(charset).trim();
|
||
|
|
if (/^utf-?8$/i.test(trimmed) || /^utf8$/i.test(trimmed)) return "en_US.UTF-8";
|
||
|
|
return trimmed;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Start a Mosh session.
|
||
|
|
*
|
||
|
|
* Netcatty only uses its bundled `mosh-client` binary here. System
|
||
|
|
* `mosh` / `mosh-client` installs are intentionally ignored so dev,
|
||
|
|
* CI, and release builds exercise the same binary.
|
||
|
|
*/
|
||
|
|
async function startMoshSession(event, options, opts = {}) {
|
||
|
|
const optionsEnv = options.env || {};
|
||
|
|
// Program discovery must consider the same PATH the spawned PTY will
|
||
|
|
// receive, including host-level terminal environment overrides.
|
||
|
|
const mergedPathForResolution = Object.prototype.hasOwnProperty.call(optionsEnv, "PATH")
|
||
|
|
? optionsEnv.PATH
|
||
|
|
: process.env.PATH;
|
||
|
|
|
||
|
|
const bareClient = resolveBareMoshClient(options, opts.moshClientLookup || {});
|
||
|
|
if (!bareClient) {
|
||
|
|
throw new Error(
|
||
|
|
"Bundled mosh-client not found. Run `npm run fetch:mosh:dev` for local dev, " +
|
||
|
|
"or ensure release packaging downloads the mosh binary release before building.",
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
const sshExe = moshHandshake.resolveSshExecutable({
|
||
|
|
findExecutable: (name) => (
|
||
|
|
process.platform === "win32"
|
||
|
|
? (opts.findExecutable || findExecutable)(name, { pathOverride: mergedPathForResolution })
|
||
|
|
: resolvePosixExecutable(name, { pathOverride: mergedPathForResolution })
|
||
|
|
),
|
||
|
|
fileExists: (p) => isExecutableFile(p) || fs.existsSync(p),
|
||
|
|
});
|
||
|
|
if (!sshExe) {
|
||
|
|
throw new Error("OpenSSH client not found. Netcatty needs ssh to start the remote mosh-server handshake.");
|
||
|
|
}
|
||
|
|
|
||
|
|
const preparedOptions = await prepareMoshSshAgentOptions(options);
|
||
|
|
return startMoshSessionViaHandshake(event, preparedOptions, { bareClient, sshExe });
|
||
|
|
}
|
||
|
|
|
||
|
|
return {
|
||
|
|
resolveBareMoshClient,
|
||
|
|
addBundledMoshRuntimeEnv,
|
||
|
|
createMoshUtf8Decoder,
|
||
|
|
buildMoshSshAuthArgs,
|
||
|
|
prepareMoshSshAgentOptions,
|
||
|
|
applyMoshSshAgentEnvironment,
|
||
|
|
cleanupMoshAuthTempFiles,
|
||
|
|
startMoshSessionViaHandshake,
|
||
|
|
swapToMoshClient,
|
||
|
|
resolveLangFromCharsetForMosh,
|
||
|
|
startMoshSession,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
module.exports = { createMoshSessionApi };
|