Files
NetMesh/electron/bridges/terminalBridge.moshHandshakeSession.test.cjs

843 lines
29 KiB
JavaScript
Raw Permalink Normal View History

const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const Module = require("node:module");
class FakePty {
constructor(command, args, opts) {
this.command = command;
this.args = args;
this.opts = opts;
this.pid = FakePty.nextPid += 1;
this.dataHandlers = [];
this.exitHandlers = [];
this.writes = [];
this.resizes = [];
this.killed = false;
}
onData(handler) {
this.dataHandlers.push(handler);
}
onExit(handler) {
this.exitHandlers.push(handler);
}
write(data) {
this.writes.push(data);
}
resize(cols, rows) {
this.resizes.push({ cols, rows });
}
kill() {
this.killed = true;
}
emitData(data) {
for (const handler of this.dataHandlers) handler(data);
}
emitExit(evt) {
for (const handler of this.exitHandlers) handler(evt);
}
}
FakePty.nextPid = 1000;
function writeExecutable(filePath) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, "#!/bin/sh\nexit 0\n");
fs.chmodSync(filePath, 0o755);
}
function loadBridgeWithFakePty(spawns) {
const bridgePath = require.resolve("./terminalBridge.cjs");
delete require.cache[bridgePath];
const originalLoad = Module._load;
Module._load = function patchedLoad(request, parent, isMain) {
if (request === "node-pty") {
return {
spawn(command, args, opts) {
const pty = new FakePty(command, args, opts);
spawns.push(pty);
return pty;
},
};
}
return originalLoad.call(this, request, parent, isMain);
};
try {
return require("./terminalBridge.cjs");
} finally {
Module._load = originalLoad;
}
}
function makeHarness(t) {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-mosh-session-"));
t.after(() => fs.rmSync(tmp, { recursive: true, force: true }));
const binDir = path.join(tmp, "bin");
const sshPath = path.join(binDir, "ssh");
const moshClientPath = path.join(tmp, "resources", "mosh", "linux-x64", "mosh-client");
writeExecutable(sshPath);
writeExecutable(moshClientPath);
const oldPath = process.env.PATH;
process.env.PATH = `${binDir}${path.delimiter}${oldPath || ""}`;
t.after(() => { process.env.PATH = oldPath; });
const spawns = [];
const bridge = loadBridgeWithFakePty(spawns);
const sessions = new Map();
const sent = [];
bridge.init({
sessions,
electronModule: {
webContents: {
fromId() {
return { send: (channel, payload) => sent.push({ channel, payload }) };
},
},
},
});
return {
bridge,
tmp,
sshPath,
sessions,
sent,
spawns,
options: {
sessionId: "mosh-test-session",
hostname: "example.com",
username: "alice",
cols: 80,
rows: 24,
},
event: { sender: { id: 42 } },
lookupOpts: {
platform: "linux",
arch: "x64",
projectRoot: tmp,
resourcesPath: path.join(tmp, "missing"),
},
};
}
test("startMoshSession handshake path returns the same shape as the legacy path", async (t) => {
const h = makeHarness(t);
const result = await h.bridge.startMoshSession(h.event, h.options, { moshClientLookup: h.lookupOpts });
assert.deepEqual(result, { sessionId: "mosh-test-session" });
});
test("Mosh PTYs explicitly enable bundled ConPTY clear support only on Windows", async (t) => {
const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform");
const startAndSwap = async (platform) => {
const h = makeHarness(t);
Object.defineProperty(process, "platform", { ...platformDescriptor, value: platform });
await h.bridge.startMoshSession(h.event, h.options, {
moshClientLookup: h.lookupOpts,
findExecutable: () => h.sshPath,
});
h.spawns[0].emitData(
"MOSH IP 203.0.113.8\r\nMOSH CONNECT 60002 ABCDEFGHIJKLMNOPQRSTUV==\r\n",
);
h.spawns[0].emitExit({ exitCode: 0, signal: 0 });
return h.spawns;
};
try {
const windowsSpawns = await startAndSwap("win32");
assert.equal(windowsSpawns.length, 2);
assert.equal(windowsSpawns[0].opts.useConptyDll, true);
assert.equal(windowsSpawns[1].opts.useConptyDll, true);
const linuxSpawns = await startAndSwap("linux");
assert.equal(linuxSpawns.length, 2);
assert.equal(linuxSpawns[0].opts.useConptyDll, false);
assert.equal(linuxSpawns[1].opts.useConptyDll, false);
} finally {
Object.defineProperty(process, "platform", platformDescriptor);
}
});
test("startMoshSession offers all locale settings to mosh-server without exporting them through SSH", async (t) => {
const h = makeHarness(t);
h.options.env = {
LANG: "C",
LANGUAGE: "zh_CN:zh",
LC_CTYPE: "ja_JP.UTF-8",
LC_ALL: "zh_CN.UTF-8",
};
await h.bridge.startMoshSession(h.event, h.options, { moshClientLookup: h.lookupOpts });
assert.equal(h.spawns[0].opts.env.LANG, undefined);
assert.equal(h.spawns[0].opts.env.LANGUAGE, undefined);
assert.equal(h.spawns[0].opts.env.LC_CTYPE, undefined);
assert.equal(h.spawns[0].opts.env.LC_ALL, undefined);
const remote = h.spawns[0].args.at(-1);
assert.ok(remote.indexOf("LANG=C") < remote.indexOf("LANGUAGE=zh_CN:zh"));
assert.ok(remote.indexOf("LANGUAGE=zh_CN:zh") < remote.indexOf("LC_CTYPE=ja_JP.UTF-8"));
assert.ok(remote.indexOf("LC_CTYPE=ja_JP.UTF-8") < remote.indexOf("LC_ALL=zh_CN.UTF-8"));
assert.equal((remote.match(/ -l /g) || []).length, 4);
});
test("startMoshSession keeps the original hostname as a UDP fallback", async (t) => {
const h = makeHarness(t);
await h.bridge.startMoshSession(h.event, h.options, { moshClientLookup: h.lookupOpts });
h.spawns[0].emitData(
"MOSH IP 203.0.113.8\r\nMOSH CONNECT 60002 ABCDEFGHIJKLMNOPQRSTUV==\r\n",
);
h.spawns[0].emitExit({ exitCode: 0, signal: 0 });
assert.equal(h.spawns[1].args[0], "203.0.113.8");
assert.equal(h.spawns[1].opts.env.MOSH_FALLBACK_HOST, "example.com");
});
test("startMoshSession uses bundled mosh-client even when PATH contains another client", async (t) => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-mosh-session-path-"));
t.after(() => fs.rmSync(tmp, { recursive: true, force: true }));
const binDir = path.join(tmp, "bin");
const sshPath = path.join(binDir, "ssh");
const pathMoshClient = path.join(binDir, "mosh-client");
const bundledMoshClient = path.join(tmp, "resources", "mosh", "linux-x64", "mosh-client");
writeExecutable(sshPath);
writeExecutable(pathMoshClient);
writeExecutable(bundledMoshClient);
const oldPath = process.env.PATH;
process.env.PATH = "";
t.after(() => { process.env.PATH = oldPath; });
const spawns = [];
const bridge = loadBridgeWithFakePty(spawns);
const sessions = new Map();
const sent = [];
bridge.init({
sessions,
electronModule: {
webContents: {
fromId() {
return { send: (channel, payload) => sent.push({ channel, payload }) };
},
},
},
});
const result = await bridge.startMoshSession(
{ sender: { id: 42 } },
{
sessionId: "mosh-path-session",
hostname: "example.com",
username: "alice",
cols: 80,
rows: 24,
env: { PATH: binDir },
},
{
moshClientLookup: {
platform: "linux",
arch: "x64",
projectRoot: tmp,
resourcesPath: path.join(tmp, "missing"),
},
},
);
assert.deepEqual(result, { sessionId: "mosh-path-session" });
assert.equal(spawns[0].command, sshPath);
spawns[0].emitData("MOSH CONNECT 60002 ABCDEFGHIJKLMNOPQRSTUV==\r\n");
spawns[0].emitExit({ exitCode: 0, signal: 0 });
assert.equal(spawns[1].command, bundledMoshClient);
});
test("startMoshSession handshake path sends the existing exit event on failure", async (t) => {
const h = makeHarness(t);
await h.bridge.startMoshSession(h.event, h.options, { moshClientLookup: h.lookupOpts });
h.spawns[0].emitExit({ exitCode: 255, signal: 0 });
const exit = h.sent.find((evt) => evt.channel === "netcatty:exit");
assert.ok(exit);
assert.equal(exit.payload.sessionId, "mosh-test-session");
assert.equal(exit.payload.reason, "error");
assert.match(
String(exit.payload.error || ""),
/no MOSH CONNECT/i,
"exit payload should name the handshake failure",
);
const dataChunks = h.sent
.filter((evt) => evt.channel === "netcatty:data" || evt.channel === "netcatty:session-data")
.map((evt) => String(evt.payload?.data ?? evt.payload ?? ""));
assert.ok(
dataChunks.some((chunk) => /Mosh handshake failed/i.test(chunk)),
"renderer should receive an explicit handshake-failure hint",
);
const handshakeHint = dataChunks.find((chunk) => /Mosh handshake failed/i.test(chunk));
assert.match(
handshakeHint,
/UDP client was not started/i,
"the hint should explain that UDP has not started yet",
);
assert.doesNotMatch(
handshakeHint,
/UDP ports.*reachable/i,
"an SSH bootstrap failure must not send users to UDP diagnostics",
);
});
test("startMoshSession writes the saved password when ssh prompts for one", async (t) => {
const h = makeHarness(t);
await h.bridge.startMoshSession(
h.event,
{ ...h.options, password: "saved-secret" },
{ moshClientLookup: h.lookupOpts },
);
h.spawns[0].emitData("(alice@example.com) Password:");
await new Promise((resolve) => h.sessions.get("mosh-test-session").flushPendingData(resolve));
assert.deepEqual(h.spawns[0].writes, ["saved-secret\r"]);
const prompt = h.sent.find((evt) =>
evt.channel === "netcatty:data" && evt.payload?.data?.includes("Password:"),
);
assert.ok(prompt, "expected the saved-password prompt to reach the renderer");
assert.notEqual(prompt.payload.meta?.moshHandshakeRequiresUserInput, true);
});
test("startMoshSession marks a password prompt that has no saved answer as interactive", async (t) => {
const h = makeHarness(t);
await h.bridge.startMoshSession(h.event, h.options, { moshClientLookup: h.lookupOpts });
h.spawns[0].emitData("(alice@example.com) Password:");
await new Promise((resolve) => h.sessions.get("mosh-test-session").flushPendingData(resolve));
assert.deepEqual(h.spawns[0].writes, []);
const prompt = h.sent.find((evt) =>
evt.channel === "netcatty:data" && evt.payload?.data?.includes("Password:"),
);
assert.ok(prompt, "expected the manual password prompt to reach the renderer");
assert.equal(prompt.payload.meta?.moshHandshakeRequiresUserInput, true);
});
test("startMoshSession keeps a saved primary password out of an MFA prompt", async (t) => {
const h = makeHarness(t);
await h.bridge.startMoshSession(
h.event,
{ ...h.options, password: "saved-secret", requiresMfa: true },
{ moshClientLookup: h.lookupOpts },
);
h.spawns[0].emitData("Verification code:");
await new Promise((resolve) => h.sessions.get("mosh-test-session").flushPendingData(resolve));
assert.deepEqual(h.spawns[0].writes, []);
const prompt = h.sent.find((evt) =>
evt.channel === "netcatty:data" && evt.payload?.data?.includes("Verification code:"),
);
assert.ok(prompt, "expected the MFA prompt to reach the renderer");
assert.equal(prompt.payload.meta?.moshHandshakeRequiresUserInput, true);
});
test("startMoshSession marks the OpenSSH first-host confirmation as interactive", async (t) => {
const h = makeHarness(t);
await h.bridge.startMoshSession(h.event, h.options, { moshClientLookup: h.lookupOpts });
h.spawns[0].emitData(
"Are you sure you want to continue connecting (yes/no/[fingerprint])?",
);
await new Promise((resolve) => h.sessions.get("mosh-test-session").flushPendingData(resolve));
const prompt = h.sent.find((evt) =>
evt.channel === "netcatty:data" && evt.payload?.data?.includes("continue connecting"),
);
assert.ok(prompt, "expected the host confirmation prompt to reach the renderer");
assert.equal(prompt.payload.meta?.moshHandshakeRequiresUserInput, true);
});
test("startMoshSession exposes shared fail-closed keyboard-interactive prompts", async (t) => {
const h = makeHarness(t);
await h.bridge.startMoshSession(h.event, h.options, { moshClientLookup: h.lookupOpts });
const prompts = [
"Duo two-factor login\r\nPasscode or option (1-1):",
"\r\nCustom authentication>",
"\r\n验证码",
];
for (const text of prompts) {
h.spawns[0].emitData(text);
await new Promise((resolve) => h.sessions.get("mosh-test-session").flushPendingData(resolve));
const prompt = h.sent.find((evt) =>
evt.channel === "netcatty:data" && evt.payload?.data?.includes(text.trim()),
);
assert.ok(prompt, `expected prompt to reach the renderer: ${text}`);
assert.equal(prompt.payload.meta?.moshHandshakeRequiresUserInput, true, text);
}
});
test("startMoshSession password-only mode disables public-key authentication", async (t) => {
const h = makeHarness(t);
await h.bridge.startMoshSession(
h.event,
{ ...h.options, authMethod: "password", password: "saved-secret", useSshAgent: false },
{ moshClientLookup: h.lookupOpts },
);
assert.ok(h.spawns[0].args.includes("PubkeyAuthentication=no"));
assert.ok(h.spawns[0].args.includes("PreferredAuthentications=password,keyboard-interactive"));
});
test("startMoshSession MFA password mode prefers keyboard-interactive", async (t) => {
const h = makeHarness(t);
await h.bridge.startMoshSession(
h.event,
{
...h.options,
authMethod: "password",
password: "saved-secret",
requiresMfa: true,
useSshAgent: false,
},
{ moshClientLookup: h.lookupOpts },
);
assert.ok(h.spawns[0].args.includes("PubkeyAuthentication=no"));
assert.ok(h.spawns[0].args.includes("PreferredAuthentications=keyboard-interactive,password"));
});
test("startMoshSession MFA auto mode keeps publickey first and prefers keyboard-interactive", async (t) => {
const h = makeHarness(t);
await h.bridge.startMoshSession(
h.event,
{
...h.options,
authMethod: "auto",
password: "saved-secret",
requiresMfa: true,
identityFilePaths: [path.join(os.tmpdir(), "netcatty-mosh-mfa-id_ed25519")],
useSshAgent: false,
},
{ moshClientLookup: h.lookupOpts },
);
assert.ok(h.spawns[0].args.includes("PreferredAuthentications=publickey,keyboard-interactive,password"));
});
test("startMoshSession key mode never probes unrelated default identities", async (t) => {
const h = makeHarness(t);
await h.bridge.startMoshSession(
h.event,
{
...h.options,
authMethod: "key",
password: "fallback-secret",
useSshAgent: false,
},
{ moshClientLookup: h.lookupOpts },
);
assert.ok(h.spawns[0].args.includes("IdentityFile=none"));
assert.ok(h.spawns[0].args.includes("IdentitiesOnly=yes"));
assert.equal(h.spawns[0].args.includes("PubkeyAuthentication=no"), false);
});
test("startMoshSession writes the saved password when ConPTY appends cursor controls to the prompt", async (t) => {
const h = makeHarness(t);
await h.bridge.startMoshSession(
h.event,
{ ...h.options, password: "saved-secret" },
{ moshClientLookup: h.lookupOpts },
);
h.spawns[0].emitData("alice@example.com's password: \x1b[?25h");
assert.deepEqual(h.spawns[0].writes, ["saved-secret\r"]);
});
test("startMoshSession passes vault private keys to ssh via a temp identity file", async (t) => {
const h = makeHarness(t);
await h.bridge.startMoshSession(
h.event,
{
...h.options,
keyId: "key-1",
privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----\nkey\n-----END OPENSSH PRIVATE KEY-----",
password: "wrong-password",
},
{ moshClientLookup: h.lookupOpts },
);
const keyFlagIndex = h.spawns[0].args.indexOf("-i");
assert.notEqual(keyFlagIndex, -1);
const keyPath = h.spawns[0].args[keyFlagIndex + 1];
assert.equal(fs.existsSync(keyPath), true);
assert.equal(h.spawns[0].args.includes("IdentitiesOnly=yes"), true);
assert.equal(h.spawns[0].args.includes("alice@example.com"), true);
h.spawns[0].emitExit({ exitCode: 255, signal: 0 });
assert.equal(fs.existsSync(keyPath), false);
});
test("startMoshSession passes certificates with reference identity files", async (t) => {
const h = makeHarness(t);
const referenceKeyPath = path.join(os.tmpdir(), "netcatty-reference-id_ed25519");
await h.bridge.startMoshSession(
h.event,
{
...h.options,
keyId: "reference-key-1",
identityFilePaths: [referenceKeyPath],
certificate: "ssh-ed25519-cert-v01@openssh.com AAAATEST netcatty-cert",
},
{ moshClientLookup: h.lookupOpts },
);
const keyFlagIndex = h.spawns[0].args.indexOf("-i");
assert.notEqual(keyFlagIndex, -1);
assert.equal(h.spawns[0].args[keyFlagIndex + 1], referenceKeyPath);
assert.equal(h.spawns[0].args.includes("IdentitiesOnly=yes"), true);
const certFlagIndex = h.spawns[0].args.findIndex((arg) =>
typeof arg === "string" && arg.startsWith("CertificateFile=")
);
assert.notEqual(certFlagIndex, -1);
const certPath = h.spawns[0].args[certFlagIndex].slice("CertificateFile=".length);
assert.equal(fs.existsSync(certPath), true);
assert.match(fs.readFileSync(certPath, "utf8"), /netcatty-cert/);
h.spawns[0].emitExit({ exitCode: 255, signal: 0 });
assert.equal(fs.existsSync(certPath), false);
});
test("startMoshSession uses unique temp identity files for concurrent sessions with the same key", async (t) => {
const h = makeHarness(t);
const authOptions = {
keyId: "key-1",
privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----\nkey\n-----END OPENSSH PRIVATE KEY-----",
};
await h.bridge.startMoshSession(
h.event,
{ ...h.options, ...authOptions },
{ moshClientLookup: h.lookupOpts },
);
await h.bridge.startMoshSession(
h.event,
{ ...h.options, ...authOptions, sessionId: "mosh-test-session-2" },
{ moshClientLookup: h.lookupOpts },
);
const firstKeyPath = h.spawns[0].args[h.spawns[0].args.indexOf("-i") + 1];
const secondKeyPath = h.spawns[1].args[h.spawns[1].args.indexOf("-i") + 1];
assert.notEqual(firstKeyPath, secondKeyPath);
assert.equal(fs.existsSync(firstKeyPath), true);
assert.equal(fs.existsSync(secondKeyPath), true);
h.spawns[0].emitExit({ exitCode: 255, signal: 0 });
assert.equal(fs.existsSync(firstKeyPath), false);
assert.equal(fs.existsSync(secondKeyPath), true);
h.spawns[1].emitExit({ exitCode: 255, signal: 0 });
assert.equal(fs.existsSync(secondKeyPath), false);
});
test("closeSession removes Mosh temp identity files even before ssh exits", async (t) => {
const h = makeHarness(t);
await h.bridge.startMoshSession(
h.event,
{
...h.options,
keyId: "key-1",
privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----\nkey\n-----END OPENSSH PRIVATE KEY-----",
},
{ moshClientLookup: h.lookupOpts },
);
const keyPath = h.spawns[0].args[h.spawns[0].args.indexOf("-i") + 1];
assert.equal(fs.existsSync(keyPath), true);
h.bridge.closeSession(h.event, { sessionId: "mosh-test-session" });
assert.equal(fs.existsSync(keyPath), false);
});
test("startMoshSession writes the saved passphrase when ssh prompts for the temp key", async (t) => {
const h = makeHarness(t);
await h.bridge.startMoshSession(
h.event,
{
...h.options,
privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----\nkey\n-----END OPENSSH PRIVATE KEY-----",
passphrase: "key-passphrase",
},
{ moshClientLookup: h.lookupOpts },
);
h.spawns[0].emitData("Enter passphrase for key 'mosh-auth-key-1.pem':");
await new Promise((resolve) => h.sessions.get("mosh-test-session").flushPendingData(resolve));
assert.deepEqual(h.spawns[0].writes, ["key-passphrase\r"]);
const prompt = h.sent.find((evt) =>
evt.channel === "netcatty:data" && evt.payload?.data?.includes("passphrase"),
);
assert.ok(prompt, "expected the saved-passphrase prompt to reach the renderer");
assert.notEqual(prompt.payload.meta?.moshHandshakeRequiresUserInput, true);
});
test("startMoshSession swaps to mosh-client when MOSH CONNECT has no trailing newline", async (t) => {
const h = makeHarness(t);
await h.bridge.startMoshSession(h.event, h.options, { moshClientLookup: h.lookupOpts });
// ConPTY / OpenSSH often exit immediately after printing the magic line
// with no final newline. The sniffer must flush on ssh exit (issue #2025).
h.spawns[0].emitData("MOSH CONNECT 60002 ABCDEFGHIJKLMNOPQRSTUV==");
h.spawns[0].emitExit({ exitCode: 0, signal: 0 });
assert.equal(h.spawns.length, 2);
assert.deepEqual(h.spawns[1].args.slice(-2), [h.options.hostname, "60002"]);
});
test("startMoshSession handshake path sends the existing exit event after mosh-client exits", async (t) => {
const h = makeHarness(t);
await h.bridge.startMoshSession(h.event, h.options, { moshClientLookup: h.lookupOpts });
h.spawns[0].emitData("MOSH CONNECT 60002 ABCDEFGHIJKLMNOPQRSTUV==\r\n");
h.spawns[0].emitExit({ exitCode: 0, signal: 0 });
assert.equal(h.spawns.length, 2);
h.spawns[1].emitExit({ exitCode: 0, signal: 0 });
const exit = h.sent.find((evt) => evt.channel === "netcatty:exit");
assert.ok(exit);
assert.equal(exit.payload.sessionId, "mosh-test-session");
assert.equal(exit.payload.reason, "exited");
});
test("startMoshSession keeps MoshCatty on Netcatty's primary terminal screen", async (t) => {
const h = makeHarness(t);
await h.bridge.startMoshSession(h.event, h.options, { moshClientLookup: h.lookupOpts });
h.spawns[0].emitData("MOSH CONNECT 60002 ABCDEFGHIJKLMNOPQRSTUV==\r\n");
h.spawns[0].emitExit({ exitCode: 0, signal: 0 });
assert.equal(h.spawns.length, 2);
assert.equal(h.spawns[1].opts.env.MOSH_NO_TERM_INIT, "1");
});
test("startMoshSession restores terminal modes on exit without leaving the primary screen", async (t) => {
const h = makeHarness(t);
await h.bridge.startMoshSession(h.event, h.options, { moshClientLookup: h.lookupOpts });
h.spawns[0].emitData("MOSH CONNECT 60002 ABCDEFGHIJKLMNOPQRSTUV==\r\n");
h.spawns[0].emitExit({ exitCode: 0, signal: 0 });
h.spawns[1].emitData("\x1b[?25l\x1b[?1000h");
h.spawns[1].emitExit({ exitCode: 1, signal: 0 });
const terminalData = h.sent
.filter((evt) => evt.channel === "netcatty:data")
.map((evt) => evt.payload.data)
.join("");
const exitIndex = h.sent.findIndex((evt) => evt.channel === "netcatty:exit");
const cleanupIndex = h.sent.findIndex(
(evt) => evt.channel === "netcatty:data" && evt.payload.data.includes("\x1b[?25h"),
);
assert.match(terminalData, /\x1b\[\?25h/);
assert.match(terminalData, /\x1b\[\?1000l/);
assert.doesNotMatch(terminalData, /\x1b\[\?1049l/);
assert.ok(cleanupIndex >= 0 && cleanupIndex < exitIndex);
});
test("startMoshSession forwards terminal shortcut escape sequences after the client swap", async (t) => {
const h = makeHarness(t);
await h.bridge.startMoshSession(h.event, h.options, { moshClientLookup: h.lookupOpts });
h.spawns[0].emitData("MOSH CONNECT 60002 ABCDEFGHIJKLMNOPQRSTUV==\r\n");
h.spawns[0].emitExit({ exitCode: 0, signal: 0 });
const shortcuts = [
"\x1b[A",
"\x1b[B",
"\x1b[C",
"\x1b[D",
"\x1b[1;5A",
"\x1b[1;5B",
"\x1b[1;5C",
"\x1b[1;5D",
"\x1b.",
];
for (const data of shortcuts) {
h.bridge.writeToSession(h.event, { sessionId: h.options.sessionId, data });
}
assert.deepEqual(h.spawns[1].writes, shortcuts);
assert.deepEqual(h.spawns[0].writes, []);
});
test("startMoshSession tags handshake output and emits ready after mosh-client swap", async (t) => {
const h = makeHarness(t);
await h.bridge.startMoshSession(h.event, h.options, { moshClientLookup: h.lookupOpts });
h.spawns[0].emitData("login banner\r\n");
// Pty output is coalesced and flushed on the next turn.
await new Promise((resolve) => setImmediate(resolve));
const handshakeData = h.sent.find((evt) =>
evt.channel === "netcatty:data" && evt.payload?.data?.includes("login banner"),
);
assert.ok(handshakeData, "expected handshake data on netcatty:data");
assert.equal(handshakeData.payload.meta?.moshHandshake, true);
assert.notEqual(handshakeData.payload.meta?.moshHandshakeRequiresUserInput, true);
assert.equal(
h.sent.some((evt) => evt.channel === "netcatty:mosh:ready"),
false,
"ready must not fire before the mosh-client swap",
);
h.spawns[0].emitData("MOSH CONNECT 60002 ABCDEFGHIJKLMNOPQRSTUV==\r\n");
h.spawns[0].emitExit({ exitCode: 0, signal: 0 });
const ready = h.sent.find((evt) => evt.channel === "netcatty:mosh:ready");
assert.ok(ready, "expected netcatty:mosh:ready after client swap");
assert.equal(ready.payload.sessionId, "mosh-test-session");
assert.equal(h.sessions.get("mosh-test-session")?.moshHandshakePhase, "mosh-client");
});
test("startMoshSession clears successful SSH bootstrap output before the Mosh screen", async (t) => {
const h = makeHarness(t);
await h.bridge.startMoshSession(h.event, h.options, { moshClientLookup: h.lookupOpts });
h.spawns[0].emitData(
"Welcome to Ubuntu\r\nmosh-server (mosh 1.4.0)\r\nLicense GPLv3+\r\n",
);
await new Promise((resolve) => setImmediate(resolve));
h.spawns[0].emitData("MOSH CONNECT 60002 ABCDEFGHIJKLMNOPQRSTUV==\r\n");
h.spawns[0].emitExit({ exitCode: 0, signal: 0 });
h.spawns[1].emitData(Buffer.from("root@example:~# "));
await new Promise((resolve) => h.sessions.get("mosh-test-session").flushPendingData(resolve));
const terminalData = h.sent
.filter((evt) => evt.channel === "netcatty:data")
.map((evt) => evt.payload.data)
.join("");
const bootstrapIndex = terminalData.indexOf("Welcome to Ubuntu");
const clearIndex = terminalData.indexOf("\x1b[2J\x1b[H");
const promptIndex = terminalData.indexOf("root@example:~# ");
assert.ok(bootstrapIndex >= 0, "the interactive SSH bootstrap should remain visible while connecting");
assert.ok(clearIndex > bootstrapIndex, "the successful handoff should clear bootstrap cells");
assert.ok(promptIndex > clearIndex, "the Mosh screen should render onto the cleared viewport");
});
test("startMoshSession stashes stats-companion auth after a successful handshake", async (t) => {
const h = makeHarness(t);
await h.bridge.startMoshSession(
h.event,
{
...h.options,
port: 2200,
authMethod: "auto",
password: "secret",
keyId: "key-1",
identityFilePaths: ["~/.ssh/id_work"],
agentPublicKeys: ["ssh-ed25519 AAAASELECTED"],
legacyAlgorithms: true,
skipEcdsaHostKey: true,
algorithmOverrides: { cipher: ["aes128-cbc"] },
},
{ moshClientLookup: h.lookupOpts },
);
// No stats auth before the handshake completes — a failed handshake must
// not leave usable credentials lying around for the companion connection.
const before = h.sessions.get("mosh-test-session");
assert.equal(before.moshStatsAuth, undefined);
h.spawns[0].emitData("MOSH CONNECT 60002 ABCDEFGHIJKLMNOPQRSTUV==\r\n");
h.spawns[0].emitExit({ exitCode: 0, signal: 0 });
const session = h.sessions.get("mosh-test-session");
assert.ok(session.moshStatsAuth, "expected moshStatsAuth to be set after swap");
assert.equal(session.moshStatsAuth.hostname, "example.com");
assert.equal(session.moshStatsAuth.port, 2200);
assert.equal(session.moshStatsAuth.username, "alice");
assert.equal(session.moshStatsAuth.authMethod, "auto");
assert.equal(session.moshStatsAuth.password, "secret");
assert.equal(session.moshStatsAuth.identityFilePaths[0], path.join(os.homedir(), ".ssh", "id_work"));
assert.deepEqual(session.moshStatsAuth.agentPublicKeys, ["ssh-ed25519 AAAASELECTED"]);
assert.equal(session.moshStatsAuth.legacyAlgorithms, true);
assert.equal(session.moshStatsAuth.skipEcdsaHostKey, true);
assert.deepEqual(session.moshStatsAuth.algorithmOverrides, { cipher: ["aes128-cbc"] });
});
test("closeSession ends a Mosh stats companion connection", async (t) => {
const h = makeHarness(t);
await h.bridge.startMoshSession(h.event, h.options, { moshClientLookup: h.lookupOpts });
h.spawns[0].emitData("MOSH CONNECT 60002 ABCDEFGHIJKLMNOPQRSTUV==\r\n");
h.spawns[0].emitExit({ exitCode: 0, signal: 0 });
// Simulate a lazily-opened companion ssh2 connection on the live session.
// It lives on moshStatsConn (separate from session.conn) per #1198.
const session = h.sessions.get("mosh-test-session");
let ended = false;
session.moshStatsConn = { end() { ended = true; } };
h.bridge.closeSession(h.event, { sessionId: "mosh-test-session" });
assert.equal(ended, true);
});
test("startMoshSession fails when bundled mosh-client is missing even if PATH has mosh-client", async (t) => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-mosh-session-missing-"));
t.after(() => fs.rmSync(tmp, { recursive: true, force: true }));
const binDir = path.join(tmp, "bin");
writeExecutable(path.join(binDir, "ssh"));
writeExecutable(path.join(binDir, "mosh-client"));
const spawns = [];
const bridge = loadBridgeWithFakePty(spawns);
bridge.init({
sessions: new Map(),
electronModule: {
webContents: {
fromId() {
return { send() {} };
},
},
},
});
await assert.rejects(
bridge.startMoshSession(
{ sender: { id: 42 } },
{
sessionId: "mosh-missing-bundled",
hostname: "example.com",
username: "alice",
env: { PATH: binDir },
},
{
moshClientLookup: {
platform: "linux",
arch: "x64",
projectRoot: tmp,
resourcesPath: path.join(tmp, "missing"),
},
},
),
/Bundled mosh-client not found/,
);
assert.equal(spawns.length, 0);
});