[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
984
electron/bridges/terminalBridge/moshSession.cjs
Normal file
984
electron/bridges/terminalBridge/moshSession.cjs
Normal file
@@ -0,0 +1,984 @@
|
||||
/* 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 };
|
||||
Reference in New Issue
Block a user