Fix Windows mosh startup

Fix Windows mosh startup
This commit is contained in:
陈大猫
2026-05-07 01:31:09 +08:00
committed by GitHub
parent 8efdd1c9cb
commit 3efc9ada8e
15 changed files with 853 additions and 292 deletions

View File

@@ -9,7 +9,7 @@ name: build-mosh-binaries
# (`binaricat/Netcatty-mosh-bin` by default).
#
# `paths` keeps unrelated commits (UI, bridges, etc) from rebuilding
# mosh on every push — this workflow is expensive (~30min Cygwin leg).
# or refreshing mosh binaries on every push.
on:
workflow_dispatch:
inputs:
@@ -129,48 +129,22 @@ jobs:
path: out/
# ------------------------------------------------------------------
# Windows x64 — in-CI Cygwin build from upstream mobile-shell/mosh
# source. Cygwin's POSIX runtime can't be fully statically linked, so
# we accept the dynamic Cygwin DLL deps and bundle them alongside the
# exe (cygcheck-discovered, ~10 MB total). The pinned-FluentTerminal
# path is preserved as `fetch-windows.sh` for emergency fallback.
# Windows x64 pinned standalone client.
# Do not compile this in CI: the upstream Cygwin build can clear the
# terminal and never render output on Windows. Ship the SHA256-pinned
# FluentTerminal standalone binary verified by fetch-windows.sh.
# ------------------------------------------------------------------
build-windows-x64:
name: build-windows-x64
runs-on: windows-latest
fetch-windows-x64:
name: fetch-windows-x64
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Cygwin
uses: cygwin/cygwin-install-action@v5
with:
add-to-path: false
# Keep package signature checks, but avoid the setup.exe hash
# fetch path that currently fails on windows-latest runners.
check-hash: false
packages: >
gcc-g++ make autoconf automake libtool perl perl_pods pkg-config git
openssl-devel libssl-devel libprotobuf-devel libncurses-devel
libncursesw-devel zlib-devel protobuf-compiler
- name: Build mosh-client.exe (win32-x64)
shell: pwsh
- name: Fetch pinned mosh-client.exe (win32-x64)
run: |
$ErrorActionPreference = "Stop"
$cygwinBin = "C:\cygwin\bin"
$workspace = (& "$cygwinBin\cygpath.exe" -u "$env:GITHUB_WORKSPACE").Trim()
$scriptPath = Join-Path $env:RUNNER_TEMP "build-mosh-windows.sh"
$script = @'
set -euo pipefail
cd "__WORKSPACE__"
export MOSH_REF="${MOSH_REF:?missing MOSH_REF}"
export ARCH=x64
export OUT_DIR="__WORKSPACE__/out"
export OUT_DIR="${GITHUB_WORKSPACE}/out"
mkdir -p "$OUT_DIR"
bash scripts/build-mosh/build-windows.sh
'@
$script = $script.Replace("__WORKSPACE__", $workspace).Replace("`r`n", "`n")
Set-Content -Path $scriptPath -Value $script -NoNewline -Encoding utf8
$scriptPathCygwin = (& "$cygwinBin\cygpath.exe" -u "$scriptPath").Trim()
& "$cygwinBin\bash.exe" --login "$scriptPathCygwin"
bash scripts/build-mosh/fetch-windows.sh
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
@@ -179,12 +153,8 @@ jobs:
# ------------------------------------------------------------------
# Windows arm64 — intentionally not built.
# Cygwin's arm64 port is still experimental (no stable cygwin1.dll
# release for aarch64 as of this commit), so we don't attempt an
# arm64 mosh build. arm64 Windows installs fall through to the
# legacy `mosh` wrapper path in terminalBridge.startMoshSession.
# When upstream Cygwin ships a stable arm64 build, drop the same
# cygwin-install-action job below with `platform: arm64`.
# The pinned upstream source only provides x64. arm64 Windows builds
# should be added only after we have a tested standalone arm64 client.
# ------------------------------------------------------------------
# ------------------------------------------------------------------
@@ -196,7 +166,7 @@ jobs:
- build-linux-x64
- build-linux-arm64
- build-macos-universal
- build-windows-x64
- fetch-windows-x64
runs-on: ubuntu-latest
if: github.event_name == 'workflow_dispatch' && inputs.release_tag != ''
permissions:
@@ -241,7 +211,8 @@ jobs:
fi
{
printf '%s\n' 'Pre-built `mosh-client` binaries consumed by `scripts/fetch-mosh-binaries.cjs` during `npm run pack`.'
printf 'Built from `mobile-shell/mosh` upstream ref `%s`.\n\n' "${MOSH_REF}"
printf 'Linux/macOS artifacts are built from `mobile-shell/mosh` upstream ref `%s`.\n' "${MOSH_REF}"
printf '%s\n\n' 'Windows x64 is the SHA256-pinned FluentTerminal standalone `mosh-client.exe` fallback.'
printf 'Source workflow: %s/%s/actions/runs/%s\n' "${GITHUB_SERVER_URL}" "${GITHUB_REPOSITORY}" "${GITHUB_RUN_ID}"
printf 'Source commit: `%s`\n\n' "${GITHUB_SHA}"
printf '%s\n' 'All artifacts are GPL-3.0; see `resources/mosh/README.md` for source provenance.'

View File

@@ -624,8 +624,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
pendingAuthRef,
termRef,
onUpdateHost,
onStartSsh: (term) => {
sessionStartersRef.current?.startSSH(term);
onStartSession: (term) => {
const starters = sessionStartersRef.current;
if (!starters) return;
if (host.moshEnabled) {
starters.startMosh(term);
return;
}
starters.startSSH(term);
},
setStatus: (next) => setStatus(next),
setProgressLogs,

View File

@@ -11,7 +11,7 @@ export const useTerminalAuthState = ({
pendingAuthRef,
termRef,
onUpdateHost,
onStartSsh,
onStartSession,
setStatus,
setProgressLogs,
}: {
@@ -19,7 +19,7 @@ export const useTerminalAuthState = ({
pendingAuthRef: RefObject<PendingAuth>;
termRef: RefObject<XTerm | null>;
onUpdateHost?: (host: Host) => void;
onStartSsh: (term: XTerm) => void;
onStartSession: (term: XTerm) => void;
setStatus: (status: TerminalSession["status"]) => void;
setProgressLogs: (next: string[] | ((prev: string[]) => string[])) => void;
}) => {
@@ -106,7 +106,7 @@ export const useTerminalAuthState = ({
logger.warn("Failed to clear terminal", err);
}
onStartSsh(term);
onStartSession(term);
},
[
authKeyId,
@@ -116,7 +116,7 @@ export const useTerminalAuthState = ({
authUsername,
host,
isValid,
onStartSsh,
onStartSession,
onUpdateHost,
pendingAuthRef,
saveCredentials,

View File

@@ -172,6 +172,248 @@ test("startMosh passes the saved password to the mosh backend", async () => {
assert.equal(capturedOptions.password, "saved-secret");
});
test("startMosh passes configured key material to the mosh backend", async () => {
let capturedOptions: Record<string, unknown> | null = null;
const terminalBackend = {
backendAvailable: () => true,
telnetAvailable: () => true,
moshAvailable: () => true,
localAvailable: () => true,
serialAvailable: () => true,
execAvailable: () => true,
startSSHSession: async () => "ssh-session",
startTelnetSession: async () => "telnet-session",
startMoshSession: async (options: Record<string, unknown>) => {
capturedOptions = options;
return "mosh-session";
},
startLocalSession: async () => "local-session",
startSerialSession: async () => "serial-session",
execCommand: async () => ({}),
onSessionData: () => noop,
onSessionExit: () => noop,
onChainProgress: () => noop,
writeToSession: noop,
resizeSession: noop,
};
const ctx = {
host: {
id: "host-1",
label: "Example",
hostname: "example.test",
username: "alice",
password: "wrong-password",
authMethod: "key",
identityFileId: "key-1",
identityFilePaths: ["/should/not/be/used"],
port: 2200,
},
keys: [{
id: "key-1",
label: "Deploy key",
privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----\nkey\n-----END OPENSSH PRIVATE KEY-----",
passphrase: "key-passphrase",
}],
resolvedChainHosts: [],
sessionId: "session-1",
terminalSettings: {},
terminalBackend,
sessionRef: { current: null },
hasConnectedRef: { current: false },
hasRunStartupCommandRef: { current: false },
disposeDataRef: { current: null },
disposeExitRef: { current: null },
fitAddonRef: { current: null },
serializeAddonRef: { current: null },
pendingAuthRef: { current: null },
updateStatus: noop,
setStatus: noop,
setError: noop,
setNeedsAuth: noop,
setAuthRetryMessage: noop,
setAuthPassword: noop,
setProgressLogs: noop,
setProgressValue: noop,
setChainProgress: noop,
};
const term = {
cols: 120,
rows: 32,
write: noop,
writeln: noop,
scrollToBottom: noop,
};
await createTerminalSessionStarters(ctx as never).startMosh(term as never);
assert.ok(capturedOptions);
assert.equal(capturedOptions.password, "wrong-password");
assert.equal(capturedOptions.privateKey, "-----BEGIN OPENSSH PRIVATE KEY-----\nkey\n-----END OPENSSH PRIVATE KEY-----");
assert.equal(capturedOptions.keyId, "key-1");
assert.equal(capturedOptions.passphrase, "key-passphrase");
assert.equal(capturedOptions.identityFilePaths, undefined);
});
test("startMosh asks for credential re-entry when saved key material cannot be decrypted", async () => {
let started = false;
let needsAuth = false;
let retryMessage: string | null = null;
const terminalBackend = {
backendAvailable: () => true,
telnetAvailable: () => true,
moshAvailable: () => true,
localAvailable: () => true,
serialAvailable: () => true,
execAvailable: () => true,
startSSHSession: async () => "ssh-session",
startTelnetSession: async () => "telnet-session",
startMoshSession: async () => {
started = true;
return "mosh-session";
},
startLocalSession: async () => "local-session",
startSerialSession: async () => "serial-session",
execCommand: async () => ({}),
onSessionData: () => noop,
onSessionExit: () => noop,
onChainProgress: () => noop,
writeToSession: noop,
resizeSession: noop,
};
const ctx = {
host: {
id: "host-1",
label: "Example",
hostname: "example.test",
username: "alice",
authMethod: "key",
identityFileId: "key-1",
port: 2200,
},
keys: [{
id: "key-1",
label: "Deploy key",
privateKey: "enc:v1:djEwAAAA",
}],
resolvedChainHosts: [],
sessionId: "session-1",
terminalSettings: {},
terminalBackend,
sessionRef: { current: null },
hasConnectedRef: { current: false },
hasRunStartupCommandRef: { current: false },
disposeDataRef: { current: null },
disposeExitRef: { current: null },
fitAddonRef: { current: null },
serializeAddonRef: { current: null },
pendingAuthRef: { current: null },
updateStatus: noop,
setStatus: noop,
setError: noop,
setNeedsAuth: (value: boolean) => { needsAuth = value; },
setAuthRetryMessage: (message: string | null) => { retryMessage = message; },
setAuthPassword: noop,
setProgressLogs: noop,
setProgressValue: noop,
setChainProgress: noop,
};
const term = {
cols: 120,
rows: 32,
write: noop,
writeln: noop,
scrollToBottom: noop,
};
await createTerminalSessionStarters(ctx as never).startMosh(term as never);
assert.equal(started, false);
assert.equal(needsAuth, true);
assert.match(retryMessage || "", /Saved credentials cannot be decrypted/);
});
test("startMosh omits identity file paths when password auth is explicit", async () => {
let capturedOptions: Record<string, unknown> | null = null;
const terminalBackend = {
backendAvailable: () => true,
telnetAvailable: () => true,
moshAvailable: () => true,
localAvailable: () => true,
serialAvailable: () => true,
execAvailable: () => true,
startSSHSession: async () => "ssh-session",
startTelnetSession: async () => "telnet-session",
startMoshSession: async (options: Record<string, unknown>) => {
capturedOptions = options;
return "mosh-session";
},
startLocalSession: async () => "local-session",
startSerialSession: async () => "serial-session",
execCommand: async () => ({}),
onSessionData: () => noop,
onSessionExit: () => noop,
onChainProgress: () => noop,
writeToSession: noop,
resizeSession: noop,
};
const ctx = {
host: {
id: "host-1",
label: "Example",
hostname: "example.test",
username: "alice",
authMethod: "password",
password: "saved-secret",
identityFilePaths: ["/should/not/be/used"],
port: 2200,
},
keys: [],
resolvedChainHosts: [],
sessionId: "session-1",
terminalSettings: {},
terminalBackend,
sessionRef: { current: null },
hasConnectedRef: { current: false },
hasRunStartupCommandRef: { current: false },
disposeDataRef: { current: null },
disposeExitRef: { current: null },
fitAddonRef: { current: null },
serializeAddonRef: { current: null },
pendingAuthRef: { current: null },
updateStatus: noop,
setStatus: noop,
setError: noop,
setNeedsAuth: noop,
setAuthRetryMessage: noop,
setAuthPassword: noop,
setProgressLogs: noop,
setProgressValue: noop,
setChainProgress: noop,
};
const term = {
cols: 120,
rows: 32,
write: noop,
writeln: noop,
scrollToBottom: noop,
};
await createTerminalSessionStarters(ctx as never).startMosh(term as never);
assert.ok(capturedOptions);
assert.equal(capturedOptions.password, "saved-secret");
assert.equal(capturedOptions.identityFilePaths, undefined);
});
test("startMosh rejects missing saved proxy profiles", async () => {
let started = false;
let error = "";
@@ -321,6 +563,80 @@ test("startMosh rejects configured proxies instead of connecting directly", asyn
assert.match(error, /Mosh does not support proxy/);
});
test("startMosh rejects jump host chains instead of connecting directly", async () => {
let started = false;
let error = "";
const terminalBackend = {
backendAvailable: () => true,
telnetAvailable: () => true,
moshAvailable: () => true,
localAvailable: () => true,
serialAvailable: () => true,
execAvailable: () => true,
startSSHSession: async () => "ssh-session",
startTelnetSession: async () => "telnet-session",
startMoshSession: async () => {
started = true;
return "mosh-session";
},
startLocalSession: async () => "local-session",
startSerialSession: async () => "serial-session",
execCommand: async () => ({}),
onSessionData: () => noop,
onSessionExit: () => noop,
onChainProgress: () => noop,
writeToSession: noop,
resizeSession: noop,
};
const ctx = {
host: {
id: "host-1",
label: "Example",
hostname: "example.test",
username: "alice",
hostChain: { hostIds: ["jump-1"] },
port: 2200,
},
keys: [],
resolvedChainHosts: [{ id: "jump-1", hostname: "jump.example.test" }],
sessionId: "session-1",
terminalSettings: {},
terminalBackend,
sessionRef: { current: null },
hasConnectedRef: { current: false },
hasRunStartupCommandRef: { current: false },
disposeDataRef: { current: null },
disposeExitRef: { current: null },
fitAddonRef: { current: null },
serializeAddonRef: { current: null },
pendingAuthRef: { current: null },
updateStatus: noop,
setStatus: noop,
setError: (message: string) => { error = message; },
setNeedsAuth: noop,
setAuthRetryMessage: noop,
setAuthPassword: noop,
setProgressLogs: noop,
setProgressValue: noop,
setChainProgress: noop,
};
const term = {
cols: 120,
rows: 32,
write: noop,
writeln: noop,
scrollToBottom: noop,
};
await createTerminalSessionStarters(ctx as never).startMosh(term as never);
assert.equal(started, false);
assert.match(error, /Mosh does not support jump host chains/);
});
test("startTelnet rejects missing saved proxy profiles", async () => {
let started = false;
let error = "";

View File

@@ -824,6 +824,14 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
return;
}
const hasConfiguredJumpHostChain =
(ctx.host.hostChain?.hostIds?.length || 0) > 0 ||
ctx.resolvedChainHosts.length > 0;
if (hasConfiguredJumpHostChain) {
stopMosh("Mosh does not support jump host chains. Use SSH for this host or remove the jump hosts from this connection.");
return;
}
const unresolvedJumpProxyHost = ctx.resolvedChainHosts.find((jumpHost) => jumpHost.proxyProfileId && !jumpHost.proxyConfig);
if (unresolvedJumpProxyHost) {
stopMosh(`Saved proxy for jump host "${unresolvedJumpProxyHost.label || unresolvedJumpProxyHost.hostname}" is missing. Open host settings and select a valid proxy.`);
@@ -854,12 +862,44 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
: null,
});
const effectivePassword = sanitizeCredentialValue(resolvedAuth.password);
const effectivePassphrase = sanitizeCredentialValue(resolvedAuth.passphrase);
const authMethod = resolvedAuth.authMethod;
const key = authMethod === "password" ? undefined : resolvedAuth.key;
const hasEncryptedPrimaryPassword = isEncryptedCredentialPlaceholder(resolvedAuth.password);
const hasEncryptedPrimaryKey = isEncryptedCredentialPlaceholder(resolvedAuth.key?.privateKey);
const hasKeyMaterial = !!sanitizeCredentialValue(key?.privateKey) && authMethod !== "password";
const hasPassword = !!effectivePassword;
const needsCredentialReentry =
(authMethod === "password" && hasEncryptedPrimaryPassword && !hasPassword) ||
(authMethod !== "password" && hasEncryptedPrimaryKey && !hasKeyMaterial && !hasPassword);
if (needsCredentialReentry) {
ctx.setError(null);
ctx.setNeedsAuth(true);
ctx.setAuthRetryMessage(
tr(
"terminal.auth.credentialsUnavailable",
"Saved credentials cannot be decrypted on this device. Please re-enter and save them again.",
),
);
ctx.setAuthPassword("");
ctx.setStatus("connecting");
return;
}
const moshEnv = buildTermEnv(ctx.host, ctx.terminalSettings);
const id = await ctx.terminalBackend.startMoshSession({
sessionId: ctx.sessionId,
hostname: ctx.host.hostname,
username: resolvedAuth.username || "root",
password: effectivePassword,
privateKey: sanitizeCredentialValue(key?.privateKey),
certificate: key?.certificate,
keyId: key?.id,
passphrase: key
? (effectivePassphrase || sanitizeCredentialValue(key.passphrase))
: undefined,
identityFilePaths: authMethod !== "password" && !key ? ctx.host.identityFilePaths : undefined,
port: ctx.host.port || 22,
moshServerPath: ctx.host.moshServerPath,
agentForwarding: ctx.host.agentForwarding,

View File

@@ -7,7 +7,9 @@ const os = require("node:os");
const fs = require("node:fs");
const net = require("node:net");
const { randomUUID } = require("node:crypto");
const { execFile } = require("node:child_process");
const path = require("node:path");
const { promisify } = require("node:util");
const { StringDecoder } = require("node:string_decoder");
const pty = require("node-pty");
const { SerialPort } = require("serialport");
@@ -20,6 +22,9 @@ const { trackSessionIdlePrompt } = require("./ai/shellUtils.cjs");
const { createZmodemSentry } = require("./zmodemHelper.cjs");
const { discoverShells } = require("./shellDiscovery.cjs");
const moshHandshake = require("./moshHandshake.cjs");
const tempDirBridge = require("./tempDirBridge.cjs");
const execFileAsync = promisify(execFile);
// Shared references
let sessions = null;
@@ -924,27 +929,166 @@ function addBundledMoshRuntimeEnv(env, bareClient, opts = {}) {
return env;
}
function createMoshSshPasswordResponder(sshPty, password) {
if (typeof password !== "string" || password.length === 0) {
function createMoshSshPasswordResponder(sshPty, password, passphrase) {
if (
(typeof password !== "string" || password.length === 0) &&
(typeof passphrase !== "string" || passphrase.length === 0)
) {
return () => {};
}
let answered = false;
let answeredPassword = false;
let answeredPassphrase = false;
let tail = "";
return (chunk) => {
if (answered) return;
if (answeredPassword && answeredPassphrase) return;
const text = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk || "");
if (!text) return;
tail = (tail + text).slice(-512);
if (typeof passphrase === "string" && passphrase.length > 0 && !answeredPassphrase && /(^|[\r\n]).*passphrase.*:\s*$/i.test(tail)) {
answeredPassphrase = true;
sshPty.write(`${passphrase}\r`);
return;
}
if (typeof password !== "string" || password.length === 0 || answeredPassword) return;
if (!/(^|[\r\n]).*password:\s*$/i.test(tail)) return;
answered = true;
answeredPassword = true;
sshPty.write(`${password}\r`);
};
}
function normalizeMoshIdentityPath(keyPath) {
if (typeof keyPath !== "string") return null;
const trimmed = keyPath.trim();
if (!trimmed) return null;
if (trimmed === "~") return os.homedir();
if (trimmed.startsWith("~/")) return path.join(os.homedir(), trimmed.slice(2));
return trimmed;
}
function safeMoshAuthFileName(sessionId, keyId, suffix) {
const safeId = String(keyId || sessionId || randomUUID())
.replace(/[^a-zA-Z0-9_-]/g, "_")
.slice(0, 80);
return `mosh-auth-${safeId}-${randomUUID()}${suffix}`;
}
async function writeMoshAuthTempFile(fileName, content) {
const target = tempDirBridge.getTempFilePath(fileName);
const normalized = content.endsWith("\n") ? content : `${content}\n`;
let created = false;
try {
const handle = await fs.promises.open(target, "wx", 0o600);
created = true;
await handle.close();
await restrictMoshAuthFilePermissions(target, { failClosed: true });
await fs.promises.writeFile(target, normalized, { flag: "w", mode: 0o600 });
try {
await fs.promises.chmod(target, 0o600);
} catch {
// Best effort on Windows; ACL hardening above is the security boundary.
}
} catch (err) {
if (created) cleanupMoshAuthTempFiles([target]);
throw err;
}
return target;
}
async function restrictMoshAuthFilePermissions(target, opts = {}) {
if (process.platform !== "win32") return true;
let username = process.env.USERNAME;
if (!username) {
try {
username = os.userInfo().username;
} catch {
username = "";
}
}
if (!username) {
if (opts.failClosed) {
throw new Error("Failed to restrict private key ACLs: unable to resolve current Windows user");
}
return false;
}
const identities = [];
if (process.env.USERDOMAIN) identities.push(`${process.env.USERDOMAIN}\\${username}`);
identities.push(username);
let lastError = null;
for (const identity of identities) {
try {
await execFileAsync("icacls.exe", [target, "/grant:r", `${identity}:F`], { windowsHide: true });
await execFileAsync("icacls.exe", [target, "/inheritance:r"], { windowsHide: true });
await execFileAsync("icacls.exe", [target, "/grant:r", `${identity}:F`], { windowsHide: true });
return true;
} catch (err) {
lastError = err;
}
}
const message = lastError?.message || String(lastError || "unknown error");
if (opts.failClosed) {
throw new Error(`Failed to restrict private key ACLs: ${message}`);
}
console.warn("[Mosh] Failed to restrict private key ACLs:", message);
return false;
}
function cleanupMoshAuthTempFiles(files) {
for (const file of files || []) {
try {
fs.unlinkSync(file);
} catch {
// Best effort cleanup; Settings > System can clear Netcatty temp files.
}
}
}
async function buildMoshSshAuthArgs(options, sessionId) {
const sshArgs = [];
const tempFiles = [];
try {
if (typeof options.privateKey === "string" && options.privateKey.trim().length > 0) {
const keyPath = await writeMoshAuthTempFile(
safeMoshAuthFileName(sessionId, options.keyId, ".pem"),
options.privateKey,
);
tempFiles.push(keyPath);
sshArgs.push("-i", keyPath, "-o", "IdentitiesOnly=yes");
if (typeof options.certificate === "string" && options.certificate.trim().length > 0) {
const certPath = await writeMoshAuthTempFile(
safeMoshAuthFileName(sessionId, options.keyId, "-cert.pub"),
options.certificate,
);
tempFiles.push(certPath);
sshArgs.push("-o", `CertificateFile=${certPath}`);
}
} else if (Array.isArray(options.identityFilePaths) && options.identityFilePaths.length > 0) {
for (const keyPath of options.identityFilePaths) {
const normalized = normalizeMoshIdentityPath(keyPath);
if (normalized) sshArgs.push("-i", normalized);
}
if (sshArgs.length > 0) {
sshArgs.push("-o", "IdentitiesOnly=yes");
}
}
} catch (err) {
cleanupMoshAuthTempFiles(tempFiles);
throw err;
}
return { sshArgs, tempFiles };
}
/**
* Phase-2 / Phase-3b path: run the SSH bootstrap ourselves *inside the
* user's terminal PTY* so password / 2FA / known-hosts prompts render
@@ -974,6 +1118,7 @@ async function startMoshSessionViaHandshake(event, options, { bareClient, sshExe
const rows = options.rows || 24;
const optionsEnv = options.env || {};
const lang = optionsEnv.LANG || resolveLangFromCharsetForMosh(options.charset);
const moshAuth = await buildMoshSshAuthArgs(options, sessionId);
const { args: sshArgs } = moshHandshake.buildSshHandshakeCommand({
host: options.hostname,
@@ -981,6 +1126,7 @@ async function startMoshSessionViaHandshake(event, options, { bareClient, sshExe
username: options.username,
lang,
moshServer: moshHandshake.buildMoshServerCommand(options.moshServerPath),
sshArgs: moshAuth.sshArgs,
});
const sshEnv = { ...process.env, ...optionsEnv, TERM: "xterm-256color" };
@@ -988,13 +1134,19 @@ async function startMoshSessionViaHandshake(event, options, { bareClient, sshExe
sshEnv.SSH_AUTH_SOCK = process.env.SSH_AUTH_SOCK;
}
const sshPty = pty.spawn(sshExe, sshArgs, {
cols,
rows,
env: sshEnv,
cwd: os.homedir(),
encoding: null,
});
let sshPty;
try {
sshPty = pty.spawn(sshExe, sshArgs, {
cols,
rows,
env: sshEnv,
cwd: os.homedir(),
encoding: null,
});
} catch (err) {
cleanupMoshAuthTempFiles(moshAuth.tempFiles);
throw err;
}
const session = {
proc: sshPty,
@@ -1015,6 +1167,7 @@ async function startMoshSessionViaHandshake(event, options, { bareClient, sshExe
rows,
moshHandshakePhase: "ssh",
moshHandshakeResult: null,
moshAuthTempFiles: moshAuth.tempFiles,
};
sessions.set(sessionId, session);
@@ -1035,7 +1188,7 @@ async function startMoshSessionViaHandshake(event, options, { bareClient, sshExe
session.flushPendingData = flush;
const sniffer = moshHandshake.createMoshConnectSniffer();
const respondToPasswordPrompt = createMoshSshPasswordResponder(sshPty, options.password);
const respondToPasswordPrompt = createMoshSshPasswordResponder(sshPty, options.password, options.passphrase);
// Forward bytes from the ssh PTY to the renderer, redacting the
// MOSH CONNECT magic line. ZMODEM is intentionally not enabled
@@ -1059,8 +1212,10 @@ async function startMoshSessionViaHandshake(event, options, { bareClient, sshExe
sshPty.onExit(({ exitCode, signal }) => {
if (sessions.get(sessionId) !== session || session.closed) {
cleanupMoshAuthTempFiles(moshAuth.tempFiles);
return;
}
cleanupMoshAuthTempFiles(moshAuth.tempFiles);
if (session.moshHandshakePhase === "parsed" && session.moshHandshakeResult) {
try {
@@ -1470,6 +1625,8 @@ function closeSession(event, payload) {
}
} catch (err) {
console.warn("Close failed", err);
} finally {
cleanupMoshAuthTempFiles(session.moshAuthTempFiles);
}
ptyProcessTree.unregisterPid(payload.sessionId);
sessions.delete(payload.sessionId);

View File

@@ -219,6 +219,98 @@ test("startMoshSession writes the saved password when ssh prompts for one", asyn
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 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':");
assert.deepEqual(h.spawns[0].writes, ["key-passphrase\r"]);
});
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 });

5
global.d.ts vendored
View File

@@ -178,6 +178,11 @@ declare global {
hostname: string;
username?: string;
password?: string;
privateKey?: string;
certificate?: string;
keyId?: string;
passphrase?: string;
identityFilePaths?: string[];
port?: number;
moshServerPath?: string;
moshClientPath?: string;

View File

@@ -8,23 +8,23 @@ directly (see `electron/bridges/moshHandshake.cjs` and
## How binaries land here
1. `.github/workflows/build-mosh-binaries.yml` builds `mosh-client` on
relevant pushes/PRs, or on a manual `workflow_dispatch`. It uses
`scripts/build-mosh/{build-linux,build-macos,build-windows}.sh` to
produce one binary per target from upstream `mobile-shell/mosh`
source:
1. `.github/workflows/build-mosh-binaries.yml` builds or fetches
`mosh-client` on relevant pushes/PRs, or on a manual
`workflow_dispatch`. It uses `scripts/build-mosh/build-linux.sh` and
`scripts/build-mosh/build-macos.sh` for Linux/macOS, and
`scripts/build-mosh/fetch-windows.sh` for the pinned Windows binary:
| target | provenance |
|-------------------|-----------------------------------------------------------------|
| `linux-x64` | upstream source, manylinux2014, static third-party deps + glibc |
| `linux-arm64` | upstream source, manylinux2014, static third-party deps + glibc |
| `darwin-universal`| upstream source, lipo arm64 + x86_64, macOS system dylibs only |
| `win32-x64` | upstream source, Cygwin GCC, ships with bundled Cygwin DLLs |
| `win32-x64` | FluentTerminal-pinned standalone fallback, SHA256 pinned |
| `win32-arm64` | (not built — Cygwin arm64 port not yet stable) |
`fetch-windows.sh` is preserved as an emergency fallback that pulls
the FluentTerminal-pinned binary; it's no longer wired into the
default workflow.
The upstream Cygwin Windows build path was removed from the default
workflow because the tested build clears the terminal but never
renders remote output on Windows.
2. When manually dispatched with `release_tag`, that workflow publishes
the binaries to the dedicated `binaricat/Netcatty-mosh-bin`
@@ -50,8 +50,8 @@ directly (see `electron/bridges/moshHandshake.cjs` and
whatever happens to be installed on the developer machine.
Official Windows package builds currently ship x64 only for bundled
Mosh coverage. Windows arm64 packaging should be re-enabled there
after the `build-mosh-binaries` workflow can produce `win32-arm64`.
Mosh coverage. Windows arm64 packaging should be added only after we
have a tested standalone arm64 client.
The directory is otherwise empty (binaries are gitignored).
@@ -61,12 +61,12 @@ The directory is otherwise empty (binaries are gitignored).
(https://github.com/mobile-shell/mosh).
- Netcatty is **GPL-3.0**, so redistribution as part of the installer
is permitted.
- The Windows binary is built in CI from upstream
https://github.com/mobile-shell/mosh @ tag `MOSH_REF` (default
`mosh-1.4.0`) using the Cygwin GCC toolchain. The bundled DLLs are
redistributable Cygwin runtime libraries — see
`mosh-client-win32-x64-dlls/README.txt` (generated by the build) for
the per-DLL license listing.
- The default Windows x64 binary is the FluentTerminal-pinned
standalone `mosh-client.exe` from
https://github.com/felixse/FluentTerminal @ commit `bad0f85`, pinned
by SHA256 in `scripts/fetch-mosh-binaries.cjs`. The old Cygwin build
path is intentionally not used for Windows releases while it
reproduces the blank-screen runtime issue.
- Bundled/static deps (OpenSSL Apache-2.0, protobuf BSD-3-Clause,
ncurses MIT) are compatible with GPL-3.0.
@@ -98,12 +98,12 @@ For macOS the build needs an Xcode toolchain; see
- Mosh startup requires Netcatty's bundled `mosh-client` and a usable
`ssh` client for the remote bootstrap. System-installed `mosh` /
`mosh-client` binaries are intentionally ignored.
- Windows binary built in-CI from upstream source via Cygwin GCC; ships
alongside `cygwin1.dll` + transitive deps so it runs on a stock
Windows machine without a Cygwin install.
- Windows x64 currently ships the FluentTerminal-pinned standalone
client because the upstream Cygwin bundle can blank after terminal
initialization on Windows.
## Roadmap
- Cygwin arm64 port stabilizes → add a `build-windows-arm64` matrix
leg using the same `build-windows.sh` script.
- Add Windows arm64 only after a tested standalone arm64 client is
available.
- Make `MOSH_REF` track upstream release tags automatically.

View File

@@ -1,175 +0,0 @@
#!/usr/bin/env bash
# Build mosh-client.exe from upstream mobile-shell/mosh source inside a
# Cygwin environment. Phase 1 pinned a third-party prebuilt
# (FluentTerminal); this rebuilds it in CI so we own the provenance
# end-to-end and ship the same upstream version everywhere.
#
# Cygwin doesn't make full static linking practical (cygwin1.dll
# implements the POSIX runtime; it must be present at runtime), so we
# bundle every required Cygwin DLL alongside `mosh-client.exe`. This
# keeps the binary reproducible and self-contained — the only
# environmental requirement is the Cygwin Project's GPL-3.0 DLLs, all
# of which we redistribute under their respective licenses.
#
# Inputs (env):
# MOSH_REF — git ref of mobile-shell/mosh (e.g. mosh-1.4.0)
# ARCH — x64 (only — Cygwin's arm64 port isn't release-ready)
# OUT_DIR — directory to write mosh-client-win32-<arch>.exe + DLL bundle
#
# Output:
# $OUT_DIR/mosh-client-win32-<arch>.exe
# $OUT_DIR/mosh-client-win32-<arch>-dlls/*.dll
# $OUT_DIR/terminfo/<first-letter-or-hash>/xterm-256color
# $OUT_DIR/mosh-client-win32-<arch>.sha256
#
# Expected to run inside a Cygwin bash login shell (set up by the CI's
# cygwin-install-action with development packages already installed).
set -euo pipefail
: "${MOSH_REF:?missing MOSH_REF}"
: "${ARCH:?missing ARCH}"
: "${OUT_DIR:?missing OUT_DIR}"
validate_mosh_ref() {
if [[ ! "$MOSH_REF" =~ ^[A-Za-z0-9][A-Za-z0-9._/-]*$ ]] \
|| [[ "$MOSH_REF" == *..* ]] \
|| [[ "$MOSH_REF" == *@\{* ]] \
|| [[ "$MOSH_REF" == */ ]] \
|| [[ "$MOSH_REF" == *.lock ]]; then
echo "ERROR: invalid MOSH_REF: $MOSH_REF" >&2
exit 1
fi
}
validate_mosh_ref
if [ "$ARCH" != "x64" ]; then
echo "ERROR: only ARCH=x64 supported by the Cygwin Windows build (got: $ARCH)." >&2
exit 1
fi
# Sanity: must run under Cygwin so we have access to cygcheck and the
# Cygwin gcc toolchain.
if ! uname -a | grep -qi CYGWIN; then
echo "ERROR: build-windows.sh must run inside a Cygwin shell." >&2
uname -a >&2
exit 1
fi
WORK=$(mktemp -d)
trap 'rm -rf "$WORK"' EXIT
mkdir -p "$OUT_DIR"
cd "$WORK"
# Build mosh against the Cygwin-supplied OpenSSL, protobuf, ncurses.
# Static linking against those is not supported by the upstream
# build for Cygwin, so we accept the dynamic deps and bundle the DLLs.
git init mosh
git -C mosh remote add origin https://github.com/mobile-shell/mosh.git
git -C mosh fetch --depth 1 origin "$MOSH_REF"
git -C mosh checkout --detach FETCH_HEAD
cd mosh
./autogen.sh
./configure --enable-completion=no --disable-server \
CXXFLAGS="-O2 -static-libgcc -static-libstdc++" \
LDFLAGS="-static-libgcc -static-libstdc++"
make -j"$(nproc)"
OUT_EXE="$OUT_DIR/mosh-client-win32-x64.exe"
DLL_DIR="$OUT_DIR/mosh-client-win32-x64-dlls"
TERMINFO_DIR="$OUT_DIR/terminfo"
mkdir -p "$DLL_DIR"
cp src/frontend/mosh-client.exe "$OUT_EXE"
strip "$OUT_EXE"
echo "--- file ---"
file "$OUT_EXE"
echo "--- size ---"
ls -lh "$OUT_EXE"
# Walk the import graph via cygcheck and copy every Cygwin-shipped DLL
# (paths that normalize to /usr/bin/) so the binary runs anywhere without
# an external Cygwin install.
echo "--- cygcheck ---"
CYGCHECK_OUT="$WORK/cygcheck.txt"
cygcheck "$OUT_EXE" | tee "$CYGCHECK_OUT"
bundled_count=0
while IFS= read -r line; do
candidate=$(printf '%s' "$line" | tr -d '\r' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
case "$candidate" in
*.dll|*.DLL)
# Convert Windows-style paths to Cygwin paths if present.
cyg_candidate=$(cygpath -u "$candidate" 2>/dev/null || echo "$candidate")
case "$cyg_candidate" in
/usr/bin/*.dll|/usr/bin/*.DLL)
if [ -f "$cyg_candidate" ]; then
base=$(basename "$cyg_candidate")
if [ ! -f "$DLL_DIR/$base" ]; then
cp "$cyg_candidate" "$DLL_DIR/$base"
echo "bundled DLL: $base"
bundled_count=$((bundled_count + 1))
fi
fi
;;
esac
;;
esac
done < "$CYGCHECK_OUT"
if [ "$bundled_count" -eq 0 ] || [ ! -f "$DLL_DIR/cygwin1.dll" ]; then
echo "ERROR: failed to bundle required Cygwin DLLs for mosh-client.exe." >&2
exit 1
fi
echo "--- bundled DLLs ---"
ls -lh "$DLL_DIR"
# mosh-client is linked to ncurses and looks up TERM=xterm-256color at
# startup. A bare Cygwin DLL bundle does not include the compiled terminfo
# database, so ship the exact entry that Netcatty uses.
TERMINFO_ENTRY=$(find /usr/share/terminfo -type f -name xterm-256color | head -n 1 || true)
if [ -z "$TERMINFO_ENTRY" ]; then
echo "ERROR: failed to find /usr/share/terminfo/**/xterm-256color for mosh-client.exe." >&2
exit 1
fi
mkdir -p "$TERMINFO_DIR/$(basename "$(dirname "$TERMINFO_ENTRY")")"
cp "$TERMINFO_ENTRY" "$TERMINFO_DIR/$(basename "$(dirname "$TERMINFO_ENTRY")")/xterm-256color"
echo "--- bundled terminfo ---"
find "$TERMINFO_DIR" -type f -print
# License: the Cygwin DLLs ship under various GPL-compatible licenses.
# Ship a top-level NOTICE so end users can see what we redistributed.
cat > "$DLL_DIR/README.txt" <<'EOF'
This directory bundles the Cygwin runtime DLLs required by
mosh-client.exe (built from https://github.com/mobile-shell/mosh ).
cygwin1.dll : LGPL-3.0 (Cygwin Project, https://cygwin.com/)
cygcrypto-*.dll : Apache-2.0 (OpenSSL Project, https://www.openssl.org/)
cygprotobuf-*.dll : BSD-3-Clause (Google, https://github.com/protocolbuffers/protobuf)
cygncursesw-*.dll : MIT-style (Free Software Foundation)
cygintl-*.dll : LGPL-2.1 (GNU gettext)
cyggcc_s-*.dll, cygstdc++ : GPL-3.0 with GCC Runtime Library Exception
The full text of each license is reproduced in the upstream source
tree of the respective project.
EOF
# Bundle exe + DLLs into a single tar.gz artifact for distribution.
# fetch-mosh-binaries.cjs unpacks the tarball into the local
# resources/mosh/win32-x64/ directory.
BUNDLE_TGZ="$OUT_DIR/mosh-client-win32-x64.tar.gz"
BUNDLE_DIR="$WORK/win32-x64-bundle"
mkdir -p "$BUNDLE_DIR"
cp "$OUT_EXE" "$BUNDLE_DIR/mosh-client.exe"
cp -R "$DLL_DIR" "$BUNDLE_DIR/mosh-client-win32-x64-dlls"
cp -R "$TERMINFO_DIR" "$BUNDLE_DIR/terminfo"
( cd "$BUNDLE_DIR" && tar -czf "$BUNDLE_TGZ" \
"mosh-client.exe" \
"mosh-client-win32-x64-dlls" \
"terminfo" )
( cd "$OUT_DIR" && sha256sum "mosh-client-win32-x64.exe" > "mosh-client-win32-x64.sha256" )
( cd "$OUT_DIR" && sha256sum "mosh-client-win32-x64.tar.gz" > "mosh-client-win32-x64.tar.gz.sha256" )
cat "$OUT_DIR/mosh-client-win32-x64.sha256"
cat "$OUT_DIR/mosh-client-win32-x64.tar.gz.sha256"

View File

@@ -1,12 +1,8 @@
#!/usr/bin/env bash
# Phase-1 source: pin to the FluentTerminal-shipped mosh-cygwin standalone
# Source: pin to the FluentTerminal-shipped mosh-cygwin standalone
# build (PE32+ x86-64, statically linked Cygwin runtime, no cygwin1.dll
# dependency). FluentTerminal is GPL-3.0 same license as netcatty
# and the binary itself is GPL-3.0 from upstream mobile-shell/mosh.
#
# Phase-2 replaced this fetch with an in-CI Cygwin build from upstream
# source so we own the provenance end-to-end.
#
# dependency). FluentTerminal is GPL-3.0, same license as Netcatty, and
# the binary itself is GPL-3.0 from upstream mobile-shell/mosh.
# The pinned commit is FluentTerminal master @ bad0f85 (2019-09-12), which
# is the commit where the prebuilt mosh-client.exe was added to the repo.
# Verifying SHA256 against a frozen value protects against silent updates.

View File

@@ -23,6 +23,13 @@
// MOSH_BIN_RES_DIR — override output dir for tests.
// MOSH_BIN_ALLOW_UNVERIFIED=true — explicit local escape hatch for mirrors
// without SHA256SUMS. Never use for release builds.
// MOSH_BIN_FORCE_WINDOWS_CYGWIN=true — debug escape hatch for the upstream
// Cygwin Windows bundle. The default Windows x64 asset
// is the FluentTerminal-pinned standalone client because
// the current Cygwin build clears the terminal and never
// renders remote output on Windows.
// MOSH_BIN_WINDOWS_LEGACY_URL / MOSH_BIN_WINDOWS_LEGACY_SHA256 — test/mirror
// overrides for that pinned Windows fallback.
const fs = require("node:fs");
const path = require("node:path");
@@ -35,15 +42,24 @@ const { main: resolveMoshBinRelease } = require("./resolve-mosh-bin-release.cjs"
const ROOT = path.resolve(__dirname, "..");
const DEFAULT_RES_DIR = path.join(ROOT, "resources", "mosh");
const WINDOWS_LEGACY_FLUENT_MOSH_CLIENT = {
id: "windows-fluentterminal-standalone",
file: "mosh-client-win32-x64.exe",
local: "win32-x64/mosh-client.exe",
url: "https://raw.githubusercontent.com/felixse/FluentTerminal/bad0f85/Dependencies/MoshExecutables/x64/mosh-client.exe",
sha256: "5a8d84ff205c6a0711e53b961f909484a892f42648807e52d46d4fa93c05e286",
};
// (file basename in the release -> relative subpath under resources/mosh/)
// Using flat names in the release for SHA256SUMS readability, then
// fanning out into platform-arch subdirs locally.
//
// All targets are tar.gz bundles containing the binary plus the runtime
// helpers each platform needs: ncurses terminfo on every platform, plus
// the Cygwin DLL set on Windows. Bundling terminfo lets the bundled
// statically-linked mosh-client work on minimal hosts that don't have a
// Linux/macOS targets are tar.gz bundles containing the binary plus the
// runtime helpers each platform needs. Windows x64 defaults to the
// SHA256-pinned FluentTerminal standalone exe because the tested Cygwin
// bundle clears the terminal and never renders remote output on Windows.
// Bundling terminfo lets bundled Posix mosh-client builds work on
// minimal hosts that don't have a
// system ncurses-base — see issue #890.
//
// `legacy` describes the pre-bundle artifact name some published mosh
@@ -67,18 +83,38 @@ const TARGETS = [
file: "mosh-client-darwin-universal.tar.gz", localDir: "darwin-universal", extract: "tar.gz",
legacy: { file: "mosh-client-darwin-universal", local: "darwin-universal/mosh-client" },
},
{ platform: "win32", arch: "x64", file: "mosh-client-win32-x64.tar.gz", localDir: "win32-x64", extract: "tar.gz" },
{
platform: "win32", arch: "x64",
file: "mosh-client-win32-x64.tar.gz", localDir: "win32-x64", extract: "tar.gz",
legacy: WINDOWS_LEGACY_FLUENT_MOSH_CLIENT,
preferLegacy: true,
},
];
function selectReleaseAsset(target, sums) {
function applyReleaseAssetOverrides(asset, opts = {}) {
if (asset.id !== WINDOWS_LEGACY_FLUENT_MOSH_CLIENT.id) return asset;
return {
...asset,
url: opts.windowsLegacyUrl || asset.url,
sha256: opts.windowsLegacySha256 || asset.sha256,
};
}
function selectReleaseAsset(target, sums, opts = {}) {
const primary = { file: target.file, extract: target.extract, local: target.local, localDir: target.localDir };
if (!target.legacy) return primary;
if (target.preferLegacy && !opts.forceWindowsCygwin) {
if (sums.has(target.legacy.file)) {
return { file: target.legacy.file, local: target.legacy.local };
}
return applyReleaseAssetOverrides(target.legacy, opts);
}
// SHA256SUMS unavailable (allowUnverified mirror) — keep the primary
// and let download / extraction errors surface naturally.
if (sums.size === 0) return primary;
if (sums.has(target.file)) return primary;
if (sums.has(target.legacy.file)) {
return { file: target.legacy.file, local: target.legacy.local };
return applyReleaseAssetOverrides({ file: target.legacy.file, local: target.legacy.local }, opts);
}
return primary;
}
@@ -299,13 +335,28 @@ function unpackTarGz(buf, target, { resDir }) {
return destDir;
}
function writeFlatAsset(buf, target, asset, { resDir }) {
const dest = path.join(resDir, asset.local);
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-mosh-flat-"));
const tmpDest = path.join(tmpRoot, path.basename(dest));
try {
fs.writeFileSync(tmpDest, buf);
if (target.platform !== "win32") fs.chmodSync(tmpDest, 0o755);
replaceDir(tmpRoot, path.dirname(dest));
} catch (err) {
fs.rmSync(tmpRoot, { recursive: true, force: true });
throw err;
}
return dest;
}
async function fetchOne(target, sums, opts) {
const { baseUrl, resDir, allowUnverified = false } = opts;
const asset = selectReleaseAsset(target, sums);
const asset = selectReleaseAsset(target, sums, opts);
if (asset.file !== target.file) {
log(`using legacy asset ${asset.file} (release predates the bundled tar.gz layout for ${target.platform}-${target.arch})`);
log(`using legacy asset ${asset.file} for ${target.platform}-${target.arch}`);
}
const url = `${baseUrl}/${asset.file}`;
const url = asset.url || `${baseUrl}/${asset.file}`;
let buf;
try {
buf = await follow(url);
@@ -313,7 +364,7 @@ async function fetchOne(target, sums, opts) {
throw new Error(`download failed for ${asset.file}: ${err.message}`);
}
const expected = sums.get(asset.file);
const expected = asset.sha256 || sums.get(asset.file);
const actual = crypto.createHash("sha256").update(buf).digest("hex");
if (expected && expected !== actual) {
throw new Error(`SHA256 mismatch for ${asset.file}: expected ${expected}, got ${actual}`);
@@ -331,10 +382,7 @@ async function fetchOne(target, sums, opts) {
return true;
}
const dest = path.join(resDir, asset.local);
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.writeFileSync(dest, buf);
if (target.platform !== "win32") fs.chmodSync(dest, 0o755);
const dest = writeFlatAsset(buf, target, asset, { resDir });
log(`wrote ${path.relative(ROOT, dest)} (${buf.length} bytes, sha256=${actual})`);
return true;
}
@@ -366,6 +414,7 @@ async function main(argv = process.argv.slice(2), env = process.env) {
`https://github.com/${owner}/${repo}/releases/download/${encodeURIComponent(release)}`;
const resDir = path.resolve(env.MOSH_BIN_RES_DIR || DEFAULT_RES_DIR);
const allowUnverified = env.MOSH_BIN_ALLOW_UNVERIFIED === "true";
const forceWindowsCygwin = env.MOSH_BIN_FORCE_WINDOWS_CYGWIN === "true";
const platformFilter = hostTarget?.platform || platformArg;
const archFilter = hostTarget?.arch || archArg;
@@ -377,7 +426,14 @@ async function main(argv = process.argv.slice(2), env = process.env) {
if (platformFilter && target.platform !== platformFilter) continue;
if (archFilter && target.arch !== archFilter) continue;
total += 1;
if (await fetchOne(target, sums, { baseUrl, resDir, allowUnverified })) ok += 1;
if (await fetchOne(target, sums, {
baseUrl,
resDir,
allowUnverified,
forceWindowsCygwin,
windowsLegacyUrl: env.MOSH_BIN_WINDOWS_LEGACY_URL,
windowsLegacySha256: env.MOSH_BIN_WINDOWS_LEGACY_SHA256,
})) ok += 1;
}
log(`done - ${ok}/${total} binaries written`);
if (ok < total) throw new Error(`only wrote ${ok}/${total} requested binaries`);
@@ -402,5 +458,6 @@ module.exports = {
validateTarEntries,
assertExtractedTreeSafe,
unpackTarGz,
writeFlatAsset,
main,
};

View File

@@ -197,6 +197,7 @@ test("fetch-mosh-binaries normalizes the Windows tarball to mosh-client.exe", as
MOSH_BIN_RELEASE: "test",
MOSH_BIN_BASE_URL: baseUrl,
MOSH_BIN_RES_DIR: resDir,
MOSH_BIN_FORCE_WINDOWS_CYGWIN: "true",
CI: "true",
},
stdio: "pipe",
@@ -224,6 +225,7 @@ test("fetch-mosh-binaries accepts legacy Windows bundles without terminfo", asyn
MOSH_BIN_RELEASE: "test",
MOSH_BIN_BASE_URL: baseUrl,
MOSH_BIN_RES_DIR: resDir,
MOSH_BIN_FORCE_WINDOWS_CYGWIN: "true",
CI: "true",
},
stdio: "pipe",
@@ -255,6 +257,7 @@ test("fetch-mosh-binaries rejects invalid Windows terminfo entries", async (t) =
MOSH_BIN_RELEASE: "test",
MOSH_BIN_BASE_URL: baseUrl,
MOSH_BIN_RES_DIR: resDir,
MOSH_BIN_FORCE_WINDOWS_CYGWIN: "true",
CI: "true",
},
stdio: "pipe",
@@ -282,6 +285,7 @@ test("fetch-mosh-binaries fails when SHA256SUMS lacks the requested asset", asyn
MOSH_BIN_RELEASE: "test",
MOSH_BIN_BASE_URL: baseUrl,
MOSH_BIN_RES_DIR: resDir,
MOSH_BIN_FORCE_WINDOWS_CYGWIN: "true",
CI: "true",
},
stdio: "pipe",
@@ -324,9 +328,85 @@ test("selectReleaseAsset stays on the primary when SHA256SUMS is empty (unverifi
assert.equal(selectReleaseAsset(target, new Map()).file, "mosh-client-linux-x64.tar.gz");
});
test("selectReleaseAsset prefers the pinned Windows standalone client by default", () => {
const target = {
platform: "win32", arch: "x64",
file: "mosh-client-win32-x64.tar.gz", localDir: "win32-x64", extract: "tar.gz",
legacy: {
id: "windows-fluentterminal-standalone",
file: "mosh-client-win32-x64.exe",
local: "win32-x64/mosh-client.exe",
url: "https://example.test/mosh-client.exe",
sha256: "abc",
},
preferLegacy: true,
};
const sums = new Map([["mosh-client-win32-x64.tar.gz", "def"]]);
assert.equal(selectReleaseAsset(target, sums).file, "mosh-client-win32-x64.exe");
assert.equal(selectReleaseAsset(target, sums, { forceWindowsCygwin: true }).file, "mosh-client-win32-x64.tar.gz");
});
test("selectReleaseAsset uses the released Windows standalone asset when published", () => {
const target = {
platform: "win32", arch: "x64",
file: "mosh-client-win32-x64.tar.gz", localDir: "win32-x64", extract: "tar.gz",
legacy: {
id: "windows-fluentterminal-standalone",
file: "mosh-client-win32-x64.exe",
local: "win32-x64/mosh-client.exe",
url: "https://example.test/mosh-client.exe",
sha256: "abc",
},
preferLegacy: true,
};
const asset = selectReleaseAsset(target, new Map([["mosh-client-win32-x64.exe", "def"]]));
assert.equal(asset.file, "mosh-client-win32-x64.exe");
assert.equal(asset.local, "win32-x64/mosh-client.exe");
assert.equal(asset.url, undefined);
assert.equal(asset.sha256, undefined);
});
test("fetch-mosh-binaries downloads the pinned Windows standalone client", async (t) => {
const resDir = path.join(makeTmp(t), "resources", "mosh");
const flat = Buffer.from("working-windows-standalone");
fs.mkdirSync(path.join(resDir, "win32-x64", "mosh-client-win32-x64-dlls"), { recursive: true });
fs.mkdirSync(path.join(resDir, "win32-x64", "terminfo", "78"), { recursive: true });
fs.writeFileSync(path.join(resDir, "win32-x64", "mosh-client-win32-x64-dlls", "cygwin1.dll"), "stale");
fs.writeFileSync(path.join(resDir, "win32-x64", "terminfo", "78", "xterm-256color"), "stale");
const baseUrl = await serveAssets(t, {
SHA256SUMS: "",
});
const legacyBaseUrl = await serveAssets(t, {
"mosh-client-win32-x64.exe": flat,
});
await execFileAsync(process.execPath, [script, "--platform=win32", "--arch=x64"], {
env: {
...process.env,
MOSH_BIN_RELEASE: "test",
MOSH_BIN_BASE_URL: baseUrl,
MOSH_BIN_RES_DIR: resDir,
MOSH_BIN_WINDOWS_LEGACY_URL: `${legacyBaseUrl}/mosh-client-win32-x64.exe`,
MOSH_BIN_WINDOWS_LEGACY_SHA256: sha256(flat),
CI: "true",
},
stdio: "pipe",
});
const dest = path.join(resDir, "win32-x64", "mosh-client.exe");
assert.equal(fs.existsSync(dest), true);
assert.equal(fs.readFileSync(dest, "utf8"), "working-windows-standalone");
assert.equal(fs.existsSync(path.join(resDir, "win32-x64", "mosh-client-win32-x64-dlls")), false);
assert.equal(fs.existsSync(path.join(resDir, "win32-x64", "terminfo")), false);
});
test("fetch-mosh-binaries falls back to the legacy flat asset for older releases", async (t) => {
const resDir = path.join(makeTmp(t), "resources", "mosh");
const flat = Buffer.from("legacy-binary");
fs.mkdirSync(path.join(resDir, "linux-x64", "terminfo", "78"), { recursive: true });
fs.writeFileSync(path.join(resDir, "linux-x64", "terminfo", "78", "xterm-256color"), "stale");
const baseUrl = await serveAssets(t, {
"mosh-client-linux-x64": flat,
SHA256SUMS: `${sha256(flat)} mosh-client-linux-x64\n`,
@@ -471,6 +551,7 @@ test("fetch-mosh-binaries rejects symlinks inside Windows tarballs", { skip: pro
MOSH_BIN_RELEASE: "test",
MOSH_BIN_BASE_URL: baseUrl,
MOSH_BIN_RES_DIR: path.join(makeTmp(t), "resources", "mosh"),
MOSH_BIN_FORCE_WINDOWS_CYGWIN: "true",
CI: "true",
},
stdio: "pipe",

View File

@@ -57,21 +57,22 @@ function moshExtraResources(platform) {
}
if (platform === "win32") {
// Windows ships mosh-client.exe + Cygwin DLL bundle (cygwin1.dll,
// cygcrypto-*.dll, etc.) plus the ncurses terminfo entry used by
// TERM=xterm-256color.
// Windows normally ships the pinned standalone mosh-client.exe. Keep
// optional DLL/terminfo packaging so older Cygwin bundles remain usable.
const arch = requestedArch();
const exe = path.join(moshRoot, `win32-${arch}`, "mosh-client.exe");
const dllDir = path.join(moshRoot, `win32-${arch}`, `mosh-client-win32-${arch}-dlls`);
if (!hasFile(exe) || !hasDir(dllDir)) return [];
if (!hasFile(exe)) return [];
const resources = [
{ from: `resources/mosh/win32-${arch}/`, to: "mosh/", filter: ["mosh-client.exe"] },
{
];
if (hasDir(dllDir)) {
resources.push({
from: `resources/mosh/win32-${arch}/mosh-client-win32-${arch}-dlls/`,
to: `mosh/mosh-client-win32-${arch}-dlls/`,
filter: ["**/*"],
},
];
});
}
const terminfoDir = path.join(moshRoot, `win32-${arch}`, "terminfo");
if (hasDir(terminfoDir)) {
resources.push({ from: `resources/mosh/win32-${arch}/terminfo/`, to: "mosh/terminfo/", filter: ["**/*"] });

View File

@@ -8,7 +8,10 @@ const { moshExtraResources } = require("./mosh-extra-resources.cjs");
function makeTmp(t) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-mosh-resources-"));
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
t.after(() => {
if (process.cwd().startsWith(dir)) process.chdir(os.tmpdir());
fs.rmSync(dir, { recursive: true, force: true });
});
return dir;
}
@@ -104,3 +107,14 @@ test("moshExtraResources keeps legacy Windows bundles packageable", (t) => {
},
]);
});
test("moshExtraResources packages standalone Windows mosh-client.exe", (t) => {
const root = makeTmp(t);
withCwdAndArch(t, root, "x64");
writeFile(path.join(root, "resources", "mosh", "win32-x64", "mosh-client.exe"));
const got = moshExtraResources("win32");
assert.deepEqual(got, [
{ from: "resources/mosh/win32-x64/", to: "mosh/", filter: ["mosh-client.exe"] },
]);
});