* Bundle mosh-client via CI build pipeline Add a GitHub Actions workflow that builds a static, distro-portable mosh-client for linux-x64, linux-arm64, darwin-universal (arm64+x86_64) from upstream mobile-shell/mosh source, plus a pinned win32-x64 binary sourced from FluentTerminal (GPL-3.0). Releases attach SHA256SUMS so scripts/fetch-mosh-binaries.cjs can verify and pull the right binary into resources/mosh/<platform-arch>/ during npm run pack. electron-builder.config.cjs gains a moshExtraResources() helper that adds the binary to extraResources only when present on disk, keeping local dev packages working without bundled mosh. terminalBridge.cjs now exports bundledMoshClient() and prefers the bundled static client over whatever the system mosh wrapper would resolve via PATH (via the MOSH_CLIENT env var). The Windows branch throws a clear error pointing at Settings instead of silently falling back to a literal "mosh.exe" string when no wrapper is installed. This is Phase 1 — Phase 2 (follow-up) replaces the FluentTerminal Windows binary with an in-CI Cygwin static build and adds a Node-side mosh-server bootstrap so Mosh works out-of-the-box on Windows. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Phase 2: Node-side Mosh handshake (no Perl wrapper required) Reimplement what the upstream Mosh Perl wrapper does in pure Node: spawn `ssh [user@]host -- mosh-server new`, sniff the byte stream for `MOSH CONNECT <port> <key>`, then spawn `mosh-client` locally with MOSH_KEY in the environment. The new electron/bridges/moshHandshake.cjs module exposes the parser, sniffer, and command builders as pure functions so they can be unit tested without spawning real ssh. terminalBridge.startMoshSession now prefers this path whenever a bare mosh-client (bundled, explicit, or system) and ssh (in-box OpenSSH on Win10 1809+, system everywhere else) are both detectable. The legacy path through the system mosh Perl wrapper is preserved as a fallback so users with custom mosh setups don't regress. Auth is delegated to system ssh, so keys, agent, ssh_config, and known_hosts all keep working. Password / 2FA need a controlling TTY which the bootstrap doesn't provide; affected users keep the legacy wrapper path until interactive UI lands. Tests: - moshHandshake.test.cjs (20 tests) — parser corner cases, command builders, sniffer split-chunk handling, ring-buffer trim, exec resolver - terminalBridge.bareMoshClient.test.cjs (4 tests) — explicit-path basename gating 317 → 341 passing tests; lint clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Phase 3: in-CI Cygwin Windows build + visible PTY handshake Phase 3a — in-CI Cygwin Windows build - scripts/build-mosh/build-windows.sh builds mosh-client.exe from upstream mobile-shell/mosh source inside Cygwin, then walks the cygcheck import graph to bundle every required Cygwin DLL (cygwin1.dll, cygcrypto, cygprotobuf, cygncursesw, etc) into a tar.gz alongside the exe. - The `build-mosh-binaries` workflow swaps the FluentTerminal-pinned fetch job for a real Cygwin build (windows-latest + cygwin-install- action). fetch-windows.sh is preserved as an emergency fallback but no longer wired into the matrix. - fetch-mosh-binaries.cjs unpacks the tar.gz into resources/mosh/ win32-x64/ so mosh-client.exe sits next to its DLLs. - mosh-extra-resources.cjs ships the entire win32-x64/ dir (exe + DLL bundle) into Resources/mosh/, so the packaged installer runs on a stock Windows host with no Cygwin install. Phase 3b — visible PTY handshake (password / 2FA prompts) - terminalBridge.startMoshSession now spawns ssh inside node-pty so the user sees and can answer password / 2FA / known-hosts prompts in their terminal. When `MOSH CONNECT` is sniffed from the byte stream, session.proc is atomically swapped from the ssh PTY to a freshly-spawned mosh-client PTY. The MOSH CONNECT line itself is redacted from the visible output. - writeToSession / resizeSession read session.proc lazily, so input arriving after the swap goes to mosh-client without extra wiring. - The ZMODEM sentry is recreated for the new proc since its writeToRemote closure captured the previous handle. - Removes the earlier non-PTY child_process.spawn handshake — the PTY-based one supersedes it. Phase 3c — win32-arm64 deferred - Cygwin's arm64 port has no stable cygwin1.dll release yet, so we do not attempt an arm64 Windows build. arm64 Windows installs fall through to the legacy `mosh` wrapper path that the bridge already handles. Documented in the workflow. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Allow branch/PR pushes to test the mosh-binaries workflow Mirrors the build-packages workflow change in #868: any push or PR that touches the mosh build pipeline triggers the matrix (artifacts only, no release), while only `mosh-bin-*` tag pushes (or an explicit workflow_dispatch with release_tag) publish a release. `paths` filter keeps unrelated commits from running this expensive workflow (~30min for the Cygwin leg). Concurrency group cancels superseded branch/PR builds; tag builds use a unique group so a follow-up commit can't kill an in-progress release. Release job's `if:` enforces the same rule independently — even if the trigger gets re-broadened, branches/PRs can't leak a release. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Fix mosh binary workflow runners * Fix Windows mosh workflow invocation * Keep shell scripts LF in workflow checkouts * Trigger mosh workflow on attributes changes * Fix mosh build tool dependencies * Fix Linux mosh static build * Fix macOS mosh build tool lookup * Skip macOS ncurses terminfo install * Fix mosh PR review findings * Allow Linux system mosh dependencies * Fix Windows mosh DLL bundling * Limit bundled Windows mosh DLLs * Honor configured PATH for mosh handshake --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
230 lines
8.9 KiB
JavaScript
230 lines
8.9 KiB
JavaScript
const test = require("node:test");
|
|
const assert = require("node:assert/strict");
|
|
|
|
const {
|
|
parseMoshConnect,
|
|
buildSshHandshakeCommand,
|
|
buildMoshServerCommand,
|
|
buildMoshClientCommand,
|
|
createMoshConnectSniffer,
|
|
buildMoshClientEnv,
|
|
resolveSshExecutable,
|
|
} = require("./moshHandshake.cjs");
|
|
|
|
test("parseMoshConnect captures port and key from a typical mosh-server line", () => {
|
|
const line = "Welcome\r\nMOSH CONNECT 60001 ABCDEFGHIJKLMNOPQRSTUV==\r\n";
|
|
const got = parseMoshConnect(line);
|
|
assert.deepEqual(got && { port: got.port, key: got.key }, {
|
|
port: 60001,
|
|
key: "ABCDEFGHIJKLMNOPQRSTUV==",
|
|
});
|
|
});
|
|
|
|
test("parseMoshConnect accepts unpadded base64 keys (length 22)", () => {
|
|
const line = "MOSH CONNECT 60005 abcdefghijklmnopqrstuv\n";
|
|
const got = parseMoshConnect(line);
|
|
assert.equal(got && got.port, 60005);
|
|
assert.equal(got && got.key.length, 22);
|
|
});
|
|
|
|
test("parseMoshConnect rejects out-of-range ports", () => {
|
|
assert.equal(parseMoshConnect("MOSH CONNECT 99999 ABCDEFGHIJKLMNOPQRSTUV==\n"), null);
|
|
assert.equal(parseMoshConnect("MOSH CONNECT 0 ABCDEFGHIJKLMNOPQRSTUV==\n"), null);
|
|
});
|
|
|
|
test("parseMoshConnect rejects implausibly short keys (substring noise)", () => {
|
|
assert.equal(parseMoshConnect("MOSH CONNECT 60000 abc\n"), null);
|
|
});
|
|
|
|
test("parseMoshConnect handles a Buffer chunk", () => {
|
|
const buf = Buffer.from("garbage MOSH CONNECT 60010 ABCDEFGHIJKLMNOPQRSTUV==\n");
|
|
const got = parseMoshConnect(buf);
|
|
assert.equal(got && got.port, 60010);
|
|
});
|
|
|
|
test("buildSshHandshakeCommand omits -t and uses default port", () => {
|
|
const got = buildSshHandshakeCommand({ host: "example.com", username: "alice" });
|
|
assert.equal(got.command, "ssh");
|
|
assert.deepEqual(got.args, [
|
|
"alice@example.com",
|
|
"--",
|
|
"LC_ALL='en_US.UTF-8' mosh-server new -s",
|
|
]);
|
|
});
|
|
|
|
test("buildSshHandshakeCommand passes a non-default port via -p", () => {
|
|
const got = buildSshHandshakeCommand({ host: "example.com", port: 2222 });
|
|
assert.deepEqual(got.args.slice(0, 2), ["-p", "2222"]);
|
|
});
|
|
|
|
test("buildSshHandshakeCommand interpolates lang and moshServer overrides", () => {
|
|
const got = buildSshHandshakeCommand({
|
|
host: "h",
|
|
lang: "zh_CN.UTF-8",
|
|
moshServer: "/opt/mosh/bin/mosh-server new -s -c 256",
|
|
});
|
|
assert.equal(got.args.at(-1), "LC_ALL='zh_CN.UTF-8' /opt/mosh/bin/mosh-server new -s -c 256");
|
|
});
|
|
|
|
test("buildSshHandshakeCommand shell-quotes lang values", () => {
|
|
const got = buildSshHandshakeCommand({
|
|
host: "h",
|
|
lang: "C; touch /tmp/netcatty-owned",
|
|
});
|
|
assert.equal(got.args.at(-1), "LC_ALL='C; touch /tmp/netcatty-owned' mosh-server new -s");
|
|
});
|
|
|
|
test("buildMoshServerCommand treats custom server input as a path", () => {
|
|
assert.equal(
|
|
buildMoshServerCommand("/opt/Mosh Tools/mosh-server; touch /tmp/nope"),
|
|
"'/opt/Mosh Tools/mosh-server; touch /tmp/nope' new -s",
|
|
);
|
|
});
|
|
|
|
test("buildSshHandshakeCommand throws when host is missing", () => {
|
|
assert.throws(() => buildSshHandshakeCommand({}), /host is required/);
|
|
});
|
|
|
|
test("buildMoshClientCommand wires moshClientPath, host, port", () => {
|
|
const got = buildMoshClientCommand({
|
|
moshClientPath: "/usr/local/bin/mosh-client",
|
|
host: "10.0.0.1",
|
|
port: 60001,
|
|
});
|
|
assert.equal(got.command, "/usr/local/bin/mosh-client");
|
|
assert.deepEqual(got.args, ["10.0.0.1", "60001"]);
|
|
});
|
|
|
|
test("buildMoshClientCommand validates inputs", () => {
|
|
assert.throws(() => buildMoshClientCommand({ host: "h", port: 1 }), /moshClientPath/);
|
|
assert.throws(() => buildMoshClientCommand({ moshClientPath: "x", port: 1 }), /host/);
|
|
assert.throws(() => buildMoshClientCommand({ moshClientPath: "x", host: "h", port: 0 }), /port/);
|
|
});
|
|
|
|
test("createMoshConnectSniffer detects MOSH CONNECT split across chunks", () => {
|
|
const sniffer = createMoshConnectSniffer();
|
|
const r1 = sniffer.feed("login as: alice\r\nlast login: yesterday\r\nMOSH CONNE");
|
|
assert.equal(r1.parsed, null);
|
|
assert.ok(!String(r1.visible).includes("MOSH CONNE"));
|
|
const r2 = sniffer.feed("CT 60002 ABCDEFGHIJKLMNOPQRSTUV==\r\n");
|
|
assert.deepEqual(r2.parsed, { port: 60002, key: "ABCDEFGHIJKLMNOPQRSTUV==" });
|
|
assert.ok(!String(r2.visible).includes("MOSH CONNECT"));
|
|
assert.ok(!String(r2.visible).includes("ABCDEFGHIJKLMNOPQRSTUV=="));
|
|
});
|
|
|
|
test("createMoshConnectSniffer does not leak a split MOSH key", () => {
|
|
const sniffer = createMoshConnectSniffer();
|
|
const r1 = sniffer.feed("intro\r\nMOSH CONNECT 60002 ABCDEFGHIJ");
|
|
assert.equal(r1.parsed, null);
|
|
assert.equal(String(r1.visible), "intro\r\n");
|
|
const r2 = sniffer.feed("KLMNOPQRSTUV==\r\n");
|
|
assert.deepEqual(r2.parsed, { port: 60002, key: "ABCDEFGHIJKLMNOPQRSTUV==" });
|
|
assert.equal(String(r2.visible), "");
|
|
});
|
|
|
|
test("createMoshConnectSniffer passes through prompts without waiting for a newline", () => {
|
|
const sniffer = createMoshConnectSniffer();
|
|
const r = sniffer.feed("password:");
|
|
assert.equal(r.parsed, null);
|
|
assert.equal(String(r.visible), "password:");
|
|
});
|
|
|
|
test("createMoshConnectSniffer ignores invalid MOSH CONNECT lines", () => {
|
|
for (const line of [
|
|
"MOSH CONNECT 99999 ABCDEFGHIJKLMNOPQRSTUV==\r\n",
|
|
"MOSH CONNECT 0 ABCDEFGHIJKLMNOPQRSTUV==\r\n",
|
|
"MOSH CONNECT 60000 short\r\n",
|
|
"MOSH CONNECT 60000 ABCDEFGHIJKLMNOPQRSTUVWXYZ\r\n",
|
|
"MOSH CONNECT 60000 ABCDEFGHIJKLMNOPQRSTUV==oops\r\n",
|
|
]) {
|
|
const sniffer = createMoshConnectSniffer();
|
|
const r = sniffer.feed(line);
|
|
assert.equal(r.parsed, null, line);
|
|
}
|
|
});
|
|
|
|
test("createMoshConnectSniffer captures MOSH IP without showing protocol lines", () => {
|
|
const sniffer = createMoshConnectSniffer();
|
|
const r = sniffer.feed("welcome\r\nMOSH IP 203.0.113.8\r\nMOSH CONNECT 60002 ABCDEFGHIJKLMNOPQRSTUV==\r\n");
|
|
assert.deepEqual(r.parsed, { port: 60002, key: "ABCDEFGHIJKLMNOPQRSTUV==", host: "203.0.113.8" });
|
|
assert.equal(String(r.visible), "welcome\r\n");
|
|
});
|
|
|
|
test("createMoshConnectSniffer ignores unsafe MOSH IP values", () => {
|
|
const sniffer = createMoshConnectSniffer();
|
|
const r = sniffer.feed("MOSH IP --help\r\nMOSH CONNECT 60002 ABCDEFGHIJKLMNOPQRSTUV==\r\n");
|
|
assert.deepEqual(r.parsed, { port: 60002, key: "ABCDEFGHIJKLMNOPQRSTUV==" });
|
|
});
|
|
|
|
test("createMoshConnectSniffer strips the magic line from visible output", () => {
|
|
const sniffer = createMoshConnectSniffer();
|
|
const chunk = "shell prompt $ \r\nMOSH CONNECT 60003 ABCDEFGHIJKLMNOPQRSTUV==\r\nbye\r\n";
|
|
const { visible, parsed } = sniffer.feed(chunk);
|
|
assert.deepEqual(parsed, { port: 60003, key: "ABCDEFGHIJKLMNOPQRSTUV==" });
|
|
assert.ok(!String(visible).includes("MOSH CONNECT"), "visible output should not leak the marker");
|
|
});
|
|
|
|
test("createMoshConnectSniffer is idempotent after a parse", () => {
|
|
const sniffer = createMoshConnectSniffer();
|
|
const r1 = sniffer.feed("MOSH CONNECT 60010 ABCDEFGHIJKLMNOPQRSTUV==\r\n");
|
|
assert.ok(r1.parsed);
|
|
// Second feed should not re-parse / re-strip — it just passes through.
|
|
const r2 = sniffer.feed("trailing bytes after handshake\r\n");
|
|
assert.equal(r2.parsed, null);
|
|
assert.equal(String(r2.visible), "trailing bytes after handshake\r\n");
|
|
});
|
|
|
|
test("createMoshConnectSniffer trims its ring buffer so old data doesn't accumulate", () => {
|
|
const sniffer = createMoshConnectSniffer();
|
|
// Feed >> RING_SIZE (4096) bytes of harmless output.
|
|
for (let i = 0; i < 10; i += 1) {
|
|
const r = sniffer.feed("x".repeat(1024));
|
|
assert.equal(r.parsed, null);
|
|
}
|
|
// Now feed a CONNECT line — ring trimming must not have lost the
|
|
// ability to match a fresh marker.
|
|
const r = sniffer.feed("MOSH CONNECT 60020 ABCDEFGHIJKLMNOPQRSTUV==\r\n");
|
|
assert.equal(r.parsed && r.parsed.port, 60020);
|
|
});
|
|
|
|
test("buildMoshClientEnv injects MOSH_KEY without mutating the input env", () => {
|
|
const base = { LANG: "C", PATH: "/x" };
|
|
const env = buildMoshClientEnv({ baseEnv: base, key: "deadbeef", lang: "C" });
|
|
assert.equal(env.MOSH_KEY, "deadbeef");
|
|
assert.equal(env.PATH, "/x");
|
|
assert.equal(base.MOSH_KEY, undefined, "input env should not be mutated");
|
|
});
|
|
|
|
test("buildMoshClientEnv defaults TERM when missing", () => {
|
|
const env = buildMoshClientEnv({ baseEnv: {}, key: "k", lang: "C" });
|
|
assert.equal(env.TERM, "xterm-256color");
|
|
});
|
|
|
|
test("resolveSshExecutable prefers PATH lookups", () => {
|
|
const resolved = resolveSshExecutable({
|
|
findExecutable: () => "/opt/ssh/bin/ssh",
|
|
fileExists: () => true,
|
|
platform: "linux",
|
|
});
|
|
assert.equal(resolved, "/opt/ssh/bin/ssh");
|
|
});
|
|
|
|
test("resolveSshExecutable falls back to in-box OpenSSH on win32", () => {
|
|
process.env.SystemRoot = "C:\\Windows";
|
|
const resolved = resolveSshExecutable({
|
|
findExecutable: () => "ssh", // fakes "not found, returns the bare name"
|
|
fileExists: (p) => p.endsWith("OpenSSH\\ssh.exe"),
|
|
platform: "win32",
|
|
});
|
|
assert.equal(resolved, "C:\\Windows\\System32\\OpenSSH\\ssh.exe");
|
|
});
|
|
|
|
test("resolveSshExecutable returns null when nothing is found", () => {
|
|
const resolved = resolveSshExecutable({
|
|
findExecutable: () => "ssh",
|
|
fileExists: () => false,
|
|
platform: "linux",
|
|
});
|
|
assert.equal(resolved, null);
|
|
});
|