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
1423 lines
60 KiB
JavaScript
1423 lines
60 KiB
JavaScript
/* eslint-disable no-undef */
|
|
const crypto = require("node:crypto");
|
|
const { createSystemKnownHostsApi } = require("../sshBridge/systemKnownHosts.cjs");
|
|
const {
|
|
buildAuthoritativeKnownHostsContent,
|
|
buildExternalHostKeyConfigLines,
|
|
buildExternalHostKeySshOptions,
|
|
vaultPinsConnectionHosts,
|
|
} = require("../externalSshHostKeyPolicy.cjs");
|
|
const { emitTerminalSessionData } = require("../emitTerminalSessionData.cjs");
|
|
const {
|
|
setBufferedOutputBytes,
|
|
shouldAcceptSessionOutput,
|
|
shouldProcessSessionOutput,
|
|
} = require("../terminalFlowAck.cjs");
|
|
const { orderSshIdentityNames, SSH_KEY_PATTERN } = require("../sshAuthHelper.cjs");
|
|
const { fanoutSessionExit } = require("../terminalAttachRestore.cjs");
|
|
|
|
//
|
|
// EternalTerminal session backend, factored into the createXxxSessionApi
|
|
// pattern used by moshSession.cjs / telnetSession.cjs. Dependencies arrive
|
|
// via `ctx`; `with (ctx)` exposes them as free identifiers.
|
|
//
|
|
// Unlike Mosh, the `et` client performs its own SSH bootstrap and ET protocol
|
|
// handshake — Netcatty just spawns the bundled `et` binary as a PTY. Saved
|
|
// credentials (password / passphrase / jump host) are injected into et's
|
|
// internal ssh via a private ~/.ssh home + SSH_ASKPASS helper, since et drives
|
|
// ssh itself rather than exposing the prompts for us to type into.
|
|
function createEtSessionApi(ctx) {
|
|
with (ctx) {
|
|
// Node script invoked by ssh as SSH_ASKPASS. It reads the prompt text from
|
|
// argv, matches it against the entries in NETCATTY_ET_ASKPASS_MAP, and
|
|
// prints the matching secret. Written to the session's private .ssh dir.
|
|
const ET_ASKPASS_SCRIPT = String.raw`#!/usr/bin/env node
|
|
const fs = require("node:fs");
|
|
const path = require("node:path");
|
|
|
|
function normalizePrompt(prompt) {
|
|
return String(prompt || "").toLowerCase();
|
|
}
|
|
|
|
function loadEntries() {
|
|
const mapPath = process.env.NETCATTY_ET_ASKPASS_MAP;
|
|
if (!mapPath) return [];
|
|
try {
|
|
const raw = fs.readFileSync(mapPath, "utf8");
|
|
const parsed = JSON.parse(raw);
|
|
return Array.isArray(parsed) ? parsed : [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function matchesPrompt(entry, prompt) {
|
|
const matchers = Array.isArray(entry.matchers) ? entry.matchers : [];
|
|
return matchers.some((matcher) => prompt.includes(String(matcher || "").toLowerCase()));
|
|
}
|
|
|
|
function promptMatchScore(entry, prompt) {
|
|
const matchers = Array.isArray(entry.matchers) ? entry.matchers : [];
|
|
let score = 0;
|
|
for (const matcher of matchers) {
|
|
const value = String(matcher || "").toLowerCase();
|
|
if (value && prompt.includes(value)) score = Math.max(score, value.length);
|
|
}
|
|
return score;
|
|
}
|
|
|
|
function pickEntry(entries, prompt) {
|
|
const wantsPassphrase = prompt.includes("passphrase");
|
|
const matchesKnownLoginPrompt = entries.some((entry) => entry.type === "password"
|
|
&& (Array.isArray(entry.matchers) ? entry.matchers : []).some((matcher) => {
|
|
const value = String(matcher || "").toLowerCase();
|
|
return value.endsWith("'s password") && prompt.includes(value);
|
|
}));
|
|
const wantsSecondFactor = !matchesKnownLoginPrompt
|
|
&& /one[\s-]?time|\botp\b|verification|passcode|\btoken\b|2fa|two[\s-]?factor|multi[\s-]?factor|\bmfa\b|second\s+factor|secondary(?:\s+\w+){0,3}\s+passw|second(?:\s+\w+){0,3}\s+passw|additional(?:\s+\w+){0,3}\s+passw|re[-\s]?enter\s+passw|confirm\s+passw|\bedr\b|duo|动态|一次性|验证码|验证信息|令牌|双因素|多因素|短信验证|手机验证|二次|安全密码|挑战码/.test(prompt);
|
|
const wantsPassword = !wantsSecondFactor && /passw(or)?d|密\s*码|口\s*令/.test(prompt);
|
|
if (!wantsPassphrase && !wantsPassword) return null;
|
|
const scoped = entries.filter((entry) => entry.type === (wantsPassphrase ? "passphrase" : "password"));
|
|
const matched = scoped
|
|
.map((entry, index) => ({ entry, index, score: promptMatchScore(entry, prompt) }))
|
|
.filter(({ score }) => score > 0)
|
|
.sort((a, b) => b.score - a.score || a.index - b.index)[0]?.entry;
|
|
if (matched) return matched;
|
|
if (wantsPassword && scoped.length === 1) return scoped[0];
|
|
return null;
|
|
}
|
|
|
|
function main() {
|
|
const prompt = normalizePrompt(process.argv.slice(2).join(" "));
|
|
const entries = loadEntries();
|
|
const entry = pickEntry(entries, prompt);
|
|
if (!entry?.secretFile) return;
|
|
try {
|
|
const secret = fs.readFileSync(entry.secretFile, "utf8").replace(/\r?\n$/, "");
|
|
process.stdout.write(secret + "\n");
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
main();
|
|
`;
|
|
|
|
/**
|
|
* Resolve Netcatty's bundled `et` client. System `et` installs are
|
|
* intentionally ignored so dev, CI, and release builds exercise the same
|
|
* binary (mirrors resolveBareMoshClient).
|
|
*/
|
|
function resolveBareEtClient(opts = {}) {
|
|
return bundledEtClient(opts);
|
|
}
|
|
|
|
function writeSecureFile(filePath, content, mode = 0o600) {
|
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
fs.writeFileSync(filePath, content, typeof content === "string" ? "utf8" : undefined);
|
|
if (process.platform === "win32") {
|
|
try {
|
|
// Remove inherited ACLs, grant only current user full control
|
|
execFileSync("icacls", [filePath, "/inheritance:r", "/grant:r", `${os.userInfo().username}:F`], {
|
|
windowsHide: true,
|
|
timeout: 5000,
|
|
});
|
|
} catch {
|
|
// ignore ACL failures (e.g. network drives)
|
|
}
|
|
} else {
|
|
try {
|
|
fs.chmodSync(filePath, mode);
|
|
} catch {
|
|
// ignore chmod failures on non-POSIX filesystems
|
|
}
|
|
}
|
|
return filePath;
|
|
}
|
|
|
|
function normalizeSshConfigPath(targetPath) {
|
|
const raw = String(targetPath);
|
|
const expanded = raw === "~"
|
|
? os.homedir()
|
|
: raw.startsWith("~/") || raw.startsWith("~\\")
|
|
? path.join(os.homedir(), raw.slice(2))
|
|
: raw;
|
|
return path.resolve(expanded).replace(/\\/g, "/");
|
|
}
|
|
|
|
function quoteSshConfigValue(value) {
|
|
const normalized = normalizeSshConfigPath(value);
|
|
return `"${normalized.replace(/(["\\])/g, "\\$1")}"`;
|
|
}
|
|
|
|
function quoteRawSshConfigValue(value) {
|
|
return `"${String(value).replace(/(["\\])/g, "\\$1")}"`;
|
|
}
|
|
|
|
function publicIdentitySelectorPath(value) {
|
|
const raw = String(value);
|
|
return raw.toLowerCase().endsWith(".pub") ? raw : `${raw}.pub`;
|
|
}
|
|
|
|
async function prepareEtSshAgentOptions(options) {
|
|
const prepareOne = async (connectionOptions, logPrefix) => {
|
|
if (connectionOptions?.useSshAgent !== true && !connectionOptions?.agentForwarding) return connectionOptions;
|
|
let prepared = connectionOptions;
|
|
if (connectionOptions.useSshAgent === true) {
|
|
await prepareSystemSshAgentForAuth(connectionOptions, logPrefix);
|
|
const loginSocketPath = await getAvailableAgentSocket(connectionOptions.identityAgent, connectionOptions);
|
|
if (!loginSocketPath) {
|
|
throw new Error("System SSH agent is unavailable. Start or unlock it, or configure a valid agent socket.");
|
|
}
|
|
prepared = { ...prepared, _resolvedSshAgentSocket: loginSocketPath };
|
|
}
|
|
if (connectionOptions.agentForwarding) {
|
|
const forwardingSocketPath = await getAvailableForwardingAgentSocket(
|
|
connectionOptions.identityAgent,
|
|
connectionOptions,
|
|
);
|
|
if (forwardingSocketPath) {
|
|
prepared = { ...prepared, _resolvedForwardingAgentSocket: forwardingSocketPath };
|
|
}
|
|
}
|
|
return prepared;
|
|
};
|
|
|
|
const preparedTarget = await prepareOne(options, "[ET]");
|
|
if (!Array.isArray(options.jumpHosts) || options.jumpHosts.length === 0) {
|
|
return preparedTarget;
|
|
}
|
|
const preparedJumpHosts = [];
|
|
for (let index = 0; index < options.jumpHosts.length; index += 1) {
|
|
preparedJumpHosts.push(await prepareOne(options.jumpHosts[index], `[ET Chain] Hop ${index + 1}:`));
|
|
}
|
|
return { ...preparedTarget, jumpHosts: preparedJumpHosts };
|
|
}
|
|
|
|
function applyEtSshAgentEnvironment(env, options) {
|
|
delete env.SSH_AUTH_SOCK;
|
|
if (options?.useSshAgent === false) {
|
|
const automaticJumpNeedsAmbientAgent = options.jumpHosts?.some((jump) => (
|
|
jump?.authMethod === "auto" && jump.useSshAgent !== false
|
|
));
|
|
if (automaticJumpNeedsAmbientAgent && process.env.SSH_AUTH_SOCK) {
|
|
env.SSH_AUTH_SOCK = process.env.SSH_AUTH_SOCK;
|
|
}
|
|
return env;
|
|
}
|
|
const socketPath = options?._resolvedSshAgentSocket || process.env.SSH_AUTH_SOCK;
|
|
if (socketPath) env.SSH_AUTH_SOCK = socketPath;
|
|
return env;
|
|
}
|
|
|
|
// POSIX single-quote a string so it is safe to embed verbatim in a /bin/sh
|
|
// script (handles spaces and embedded single quotes in e.g. an .app path).
|
|
function shellSingleQuote(value) {
|
|
return `'${String(value).replace(/'/g, "'\\''")}'`;
|
|
}
|
|
|
|
function createPasswordPromptMatchers({ hostname, username, port }) {
|
|
const values = new Set();
|
|
const addHostVariant = (hostValue) => {
|
|
if (!hostValue) return;
|
|
const lowerHost = String(hostValue).toLowerCase();
|
|
values.add(lowerHost);
|
|
if (username) {
|
|
values.add(`${String(username).toLowerCase()}@${lowerHost}`);
|
|
values.add(`${String(username).toLowerCase()}@${lowerHost}'s password`);
|
|
if (port) values.add(`${String(username).toLowerCase()}@${lowerHost}:${port}`);
|
|
}
|
|
};
|
|
|
|
addHostVariant(hostname);
|
|
return [...values];
|
|
}
|
|
|
|
function createPassphrasePromptMatchers(keyPath) {
|
|
const normalizedPath = normalizeSshConfigPath(keyPath).toLowerCase();
|
|
return [normalizedPath, path.basename(normalizedPath)];
|
|
}
|
|
|
|
function addAskpassEntry(entries, type, matchers, secretFile) {
|
|
if (!secretFile) return;
|
|
entries.push({
|
|
type,
|
|
matchers: [...new Set((matchers || []).map((value) => String(value || "").toLowerCase()).filter(Boolean))],
|
|
secretFile,
|
|
});
|
|
}
|
|
|
|
// ET's internal ssh is driven through SSH_ASKPASS, so secondary-factor
|
|
// prompts cannot be routed to Netcatty's renderer modal from this path.
|
|
function buildPreferredAuthentications({ authMethod, hasPassword = false, hasPublicKey = false }) {
|
|
if (authMethod === "password") {
|
|
return "password,keyboard-interactive";
|
|
}
|
|
if (authMethod === "auto") {
|
|
return "publickey,password,keyboard-interactive";
|
|
}
|
|
if (hasPublicKey) {
|
|
return hasPassword ? "publickey,password,keyboard-interactive" : "publickey";
|
|
}
|
|
if (hasPassword) {
|
|
return "password,keyboard-interactive";
|
|
}
|
|
return "";
|
|
}
|
|
|
|
function createEtAskpassArtifacts(sshDir, askpassEntries) {
|
|
if (!Array.isArray(askpassEntries) || askpassEntries.length === 0) {
|
|
return { env: {}, artifacts: [] };
|
|
}
|
|
|
|
const askpassMapPath = path.join(sshDir, "netcatty-et-askpass-map.json");
|
|
const askpassScriptPath = path.join(sshDir, "netcatty-et-askpass.cjs");
|
|
writeSecureFile(askpassMapPath, `${JSON.stringify(askpassEntries, null, 2)}\n`, 0o600);
|
|
writeSecureFile(askpassScriptPath, ET_ASKPASS_SCRIPT, 0o700);
|
|
|
|
if (process.platform === "win32") {
|
|
const askpassCmdPath = path.join(sshDir, "netcatty-et-askpass.cmd");
|
|
writeSecureFile(
|
|
askpassCmdPath,
|
|
`@echo off\r\nset ELECTRON_RUN_AS_NODE=1\r\n"${process.execPath.replace(/"/g, '""')}" "%~dp0netcatty-et-askpass.cjs" %*\r\n`,
|
|
0o700,
|
|
);
|
|
return {
|
|
env: {
|
|
SSH_ASKPASS: askpassCmdPath,
|
|
SSH_ASKPASS_REQUIRE: "force",
|
|
DISPLAY: process.env.DISPLAY || "netcatty:0",
|
|
NETCATTY_ET_ASKPASS_MAP: askpassMapPath,
|
|
},
|
|
artifacts: [askpassMapPath, askpassScriptPath, askpassCmdPath],
|
|
};
|
|
}
|
|
|
|
// Unix: ssh execs SSH_ASKPASS directly, so the helper must be runnable
|
|
// without relying on a `node` on PATH. The `.cjs` shebang (#!/usr/bin/env
|
|
// node) breaks in packaged builds because Electron does not put a `node`
|
|
// binary on the user's PATH. Mirror the Windows .cmd wrapper: run the
|
|
// script through Electron's own executable with ELECTRON_RUN_AS_NODE=1.
|
|
const askpassWrapperPath = path.join(sshDir, "netcatty-et-askpass.sh");
|
|
const electronExec = shellSingleQuote(process.execPath);
|
|
writeSecureFile(
|
|
askpassWrapperPath,
|
|
`#!/bin/sh\nELECTRON_RUN_AS_NODE=1 exec ${electronExec} "$(dirname "$0")/netcatty-et-askpass.cjs" "$@"\n`,
|
|
0o700,
|
|
);
|
|
return {
|
|
env: {
|
|
SSH_ASKPASS: askpassWrapperPath,
|
|
SSH_ASKPASS_REQUIRE: "force",
|
|
DISPLAY: process.env.DISPLAY || "netcatty:0",
|
|
NETCATTY_ET_ASKPASS_MAP: askpassMapPath,
|
|
},
|
|
artifacts: [askpassMapPath, askpassScriptPath, askpassWrapperPath],
|
|
};
|
|
}
|
|
|
|
function copyIfExists(sourcePath, targetPath) {
|
|
try {
|
|
if (fs.existsSync(sourcePath)) {
|
|
fs.copyFileSync(sourcePath, targetPath);
|
|
}
|
|
} catch {
|
|
// ignore copy failures
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build a private SSH home + options for the `et` client's internal ssh.
|
|
* Returns { userHost, sshOptions, identityFilePaths, env, artifacts }. comma-free option
|
|
* values go in `sshOptions` (passed via --ssh-option); options that need
|
|
* commas/spaces are written to a config file under HOME/.ssh/config.
|
|
*/
|
|
function prepareEtSshEnvironment(sessionId, options) {
|
|
const jumpHosts = Array.isArray(options.jumpHosts) ? options.jumpHosts : [];
|
|
if (jumpHosts.length > 1) {
|
|
throw new Error("EternalTerminal currently supports at most one jump host in Netcatty.");
|
|
}
|
|
|
|
const tempDir = tempDirBridge.getTempFilePath(`et-ssh-home-${sessionId}`);
|
|
const sshDir = path.join(tempDir, ".ssh");
|
|
fs.mkdirSync(sshDir, { recursive: true });
|
|
|
|
const safeId = String(sessionId || "session").replace(/[^\w.-]/g, "_");
|
|
// sshOptions: comma-free values safe for --ssh-option (ET may split on commas)
|
|
const sshOptions = [];
|
|
// configLines: options that need commas or spaces, written to config file
|
|
const configLines = [];
|
|
const askpassEntries = [];
|
|
|
|
// Copy known_hosts from real ~/.ssh so already-trusted hosts verify
|
|
// silently. Always point ssh at the persistent user file so
|
|
// StrictHostKeyChecking=accept-new records a first-seen key for later
|
|
// mismatch detection instead of trusting it again on every ET session.
|
|
const realSshDir = path.join(os.homedir(), ".ssh");
|
|
fs.mkdirSync(realSshDir, { recursive: true });
|
|
let defaultIdentityPaths = [];
|
|
try {
|
|
const identityNames = fs.readdirSync(realSshDir, { withFileTypes: true })
|
|
.filter((entry) => (entry.isFile() || entry.isSymbolicLink()) && SSH_KEY_PATTERN.test(entry.name))
|
|
.map((entry) => entry.name);
|
|
defaultIdentityPaths = orderSshIdentityNames(identityNames)
|
|
.map((name) => path.join(realSshDir, name));
|
|
} catch {
|
|
// Local key discovery is optional. Password-only and interactive ET
|
|
// sessions must still work when ~/.ssh cannot be read.
|
|
}
|
|
const knownHostsPath = path.join(realSshDir, "known_hosts");
|
|
const verifyHostKeys = options.verifyHostKeys !== false;
|
|
|
|
// et drives ssh itself and feeds credentials through SSH_ASKPASS, which
|
|
// only answers password/passphrase prompts — never the interactive
|
|
// host-key "yes/no" confirmation. Without this, a first-time host makes
|
|
// et's internal ssh stall on that unanswerable prompt while its
|
|
// handshake text leaks to the PTY, and the renderer flips the tab to
|
|
// "connected" on that first byte (terminalSessionAttachment.ts) even
|
|
// though no shell exists yet. accept-new trusts a brand-new host
|
|
// automatically but still rejects a *changed* key (MITM protection);
|
|
// LogLevel=ERROR silences the "Permanently added..." notice and other
|
|
// ssh banners so the first real PTY bytes are the remote shell. Mirrors
|
|
// the options already used by execOnEtSession.
|
|
//
|
|
// Vault known_hosts (issue #2501):
|
|
// - Destination hop: enforced via --ssh-option (ET applies these only
|
|
// to the final target; temp-HOME Host blocks are unreliable on POSIX).
|
|
// - Jump hop: enforced via Host <jump> config block (ProxyJump child
|
|
// process does not inherit --ssh-option).
|
|
// Build per-hop snapshots so shared multi-host system lines are filtered
|
|
// only for the hop that is vault-pinned.
|
|
let targetAuthoritativeKnownHostsPath = null;
|
|
let jumpAuthoritativeKnownHostsPath = null;
|
|
let emptyKnownHostsPath = null;
|
|
const targetConnectionHost = {
|
|
hostname: options.hostname,
|
|
port: options.port || 22,
|
|
};
|
|
const jumpConnectionHosts = jumpHosts.map((jump) => ({
|
|
hostname: jump.hostname,
|
|
port: jump.port || 22,
|
|
}));
|
|
const vaultPinsTarget = vaultPinsConnectionHosts(options.knownHosts, [targetConnectionHost]);
|
|
const vaultPinsJump = vaultPinsConnectionHosts(options.knownHosts, jumpConnectionHosts);
|
|
if (verifyHostKeys) {
|
|
// Per-connection memo only — never process-lifetime, so ssh_config
|
|
// edits are observed on the next connect (Codex P1).
|
|
const sshGMemo = new Map();
|
|
if (vaultPinsTarget) {
|
|
const targetContent = buildAuthoritativeKnownHostsContent({
|
|
knownHosts: options.knownHosts,
|
|
fs,
|
|
hostname: options.hostname,
|
|
port: options.port || 22,
|
|
username: options.username,
|
|
pathModule: path,
|
|
homedir: os.homedir(),
|
|
memo: sshGMemo,
|
|
});
|
|
if (targetContent) {
|
|
targetAuthoritativeKnownHostsPath = path.join(
|
|
sshDir,
|
|
`${safeId}-authoritative-target-known_hosts`,
|
|
);
|
|
writeSecureFile(targetAuthoritativeKnownHostsPath, targetContent, 0o600);
|
|
}
|
|
}
|
|
if (vaultPinsJump && jumpHosts[0]) {
|
|
const jumpContent = buildAuthoritativeKnownHostsContent({
|
|
knownHosts: options.knownHosts,
|
|
fs,
|
|
hostname: jumpHosts[0].hostname,
|
|
port: jumpHosts[0].port || 22,
|
|
username: jumpHosts[0].username,
|
|
pathModule: path,
|
|
homedir: os.homedir(),
|
|
memo: sshGMemo,
|
|
});
|
|
if (jumpContent) {
|
|
jumpAuthoritativeKnownHostsPath = path.join(
|
|
sshDir,
|
|
`${safeId}-authoritative-jump-known_hosts`,
|
|
);
|
|
writeSecureFile(jumpAuthoritativeKnownHostsPath, jumpContent, 0o600);
|
|
}
|
|
}
|
|
} else {
|
|
// StrictHostKeyChecking=no still consults known_hosts for password-auth
|
|
// MITM protection. Point both trust files at an empty snapshot so
|
|
// verifyHostKeys=false truly bypasses stale vault/system pins.
|
|
emptyKnownHostsPath = path.join(sshDir, `${safeId}-empty-known_hosts`);
|
|
writeSecureFile(emptyKnownHostsPath, "", 0o600);
|
|
}
|
|
|
|
// Destination hop host-key policy via --ssh-option.
|
|
if (verifyHostKeys && !targetAuthoritativeKnownHostsPath) {
|
|
sshOptions.push(`UserKnownHostsFile=${normalizeSshConfigPath(knownHostsPath)}`);
|
|
}
|
|
sshOptions.push(...buildExternalHostKeySshOptions({
|
|
authoritativeKnownHostsPath: targetAuthoritativeKnownHostsPath,
|
|
emptyKnownHostsPath,
|
|
verifyHostKeys,
|
|
protocol: "et",
|
|
style: "values",
|
|
normalizePath: normalizeSshConfigPath,
|
|
}));
|
|
if (!sshOptions.some((opt) => opt.startsWith("StrictHostKeyChecking="))) {
|
|
sshOptions.push("StrictHostKeyChecking=accept-new");
|
|
}
|
|
sshOptions.push("LogLevel=ERROR");
|
|
|
|
// Port
|
|
if (options.port && options.port !== 22) {
|
|
sshOptions.push(`Port=${options.port}`);
|
|
}
|
|
|
|
if (options.useSshAgent === false) {
|
|
configLines.push("IdentityAgent none");
|
|
} else if (options.useSshAgent && options._resolvedSshAgentSocket) {
|
|
configLines.push(`IdentityAgent ${quoteRawSshConfigValue(options._resolvedSshAgentSocket)}`);
|
|
}
|
|
// Private key
|
|
const identityPaths = [];
|
|
let tempKeyPath = null;
|
|
if (options.privateKey) {
|
|
tempKeyPath = path.join(sshDir, `${safeId}-key`);
|
|
writeSecureFile(tempKeyPath, options.privateKey, 0o600);
|
|
identityPaths.push(tempKeyPath);
|
|
if (options.passphrase) {
|
|
const passphrasePath = path.join(sshDir, `${safeId}-passphrase.txt`);
|
|
writeSecureFile(passphrasePath, `${options.passphrase}\n`, 0o600);
|
|
addAskpassEntry(askpassEntries, "passphrase", createPassphrasePromptMatchers(tempKeyPath), passphrasePath);
|
|
}
|
|
}
|
|
|
|
if (options.useSshAgent && Array.isArray(options.agentPublicKeys)) {
|
|
for (let index = 0; index < options.agentPublicKeys.length; index += 1) {
|
|
const publicKey = options.agentPublicKeys[index];
|
|
if (typeof publicKey !== "string" || !publicKey.trim()) continue;
|
|
const selectorPath = path.join(sshDir, `${safeId}-agent-${index}.pub`);
|
|
writeSecureFile(selectorPath, publicKey, 0o600);
|
|
identityPaths.push(selectorPath);
|
|
}
|
|
}
|
|
|
|
// Certificate
|
|
if (options.certificate) {
|
|
const certPath = path.join(sshDir, `${safeId}-cert.pub`);
|
|
writeSecureFile(certPath, options.certificate, 0o600);
|
|
sshOptions.push(`CertificateFile=${normalizeSshConfigPath(certPath)}`);
|
|
}
|
|
|
|
// Additional identity file paths from host config
|
|
if (Array.isArray(options.identityFilePaths)) {
|
|
for (const idPath of options.identityFilePaths) {
|
|
if (idPath) {
|
|
identityPaths.push(options.useSshAgent ? publicIdentitySelectorPath(idPath) : idPath);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (options.authMethod === "auto") {
|
|
for (const keyPath of defaultIdentityPaths) {
|
|
if (!identityPaths.includes(keyPath)) {
|
|
identityPaths.push(keyPath);
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const idPath of identityPaths) {
|
|
sshOptions.push(`IdentityFile=${normalizeSshConfigPath(idPath)}`);
|
|
}
|
|
|
|
const hasStrictTargetAuth = options.authMethod === "key" || options.authMethod === "certificate";
|
|
if (hasStrictTargetAuth && identityPaths.length === 0) {
|
|
sshOptions.push("IdentityFile=none");
|
|
}
|
|
|
|
if (
|
|
(options.authMethod !== "auto" && !options.useSshAgent && (identityPaths.length > 0 || options.authMethod === "key" || options.authMethod === "certificate"))
|
|
|| (options.useSshAgent && options.identitiesOnly)
|
|
) {
|
|
sshOptions.push("IdentitiesOnly=yes");
|
|
}
|
|
|
|
// Password
|
|
const hasPassword = typeof options.password === "string" && options.password.length > 0;
|
|
if (hasPassword) {
|
|
const passwordPath = path.join(sshDir, `${safeId}-password.txt`);
|
|
writeSecureFile(passwordPath, `${options.password}\n`, 0o600);
|
|
addAskpassEntry(askpassEntries, "password", createPasswordPromptMatchers({
|
|
hostname: options.hostname,
|
|
username: options.username,
|
|
port: options.port,
|
|
}), passwordPath);
|
|
}
|
|
|
|
// Auth method preferences
|
|
// NOTE: values with commas (e.g. "password,keyboard-interactive") MUST go into
|
|
// the config file — ET on Windows passes --ssh-option values through cmd.exe
|
|
// which treats commas as argument delimiters.
|
|
const targetPreferredAuthentications = buildPreferredAuthentications({
|
|
authMethod: options.authMethod,
|
|
requiresMfa: !!options.requiresMfa,
|
|
hasPassword,
|
|
hasPublicKey: Boolean(options.useSshAgent || identityPaths.length > 0),
|
|
});
|
|
if (options.authMethod === "password") {
|
|
sshOptions.push("PubkeyAuthentication=no");
|
|
}
|
|
if (targetPreferredAuthentications) {
|
|
if (targetPreferredAuthentications.includes(",")) {
|
|
configLines.push(`PreferredAuthentications ${targetPreferredAuthentications}`);
|
|
} else {
|
|
sshOptions.push(`PreferredAuthentications=${targetPreferredAuthentications}`);
|
|
}
|
|
}
|
|
|
|
sshOptions.push("KbdInteractiveAuthentication=yes");
|
|
sshOptions.push("NumberOfPasswordPrompts=1");
|
|
|
|
// Legacy algorithms (all values contain commas → config file only)
|
|
if (options.legacyAlgorithms) {
|
|
configLines.push("KexAlgorithms +diffie-hellman-group14-sha1,diffie-hellman-group1-sha1");
|
|
configLines.push("Ciphers +aes128-cbc,aes256-cbc,3des-cbc");
|
|
configLines.push("HostKeyAlgorithms +ssh-rsa,ssh-dss");
|
|
configLines.push("PubkeyAcceptedAlgorithms +ssh-rsa,ssh-dss");
|
|
}
|
|
|
|
// Jump host — route through ET's own --jumphost/--jport so the ET TCP
|
|
// socket connects to the jumphost's etserver and the destination is
|
|
// reached over the SSH tunnel ET sets up with `ssh -J jumphost dest`.
|
|
// (A bare ssh ProxyCommand only fixes the SSH bootstrap; ET would still
|
|
// open its socket straight at the unreachable destination etserver.)
|
|
//
|
|
// ET passes the destination's --ssh-option values via `ssh -o`, which
|
|
// OpenSSH applies to the final hop only. The jump hop is configured by
|
|
// OpenSSH from ssh_config, so the jump's per-hop credentials/settings go
|
|
// into a `Host <jumphost>` block in the config file. To keep the
|
|
// destination's auth from leaking onto the jump hop, scope the
|
|
// destination's comma/space config lines under a `Host <dest>` block too
|
|
// whenever a jump host is present.
|
|
let etJumpArgs = [];
|
|
const jumpConfigLines = [];
|
|
if (jumpHosts[0]) {
|
|
const jump = jumpHosts[0];
|
|
const jumpUser = jump.username || os.userInfo().username;
|
|
const jumpHost = jump.hostname;
|
|
const jumpPort = jump.port || 22;
|
|
// ET server port on the jumphost. ET's own default is 2022; honor an
|
|
// explicit override if the jump host model ever carries one.
|
|
const jumpEtPort = jump.etPort || 2022;
|
|
|
|
// Tell ET to tunnel through the jumphost. ET opens its ET socket to
|
|
// <jumphost>:<jport> and adds `ssh -J <jumpUser@jumpHost>` for the
|
|
// bootstrap; we feed the destination via the positional host as usual.
|
|
etJumpArgs = ["--jumphost", `${jumpUser}@${jumpHost}`, "--jport", String(jumpEtPort)];
|
|
|
|
// Per-hop jump settings live in a `Host <jumpHost>` block so they apply
|
|
// to the ProxyJump connection only (not the destination).
|
|
// Do NOT set HostName to the alias token here: OpenSSH keeps the first
|
|
// obtained value, so a redundant `HostName bastion` would freeze the
|
|
// literal name and prevent the later Include of ~/.ssh/config from
|
|
// supplying the real HostName (e.g. 10.0.0.5). That would also diverge
|
|
// from the vault snapshot built via `ssh -G` against the real config.
|
|
// Omitting HostName lets Include resolve aliases; plain hostnames still
|
|
// default HostName to the Host token (Codex P1 on PR #2529).
|
|
jumpConfigLines.push(`Host ${jumpHost}`);
|
|
jumpConfigLines.push(` User ${jumpUser}`);
|
|
jumpConfigLines.push(` Port ${jumpPort}`);
|
|
|
|
if (jump.useSshAgent === false) {
|
|
jumpConfigLines.push(" IdentityAgent none");
|
|
} else if (jump.useSshAgent && jump._resolvedSshAgentSocket) {
|
|
jumpConfigLines.push(` IdentityAgent ${quoteRawSshConfigValue(jump._resolvedSshAgentSocket)}`);
|
|
}
|
|
|
|
if (jump.useSshAgent && Array.isArray(jump.agentPublicKeys)) {
|
|
for (let index = 0; index < jump.agentPublicKeys.length; index += 1) {
|
|
const publicKey = jump.agentPublicKeys[index];
|
|
if (typeof publicKey !== "string" || !publicKey.trim()) continue;
|
|
const selectorPath = path.join(sshDir, `${safeId}-jump-agent-${index}.pub`);
|
|
writeSecureFile(selectorPath, publicKey, 0o600);
|
|
jumpConfigLines.push(` IdentityFile ${quoteSshConfigValue(selectorPath)}`);
|
|
}
|
|
}
|
|
|
|
// Jump host key
|
|
if (jump.privateKey) {
|
|
const jumpKeyPath = path.join(sshDir, `${safeId}-jump-key`);
|
|
writeSecureFile(jumpKeyPath, jump.privateKey, 0o600);
|
|
jumpConfigLines.push(` IdentityFile ${quoteSshConfigValue(jumpKeyPath)}`);
|
|
jumpConfigLines.push(" IdentitiesOnly yes");
|
|
if (jump.passphrase) {
|
|
const jumpPassPath = path.join(sshDir, `${safeId}-jump-passphrase.txt`);
|
|
writeSecureFile(jumpPassPath, `${jump.passphrase}\n`, 0o600);
|
|
addAskpassEntry(askpassEntries, "passphrase", createPassphrasePromptMatchers(jumpKeyPath), jumpPassPath);
|
|
}
|
|
} else if (Array.isArray(jump.identityFilePaths)) {
|
|
const jumpIdentityPaths = jump.identityFilePaths
|
|
.filter(Boolean)
|
|
.map((identityPath) => jump.useSshAgent ? publicIdentitySelectorPath(identityPath) : identityPath);
|
|
for (const idPath of jumpIdentityPaths) {
|
|
jumpConfigLines.push(` IdentityFile ${quoteSshConfigValue(idPath)}`);
|
|
}
|
|
if (jumpIdentityPaths.length > 0 && (!jump.useSshAgent || jump.identitiesOnly)) {
|
|
jumpConfigLines.push(" IdentitiesOnly yes");
|
|
}
|
|
}
|
|
|
|
if (jump.useSshAgent && jump.identitiesOnly && !jumpConfigLines.includes(" IdentitiesOnly yes")) {
|
|
jumpConfigLines.push(" IdentitiesOnly yes");
|
|
}
|
|
|
|
const hasStrictJumpAuth = jump.authMethod === "key" || jump.authMethod === "certificate";
|
|
const hasSelectedJumpIdentity = jumpConfigLines.some((line) => line.startsWith(" IdentityFile "));
|
|
if (hasStrictJumpAuth && !hasSelectedJumpIdentity) {
|
|
jumpConfigLines.push(" IdentityFile none");
|
|
if (!jumpConfigLines.includes(" IdentitiesOnly yes")) {
|
|
jumpConfigLines.push(" IdentitiesOnly yes");
|
|
}
|
|
}
|
|
|
|
if (jump.authMethod === "auto") {
|
|
for (const keyPath of defaultIdentityPaths) {
|
|
jumpConfigLines.push(` IdentityFile ${quoteSshConfigValue(keyPath)}`);
|
|
}
|
|
} else if (jump.authMethod === "password") {
|
|
jumpConfigLines.push(" PubkeyAuthentication no");
|
|
}
|
|
|
|
const jumpPreferredAuthentications = buildPreferredAuthentications({
|
|
authMethod: jump.authMethod,
|
|
requiresMfa: !!jump.requiresMfa,
|
|
hasPassword: typeof jump.password === "string" && jump.password.length > 0,
|
|
hasPublicKey: Boolean(
|
|
jump.useSshAgent ||
|
|
jumpConfigLines.some((line) => line.startsWith(" IdentityFile ")),
|
|
),
|
|
});
|
|
if (jumpPreferredAuthentications) {
|
|
jumpConfigLines.push(` PreferredAuthentications ${jumpPreferredAuthentications}`);
|
|
}
|
|
|
|
// Jump host certificate
|
|
if (jump.certificate) {
|
|
const jumpCertPath = path.join(sshDir, `${safeId}-jump-cert.pub`);
|
|
writeSecureFile(jumpCertPath, jump.certificate, 0o600);
|
|
jumpConfigLines.push(` CertificateFile ${quoteSshConfigValue(jumpCertPath)}`);
|
|
}
|
|
|
|
// Jump host password
|
|
if (jump.password) {
|
|
const jumpPwPath = path.join(sshDir, `${safeId}-jump-password.txt`);
|
|
writeSecureFile(jumpPwPath, `${jump.password}\n`, 0o600);
|
|
addAskpassEntry(askpassEntries, "password", createPasswordPromptMatchers({
|
|
hostname: jumpHost,
|
|
username: jumpUser,
|
|
port: jumpPort,
|
|
}), jumpPwPath);
|
|
}
|
|
|
|
// Jump-host host-key policy must live in this Host block: ET's
|
|
// --ssh-option values apply only to the final destination hop, while
|
|
// ProxyJump starts a separate OpenSSH process that reads the jump
|
|
// stanza (see comment near etJumpArgs).
|
|
if (verifyHostKeys) {
|
|
if (jumpAuthoritativeKnownHostsPath) {
|
|
jumpConfigLines.push(...buildExternalHostKeyConfigLines({
|
|
authoritativeKnownHostsPath: jumpAuthoritativeKnownHostsPath,
|
|
verifyHostKeys: true,
|
|
protocol: "et",
|
|
normalizePath: normalizeSshConfigPath,
|
|
quotePath: quoteSshConfigValue,
|
|
}));
|
|
} else {
|
|
jumpConfigLines.push(` UserKnownHostsFile ${quoteSshConfigValue(knownHostsPath)}`);
|
|
jumpConfigLines.push(" StrictHostKeyChecking accept-new");
|
|
}
|
|
} else if (emptyKnownHostsPath) {
|
|
jumpConfigLines.push(...buildExternalHostKeyConfigLines({
|
|
emptyKnownHostsPath,
|
|
verifyHostKeys: false,
|
|
protocol: "et",
|
|
normalizePath: normalizeSshConfigPath,
|
|
quotePath: quoteSshConfigValue,
|
|
}));
|
|
}
|
|
jumpConfigLines.push(" LogLevel ERROR");
|
|
jumpConfigLines.push(" KbdInteractiveAuthentication yes");
|
|
jumpConfigLines.push(" NumberOfPasswordPrompts 1");
|
|
|
|
if (options.legacyAlgorithms) {
|
|
jumpConfigLines.push(" KexAlgorithms +diffie-hellman-group14-sha1,diffie-hellman-group1-sha1");
|
|
jumpConfigLines.push(" Ciphers +aes128-cbc,aes256-cbc,3des-cbc");
|
|
jumpConfigLines.push(" HostKeyAlgorithms +ssh-rsa,ssh-dss");
|
|
jumpConfigLines.push(" PubkeyAcceptedAlgorithms +ssh-rsa,ssh-dss");
|
|
}
|
|
}
|
|
|
|
// Write config file. When a jump host is present, scope the destination's
|
|
// comma/space options under `Host <dest>` so they don't bleed onto the
|
|
// jump hop, add `ProxyJump <jumpHost>` there so the standalone `ssh`
|
|
// used by execOnEtSession also tunnels through the jump (ET's own
|
|
// command-line -J overrides this for the interactive session, resolving
|
|
// to the same single hop), then append the `Host <jumpHost>` block.
|
|
const configFileLines = [];
|
|
if (jumpConfigLines.length > 0) {
|
|
const jump = jumpHosts[0];
|
|
configFileLines.push(`Host ${options.hostname}`);
|
|
configFileLines.push(` ProxyJump ${jump.hostname}`);
|
|
for (const line of configLines) {
|
|
configFileLines.push(` ${line}`);
|
|
}
|
|
configFileLines.push(...jumpConfigLines);
|
|
} else {
|
|
configFileLines.push(...configLines);
|
|
}
|
|
|
|
const writesConfigFile = configFileLines.length > 0;
|
|
let configPath = null;
|
|
if (writesConfigFile) {
|
|
// -F replaces the per-user config and also skips the system-wide
|
|
// config. Append Include directives AFTER our Host blocks so:
|
|
// 1) first-obtained-value keeps session overrides (ProxyJump,
|
|
// vault known_hosts, IdentityFile, …) for matched hosts, and
|
|
// 2) HostName aliases / ProxyCommand / algorithms from the user's
|
|
// normal ~/.ssh/config still apply (Codex P1 on PR #2529).
|
|
const includeLines = [];
|
|
try {
|
|
const realUserConfig = path.join(os.homedir(), ".ssh", "config");
|
|
if (fs.existsSync(realUserConfig)) {
|
|
includeLines.push(`Include ${quoteSshConfigValue(realUserConfig)}`);
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
try {
|
|
const systemConfigs = process.platform === "win32"
|
|
? [path.join(process.env.ProgramData || "C:\\ProgramData", "ssh", "ssh_config")]
|
|
: ["/etc/ssh/ssh_config"];
|
|
for (const systemConfig of systemConfigs) {
|
|
if (fs.existsSync(systemConfig)) {
|
|
includeLines.push(`Include ${quoteSshConfigValue(systemConfig)}`);
|
|
}
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
if (includeLines.length > 0) {
|
|
// Reset Host/Match context first. Without this, Includes after a
|
|
// `Host <jump>` stanza stay conditional on the jump host and are
|
|
// skipped for the destination (Codex P1).
|
|
configFileLines.push("");
|
|
configFileLines.push("Match all");
|
|
configFileLines.push("# Preserve normal OpenSSH user/system configuration under -F.");
|
|
configFileLines.push(...includeLines);
|
|
}
|
|
configPath = path.join(sshDir, "config");
|
|
writeSecureFile(configPath, configFileLines.join("\n") + "\n", 0o600);
|
|
}
|
|
|
|
// Create askpass artifacts
|
|
const askpass = createEtAskpassArtifacts(sshDir, askpassEntries);
|
|
|
|
// OpenSSH resolves the user config from the account home directory, not
|
|
// $HOME. When we generate a private config (ProxyJump + jump host-key
|
|
// policy), inject a PATH-fronted `ssh` wrapper that always passes
|
|
// `-F <session-config>` so both interactive ET (ssh -J) and follow-up
|
|
// execOnEtSession honor the generated Host stanzas.
|
|
const pathEnv = {};
|
|
if (configPath) {
|
|
try {
|
|
// Resolve the real OpenSSH binary to an absolute path BEFORE we
|
|
// prepend the wrapper directory to PATH. Embedding a bare `ssh`
|
|
// name would recurse into the wrapper forever.
|
|
let realSsh = process.platform === "win32"
|
|
? (findExecutable("ssh") || "")
|
|
: "";
|
|
if (!realSsh || !path.isAbsolute(realSsh)) {
|
|
try {
|
|
const resolved = execFileSync(
|
|
process.platform === "win32" ? "where" : "sh",
|
|
process.platform === "win32" ? ["ssh"] : ["-c", "command -v ssh"],
|
|
{
|
|
encoding: "utf8",
|
|
timeout: 2000,
|
|
windowsHide: true,
|
|
env: process.env,
|
|
},
|
|
).trim().split(/\r?\n/)[0];
|
|
if (resolved) realSsh = resolved;
|
|
} catch {
|
|
// Fall through to common absolute locations.
|
|
}
|
|
}
|
|
if (!realSsh || !path.isAbsolute(String(realSsh))) {
|
|
const candidates = process.platform === "win32"
|
|
? []
|
|
: ["/usr/bin/ssh", "/bin/ssh", "/usr/local/bin/ssh"];
|
|
realSsh = candidates.find((candidate) => {
|
|
try { return fs.existsSync(candidate); } catch { return false; }
|
|
}) || realSsh || "ssh";
|
|
}
|
|
if (!path.isAbsolute(String(realSsh))) {
|
|
throw new Error("unable to resolve absolute OpenSSH path for wrapper");
|
|
}
|
|
const wrapperDir = path.join(sshDir, "bin");
|
|
fs.mkdirSync(wrapperDir, { recursive: true });
|
|
if (process.platform === "win32") {
|
|
const wrapperPath = path.join(wrapperDir, "ssh.cmd");
|
|
writeSecureFile(
|
|
wrapperPath,
|
|
`@echo off\r\n"${String(realSsh).replace(/"/g, '""')}" -F "${configPath.replace(/"/g, '""')}" %*\r\n`,
|
|
0o700,
|
|
);
|
|
} else {
|
|
const wrapperPath = path.join(wrapperDir, "ssh");
|
|
const quotedSsh = `'${String(realSsh).replace(/'/g, `'\\''`)}'`;
|
|
const quotedConfig = `'${String(configPath).replace(/'/g, `'\\''`)}'`;
|
|
writeSecureFile(
|
|
wrapperPath,
|
|
`#!/bin/sh\nexec ${quotedSsh} -F ${quotedConfig} "$@"\n`,
|
|
0o700,
|
|
);
|
|
}
|
|
// Prepend the wrapper to the effective session PATH (options.env),
|
|
// not bare process.env — host/session PATH may carry ProxyCommand
|
|
// helpers that must remain visible (Codex P2).
|
|
const sessionEnv = options.env && typeof options.env === "object" ? options.env : {};
|
|
const pathKey = Object.keys(sessionEnv).find((k) => k.toLowerCase() === "path")
|
|
|| Object.keys(process.env).find((k) => k.toLowerCase() === "path")
|
|
|| "PATH";
|
|
const currentPath = sessionEnv[pathKey]
|
|
|| sessionEnv.PATH
|
|
|| sessionEnv.Path
|
|
|| process.env[pathKey]
|
|
|| process.env.PATH
|
|
|| "";
|
|
pathEnv[pathKey] = currentPath ? `${wrapperDir}${path.delimiter}${currentPath}` : wrapperDir;
|
|
} catch {
|
|
// Wrapper is best-effort; destination --ssh-option still applies.
|
|
}
|
|
}
|
|
|
|
const userHost = `${options.username || os.userInfo().username}@${options.hostname}`;
|
|
|
|
return {
|
|
userHost,
|
|
sshOptions,
|
|
identityFilePaths: identityPaths,
|
|
etJumpArgs,
|
|
env: {
|
|
// Set HOME/USERPROFILE so helpers that honor $HOME still find the
|
|
// session config; the PATH wrapper above is the enforceable path.
|
|
...(writesConfigFile ? { HOME: tempDir, USERPROFILE: tempDir } : {}),
|
|
...pathEnv,
|
|
...askpass.env,
|
|
},
|
|
artifacts: [tempDir, ...askpass.artifacts],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Remove leftover et-ssh-home-* temp directories from previous sessions
|
|
* that were not cleaned up (e.g. due to a crash).
|
|
*/
|
|
function cleanupStaleEtTempDirs() {
|
|
try {
|
|
const tempDir = tempDirBridge.getTempDir();
|
|
if (!fs.existsSync(tempDir)) return;
|
|
const entries = fs.readdirSync(tempDir);
|
|
for (const entry of entries) {
|
|
if (!entry.startsWith("et-ssh-home-")) continue;
|
|
try {
|
|
fs.rmSync(path.join(tempDir, entry), { recursive: true, force: true });
|
|
} catch {
|
|
// ignore per-entry cleanup failures
|
|
}
|
|
}
|
|
} catch {
|
|
// ignore — best-effort cleanup
|
|
}
|
|
}
|
|
|
|
function cleanupSessionExternalAuthArtifacts(session) {
|
|
if (!session || session.externalAuthArtifactsCleaned) return;
|
|
session.externalAuthArtifactsCleaned = true;
|
|
const artifacts = Array.isArray(session.externalAuthArtifacts)
|
|
? session.externalAuthArtifacts
|
|
: [];
|
|
|
|
for (const artifactPath of artifacts) {
|
|
try {
|
|
fs.rmSync(artifactPath, { recursive: true, force: true });
|
|
} catch {
|
|
// ignore cleanup failures
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Prepend an optional bundled DLL directory (dynamically-linked Windows
|
|
* builds only) to PATH so the spawned et.exe can find its runtime DLLs.
|
|
* Static MSVC builds ship no DLLs and this is a no-op.
|
|
*/
|
|
function addBundledEtDllPath(env, etClient, opts = {}) {
|
|
const platform = opts.platform || process.platform;
|
|
if (platform !== "win32" || !etClient) return env;
|
|
const clientDir = path.dirname(etClient);
|
|
const arch = opts.arch || process.arch;
|
|
const dllDir = path.join(clientDir, `et-win32-${arch}-dlls`);
|
|
if (fs.existsSync(dllDir) && fs.statSync(dllDir).isDirectory()) {
|
|
const pathKey = Object.keys(env).find((k) => k.toLowerCase() === "path") || "PATH";
|
|
const current = env[pathKey] || "";
|
|
env[pathKey] = current ? `${dllDir};${current}` : dllDir;
|
|
}
|
|
return env;
|
|
}
|
|
|
|
/**
|
|
* Build a known_hosts file for background ET exec (stats / distro probes).
|
|
* Reuses the vault-authoritative snapshot builder so system pins for
|
|
* vault-covered hosts cannot override the vault key (same policy as the
|
|
* interactive ET bootstrap). Falls back to system+vault merge without
|
|
* filtering when the vault has no usable pins.
|
|
*/
|
|
function ensureStrictExecKnownHostsFile(session, knownHosts) {
|
|
if (session.etStrictExecKnownHostsPath) {
|
|
return session.etStrictExecKnownHostsPath;
|
|
}
|
|
|
|
const hostname = session.etStatsAuth?.hostname
|
|
|| session.sshUserHost?.split("@").pop()
|
|
|| "localhost";
|
|
let content = buildAuthoritativeKnownHostsContent({
|
|
knownHosts,
|
|
fs,
|
|
hostname,
|
|
port: session.etStatsAuth?.port || 22,
|
|
username: session.etStatsAuth?.username,
|
|
pathModule: path,
|
|
homedir: os.homedir(),
|
|
});
|
|
|
|
// No vault pins: keep the previous fail-closed merge of system + any
|
|
// configured user known_hosts so probes still have a trust source.
|
|
if (!content) {
|
|
const { readSystemKnownHostsContent } = createSystemKnownHostsApi({
|
|
fs, path, os, crypto, log: console,
|
|
});
|
|
const chunks = [];
|
|
try {
|
|
const systemContent = readSystemKnownHostsContent();
|
|
if (systemContent) chunks.push(systemContent);
|
|
} catch {
|
|
// ignore read failures — strict checking fails closed below
|
|
}
|
|
const configuredKnownHosts = (session.sshOptions || []).find(
|
|
(opt) => opt.startsWith("UserKnownHostsFile="),
|
|
);
|
|
if (configuredKnownHosts) {
|
|
const configuredPath = configuredKnownHosts.slice("UserKnownHostsFile=".length)
|
|
.replace(/^"|"$/g, "");
|
|
try {
|
|
const configuredContent = fs.readFileSync(configuredPath, "utf8");
|
|
if (configuredContent) chunks.push(configuredContent);
|
|
} catch {
|
|
// ignore missing configured file
|
|
}
|
|
}
|
|
content = chunks.filter(Boolean).join("\n");
|
|
if (content && !content.endsWith("\n")) content += "\n";
|
|
}
|
|
|
|
const artifact = Array.isArray(session.externalAuthArtifacts)
|
|
? session.externalAuthArtifacts[0]
|
|
: null;
|
|
const sshDir = artifact ? path.dirname(artifact) : tempDirBridge.getTempDir();
|
|
const strictKhPath = path.join(sshDir, "netcatty-et-strict-known_hosts");
|
|
writeSecureFile(strictKhPath, content || "", 0o600);
|
|
session.etStrictExecKnownHostsPath = strictKhPath;
|
|
if (Array.isArray(session.externalAuthArtifacts)) {
|
|
session.externalAuthArtifacts.push(strictKhPath);
|
|
}
|
|
return strictKhPath;
|
|
}
|
|
|
|
/**
|
|
* Execute a remote command on an ET session by spawning a system ssh
|
|
* process. Reuses the SSH environment (keys, config, askpass) already
|
|
* prepared by prepareEtSshEnvironment() for the ET connection.
|
|
*
|
|
* @param {object} [execOpts]
|
|
* @param {boolean} [execOpts.requireTrustedHost] When true, refuse unknown
|
|
* host keys (StrictHostKeyChecking=yes) using system + vault known_hosts
|
|
* instead of accept-new. Used for background stats/distro probes.
|
|
* @param {Array} [execOpts.knownHosts] Netcatty vault known hosts to merge
|
|
* into the strict known_hosts file (defaults to session.etStatsAuth).
|
|
*/
|
|
function execOnEtSession(session, command, timeoutMs = 5000, execOpts = {}) {
|
|
if (!session?.sshUserHost || session.externalAuthArtifactsCleaned) {
|
|
return Promise.resolve({ success: false, error: "ET SSH environment not available" });
|
|
}
|
|
|
|
const requireTrustedHost = execOpts.requireTrustedHost === true;
|
|
const knownHosts = execOpts.knownHosts ?? session.etStatsAuth?.knownHosts;
|
|
|
|
const sshCmd = process.platform === "win32" ? findExecutable("ssh") : "ssh";
|
|
const args = ["-o", "BatchMode=no"];
|
|
// OpenSSH resolves the user config from the account home directory, not
|
|
// $HOME. Force the session-generated config (ProxyJump + jump Host-key
|
|
// policy) with -F so the ProxyJump child actually sees it.
|
|
const sessionHome = session.sshEnv?.HOME || session.sshEnv?.USERPROFILE;
|
|
if (sessionHome) {
|
|
const sessionConfigPath = path.join(sessionHome, ".ssh", "config");
|
|
try {
|
|
if (fs.existsSync(sessionConfigPath)) {
|
|
args.push("-F", sessionConfigPath);
|
|
}
|
|
} catch {
|
|
// Best-effort; fall through without -F.
|
|
}
|
|
}
|
|
// OpenSSH keeps the first StrictHostKeyChecking value it sees. Only inject
|
|
// accept-new when the session did not already supply a policy (e.g.
|
|
// verifyHostKeys=false → StrictHostKeyChecking=no on the interactive ET
|
|
// bootstrap). Prepending accept-new would otherwise win over the session
|
|
// setting and re-enable mismatch rejection on follow-up ssh execs.
|
|
const sessionHasStrictHostKeyChecking = (session.sshOptions || []).some(
|
|
(opt) => typeof opt === "string" && opt.startsWith("StrictHostKeyChecking="),
|
|
);
|
|
if (!requireTrustedHost && !sessionHasStrictHostKeyChecking) {
|
|
args.push("-o", "StrictHostKeyChecking=accept-new");
|
|
}
|
|
for (const opt of session.sshOptions) {
|
|
if (requireTrustedHost && opt.startsWith("StrictHostKeyChecking=")) continue;
|
|
if (requireTrustedHost && opt.startsWith("UserKnownHostsFile=")) continue;
|
|
args.push("-o", opt);
|
|
}
|
|
if (requireTrustedHost) {
|
|
const strictKhPath = ensureStrictExecKnownHostsFile(session, knownHosts);
|
|
args.push("-o", `UserKnownHostsFile=${normalizeSshConfigPath(strictKhPath)}`);
|
|
args.push("-o", "StrictHostKeyChecking=yes");
|
|
}
|
|
args.push(session.sshUserHost, command);
|
|
|
|
return new Promise((resolve) => {
|
|
const { buildTerminalProcessEnv } = require("../httpNetworkProxyBridge.cjs");
|
|
const execFileOptions = {
|
|
env: { ...buildTerminalProcessEnv(process.env), ...session.sshEnv },
|
|
timeout: timeoutMs,
|
|
encoding: "utf8",
|
|
windowsHide: true,
|
|
};
|
|
const maxBuffer = Number(execOpts.maxBuffer);
|
|
if (Number.isFinite(maxBuffer) && maxBuffer > 0) {
|
|
execFileOptions.maxBuffer = Math.floor(maxBuffer);
|
|
}
|
|
const child = execFile(sshCmd, args, execFileOptions, (err, stdout, stderr) => {
|
|
if (err) {
|
|
resolve({
|
|
success: false,
|
|
error: err.message,
|
|
stdout: stdout || "",
|
|
stderr: stderr || "",
|
|
code: typeof err.code === "number" && err.code !== 0 ? err.code : 1,
|
|
});
|
|
} else {
|
|
resolve({ success: true, stdout: stdout || "", stderr: stderr || "", code: 0 });
|
|
}
|
|
});
|
|
if (typeof execOpts.stdin === "string") {
|
|
child.stdin?.end(execOpts.stdin);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Start an EternalTerminal session using Netcatty's bundled `et` client.
|
|
*/
|
|
async function startEtSession(event, options) {
|
|
const sessionId =
|
|
options.sessionId ||
|
|
`et-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
|
|
const cols = options.cols || 80;
|
|
const rows = options.rows || 24;
|
|
|
|
const etCmd = resolveBareEtClient({});
|
|
if (!etCmd) {
|
|
throw new Error(
|
|
"Bundled et client not found. Run `npm run fetch:et:dev` for local dev, " +
|
|
"or ensure release packaging downloads the et binary release before building.",
|
|
);
|
|
}
|
|
|
|
const args = [];
|
|
|
|
// ET server port (default 2022)
|
|
if (options.etPort && options.etPort !== 2022) {
|
|
args.push("-p", String(options.etPort));
|
|
}
|
|
|
|
let sshEnvironment;
|
|
try {
|
|
const preparedOptions = await prepareEtSshAgentOptions(options);
|
|
options = preparedOptions;
|
|
if (options.agentForwarding && options._resolvedForwardingAgentSocket) {
|
|
args.push("-f", "--ssh-socket", options._resolvedForwardingAgentSocket);
|
|
}
|
|
sshEnvironment = prepareEtSshEnvironment(sessionId, preparedOptions);
|
|
} catch (err) {
|
|
throw new Error(err instanceof Error ? err.message : String(err));
|
|
}
|
|
|
|
// Pass all SSH options inline via --ssh-option (bypasses config file lookup)
|
|
for (const opt of sshEnvironment.sshOptions) {
|
|
args.push("--ssh-option", opt);
|
|
}
|
|
|
|
// Route through a jump host via ET's own --jumphost/--jport when set, so
|
|
// ET's TCP socket targets the jumphost and the destination is reached
|
|
// over the SSH tunnel rather than a direct (often unreachable) etserver.
|
|
if (Array.isArray(sshEnvironment.etJumpArgs) && sshEnvironment.etJumpArgs.length > 0) {
|
|
args.push(...sshEnvironment.etJumpArgs);
|
|
}
|
|
|
|
args.push(sshEnvironment.userHost);
|
|
|
|
const { buildTerminalProcessEnv } = require("../httpNetworkProxyBridge.cjs");
|
|
const env = {
|
|
...buildTerminalProcessEnv(process.env),
|
|
...(options.env || {}),
|
|
...(sshEnvironment?.env || {}),
|
|
TERM: "xterm-256color",
|
|
// et prints a 3-line telemetry notice to stdout on first run. The
|
|
// "first run" flag is tracked per-HOME, and prepareEtSshEnvironment
|
|
// gives each session a fresh private temp HOME, so et treats every
|
|
// connection as first-run and prints it every time. That banner is
|
|
// pre-connection output that both pollutes the terminal and trips the
|
|
// renderer's "first PTY byte = connected" check
|
|
// (terminalSessionAttachment.ts). Opt out unconditionally — this also
|
|
// suppresses the notice and disables anonymous error reporting.
|
|
ET_NO_TELEMETRY: "1",
|
|
};
|
|
|
|
applyEtSshAgentEnvironment(env, options);
|
|
|
|
addBundledEtDllPath(env, etCmd);
|
|
|
|
try {
|
|
const proc = pty.spawn(etCmd, args, {
|
|
cols,
|
|
rows,
|
|
env,
|
|
cwd: os.homedir(),
|
|
encoding: null, // Return Buffer for ZMODEM binary support
|
|
useConptyDll: process.platform === "win32",
|
|
});
|
|
|
|
const session = {
|
|
proc,
|
|
pty: proc,
|
|
type: "et",
|
|
protocol: "et",
|
|
webContentsId: event.sender.id,
|
|
hostname: options.hostname || "",
|
|
username: options.username || "",
|
|
label: options.label || options.hostname || "ET Session",
|
|
// Leave unset so ensureSessionShellKind can probe via companion SSH
|
|
// exec before AI wrappers (fish login shells — issue #1854).
|
|
shellKind: undefined,
|
|
_shellKindExecProbe: async (command, timeoutMs) => {
|
|
const result = await execOnEtSession(session, command, timeoutMs, {
|
|
requireTrustedHost: true,
|
|
knownHosts: session.etStatsAuth?.knownHosts,
|
|
});
|
|
return result?.success ? (result.stdout || "") : null;
|
|
},
|
|
shellExecutable: "remote-shell",
|
|
externalAuthArtifacts: sshEnvironment?.artifacts || [],
|
|
externalAuthArtifactsCleaned: false,
|
|
// SSH environment for remote command execution (stats, distro detection)
|
|
sshEnv: sshEnvironment?.env || {},
|
|
sshOptions: sshEnvironment?.sshOptions || [],
|
|
sshUserHost: sshEnvironment?.userHost || "",
|
|
tcpLatencyTarget: {
|
|
hostname: options.hostname,
|
|
port: options.etPort || 2022,
|
|
},
|
|
tcpLatencyDirect:
|
|
(!Array.isArray(options.jumpHosts) || options.jumpHosts.length === 0) && !options.proxy,
|
|
etStatsAuth: {
|
|
hostname: options.hostname,
|
|
port: options.port || 22,
|
|
username: options.username,
|
|
authMethod: options.authMethod,
|
|
password: options.password,
|
|
privateKey: options.privateKey,
|
|
passphrase: options.passphrase,
|
|
certificate: options.certificate,
|
|
keyId: options.keyId,
|
|
identityFilePaths: sshEnvironment?.identityFilePaths,
|
|
agentPublicKeys: options.agentPublicKeys,
|
|
useSshAgent: options.useSshAgent,
|
|
identityAgent: options.identityAgent,
|
|
identitiesOnly: options.identitiesOnly,
|
|
addKeysToAgent: options.addKeysToAgent,
|
|
useKeychain: options.useKeychain,
|
|
legacyAlgorithms: options.legacyAlgorithms,
|
|
skipEcdsaHostKey: options.skipEcdsaHostKey,
|
|
algorithmOverrides: options.algorithmOverrides,
|
|
knownHosts: options.knownHosts,
|
|
verifyHostKeys: options.verifyHostKeys,
|
|
hasJumpHost: Array.isArray(options.jumpHosts) && options.jumpHosts.length > 0,
|
|
hasProxy: !!options.proxy,
|
|
},
|
|
systemManagerSudoPassword: typeof options.sudoAutofillPassword === "string" && options.sudoAutofillPassword.length > 0
|
|
? options.sudoAutofillPassword
|
|
: undefined,
|
|
flushPendingData: null,
|
|
lastIdlePrompt: "",
|
|
lastIdlePromptAt: 0,
|
|
_promptTrackTail: "",
|
|
};
|
|
{
|
|
const { claimSessionSlot } = require("../sessionBootEpoch.cjs");
|
|
const claim = claimSessionSlot(sessions, sessionId, session, options.bootEpoch);
|
|
if (!claim.ok) {
|
|
try { proc.kill(); } catch { /* ignore */ }
|
|
cleanupSessionExternalAuthArtifacts(session);
|
|
const supersededError = new Error("Connection superseded by a newer reconnect");
|
|
supersededError.code = "NETCATTY_BOOT_SUPERSEDED";
|
|
throw supersededError;
|
|
}
|
|
}
|
|
openTerminalOutputSession?.(sessionId, event.sender);
|
|
|
|
// Start real-time session log stream if configured
|
|
if (options.sessionLog?.enabled && options.sessionLog?.directory) {
|
|
const logStreamToken = sessionLogStreamManager.startStream(sessionId, {
|
|
hostLabel: options.label || options.hostname,
|
|
hostname: options.hostname,
|
|
directory: options.sessionLog.directory,
|
|
format: options.sessionLog.format || "txt",
|
|
timestampsEnabled: Boolean(options.sessionLog.timestampsEnabled),
|
|
startTime: Date.now(),
|
|
});
|
|
session.logStreamToken = logStreamToken;
|
|
}
|
|
|
|
const {
|
|
bufferData: bufferEtData,
|
|
flushPaced: flushEtPaced,
|
|
discard: discardEt,
|
|
} = createPtyOutputBuffer((data, meta) => {
|
|
const contents = electronModule.webContents.fromId(session.webContentsId);
|
|
emitTerminalSessionData(contents, sessionId, data, {
|
|
session,
|
|
cols: session.cols,
|
|
rows: session.rows,
|
|
meta,
|
|
});
|
|
}, {
|
|
onPendingBytesChange: (bytes) => setBufferedOutputBytes(session, bytes),
|
|
shouldAcceptOutput: () => sessions.get(sessionId) === session && shouldAcceptSessionOutput(session),
|
|
});
|
|
session.flushPendingData = flushEtPaced;
|
|
session.discardPendingData = discardEt;
|
|
|
|
if (process.platform !== "win32") {
|
|
const etDecoder = new StringDecoder("utf8");
|
|
const etZmodemSentry = createZmodemSentry({
|
|
sessionId,
|
|
onData(buf) {
|
|
const str = etDecoder.write(buf);
|
|
if (!str) return;
|
|
trackSessionIdlePrompt(session, str);
|
|
bufferEtData(str);
|
|
sessionLogStreamManager.appendData(sessionId, str);
|
|
},
|
|
writeToRemote(buf) {
|
|
try { return proc.write(buf); } catch { return true; }
|
|
},
|
|
getWebContents() {
|
|
return electronModule.webContents.fromId(session.webContentsId);
|
|
},
|
|
selectUploadFiles: selectZmodemUploadFiles
|
|
? () => selectZmodemUploadFiles(session.webContentsId, sessionId)
|
|
: undefined,
|
|
selectDownloadDirectory: selectZmodemDownloadDirectory
|
|
? () => selectZmodemDownloadDirectory(session.webContentsId, sessionId)
|
|
: undefined,
|
|
label: "ET",
|
|
});
|
|
session.zmodemSentry = etZmodemSentry;
|
|
|
|
proc.onData((data) => {
|
|
if (sessions.get(sessionId) !== session) return;
|
|
if (!shouldProcessSessionOutput(session, etZmodemSentry)) return;
|
|
etZmodemSentry.consume(data);
|
|
});
|
|
} else {
|
|
proc.onData((data) => {
|
|
if (sessions.get(sessionId) !== session) return;
|
|
if (!shouldProcessSessionOutput(session)) return;
|
|
trackSessionIdlePrompt(session, data);
|
|
bufferEtData(data);
|
|
sessionLogStreamManager.appendData(sessionId, data);
|
|
});
|
|
}
|
|
|
|
let etExitFinalized = false;
|
|
proc.onExit((evt) => {
|
|
flushEtPaced(() => {
|
|
if (etExitFinalized) return;
|
|
if (sessions.get(sessionId) !== session) return;
|
|
etExitFinalized = true;
|
|
try { session.etStatsConn?.end(); } catch { /* ignore */ }
|
|
cleanupSessionExternalAuthArtifacts(session);
|
|
sessionLogStreamManager.stopStream(sessionId, session.logStreamToken);
|
|
closeTerminalOutputSession?.(sessionId);
|
|
sessions.delete(sessionId);
|
|
if (session.closed) return;
|
|
const contents = electronModule.webContents.fromId(session.webContentsId);
|
|
fanoutSessionExit(sessionId, contents, {
|
|
sessionId,
|
|
...evt,
|
|
reason: evt.exitCode === 0 ? "exited" : "error",
|
|
_terminalSessionGeneration: session._terminalSessionGeneration,
|
|
});
|
|
});
|
|
});
|
|
|
|
return { sessionId };
|
|
} catch (err) {
|
|
if (sshEnvironment?.artifacts) {
|
|
cleanupSessionExternalAuthArtifacts({
|
|
externalAuthArtifacts: sshEnvironment.artifacts,
|
|
externalAuthArtifactsCleaned: false,
|
|
});
|
|
}
|
|
console.error("[ET] Failed to start EternalTerminal session:", err.message);
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
return {
|
|
resolveBareEtClient,
|
|
prepareEtSshEnvironment,
|
|
prepareEtSshAgentOptions,
|
|
applyEtSshAgentEnvironment,
|
|
cleanupStaleEtTempDirs,
|
|
cleanupSessionExternalAuthArtifacts,
|
|
addBundledEtDllPath,
|
|
execOnEtSession,
|
|
startEtSession,
|
|
};
|
|
}
|
|
}
|
|
|
|
module.exports = { createEtSessionApi };
|