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); });