Files
NetMesh/electron/bridges/terminalBridge.moshHandshakeSession.test.cjs
zhaolei 3c72efcb7f
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
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

843 lines
29 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
});