[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:
842
electron/bridges/terminalBridge.moshHandshakeSession.test.cjs
Normal file
842
electron/bridges/terminalBridge.moshHandshakeSession.test.cjs
Normal file
@@ -0,0 +1,842 @@
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user