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
220 lines
8.3 KiB
JavaScript
220 lines
8.3 KiB
JavaScript
/* eslint-disable no-undef */
|
|
const { runWhenProxyConnectionReady } = require("../proxyUtils.cjs");
|
|
const { executeBoundedSshCommand } = require("../boundedSshExec.cjs");
|
|
|
|
const SSH_EXEC_COMMAND_MAX_OUTPUT_BYTES = 1024 * 1024;
|
|
|
|
function createExecCommandApi(ctx) {
|
|
with (ctx) {
|
|
async function execCommand(event, payload) {
|
|
const enableKeyboardInteractive = !!payload.enableKeyboardInteractive;
|
|
const commandTimeoutMs = payload.timeout || 10000;
|
|
const { tcpConnectTimeoutMs, authReadyTimeoutMs } = resolveSshConnectionTimeouts(payload);
|
|
const sender = event.sender;
|
|
const sessionId = payload.sessionId || randomUUID();
|
|
const hasCertificate = typeof payload.certificate === "string" && payload.certificate.trim().length > 0;
|
|
const fallbackAgentSocket = payload.useSshAgent === false
|
|
? null
|
|
: payload.useSshAgent === true
|
|
? undefined
|
|
: await getAvailableAgentSocket();
|
|
const systemAuthAgent = hasCertificate
|
|
? null
|
|
: await prepareSystemSshAgentForAuth(payload, "[SSH Exec]");
|
|
const defaultKeys = enableKeyboardInteractive && !(systemAuthAgent && payload.identitiesOnly)
|
|
? await findAllDefaultPrivateKeysFromHelper()
|
|
: [];
|
|
let identityFilePrivateKey = null;
|
|
let identityFilePassphrase = null;
|
|
const inlineKey = payload.privateKey && !systemAuthAgent
|
|
? await preparePrivateKeyForAuth({
|
|
sender,
|
|
privateKey: payload.privateKey,
|
|
keyId: payload.keyId,
|
|
keyName: payload.keyId || payload.username,
|
|
hostname: payload.hostname,
|
|
initialPassphrase: payload.passphrase,
|
|
logPrefix: "[SSH Exec]",
|
|
})
|
|
: null;
|
|
|
|
if (!payload.privateKey && !systemAuthAgent && payload.identityFilePaths?.length > 0) {
|
|
for (const keyPath of payload.identityFilePaths) {
|
|
try {
|
|
const identityFile = await loadIdentityFileForAuth({
|
|
sender,
|
|
keyPath,
|
|
hostname: payload.hostname,
|
|
initialPassphrase: payload.passphrase,
|
|
logPrefix: "[SSH Exec]",
|
|
});
|
|
if (!identityFile) {
|
|
continue;
|
|
}
|
|
identityFilePrivateKey = identityFile.privateKey;
|
|
identityFilePassphrase = identityFile.passphrase || null;
|
|
break;
|
|
} catch (err) {
|
|
if (isPassphraseCancelledError(err)) {
|
|
throw err;
|
|
}
|
|
console.warn("[SSH Exec] Failed to read identity file:", err?.message || err);
|
|
}
|
|
}
|
|
}
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const conn = new SSHClient();
|
|
let settled = false;
|
|
let authReadyTimer = null;
|
|
let commandController = null;
|
|
const clearTimers = () => {
|
|
if (authReadyTimer) clearTimeout(authReadyTimer);
|
|
authReadyTimer = null;
|
|
};
|
|
const rejectConnection = (err) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
clearTimers();
|
|
commandController?.abort?.(err);
|
|
conn.end();
|
|
reject(err);
|
|
};
|
|
|
|
conn
|
|
.once("connect", () => {
|
|
runWhenProxyConnectionReady(conn._sock, () => {
|
|
try { conn._sock?.setTimeout?.(0); } catch { /* ignore */ }
|
|
authReadyTimer = setTimeout(() => {
|
|
rejectConnection(new Error(`SSH authentication timeout to ${payload.hostname}`));
|
|
}, authReadyTimeoutMs);
|
|
authReadyTimer.unref?.();
|
|
});
|
|
})
|
|
.once("ready", () => {
|
|
if (authReadyTimer) clearTimeout(authReadyTimer);
|
|
authReadyTimer = null;
|
|
commandController = new AbortController();
|
|
void executeBoundedSshCommand(conn, payload.command, {
|
|
signal: commandController.signal,
|
|
openingTimeoutMs: commandTimeoutMs,
|
|
runTimeoutMs: commandTimeoutMs,
|
|
maxOutputBytes: SSH_EXEC_COMMAND_MAX_OUTPUT_BYTES,
|
|
}).then((result) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
clearTimers();
|
|
conn.end();
|
|
resolve({
|
|
stdout: result.stdout,
|
|
stderr: result.stderr,
|
|
code: result.code ?? (result.stderr ? 1 : 0),
|
|
});
|
|
}, rejectConnection);
|
|
})
|
|
.on("error", (err) => {
|
|
rejectConnection(err);
|
|
})
|
|
.once("timeout", () => {
|
|
rejectConnection(new Error(`SSH connection timeout to ${payload.hostname}`));
|
|
})
|
|
.once("end", () => {
|
|
if (settled) return;
|
|
rejectConnection(new Error("SSH connection closed unexpectedly"));
|
|
});
|
|
|
|
const connectOpts = {
|
|
host: payload.hostname,
|
|
port: payload.port || 22,
|
|
username: payload.username,
|
|
timeout: tcpConnectTimeoutMs,
|
|
readyTimeout: 0,
|
|
keepaliveInterval: 0,
|
|
// Honor the host's algorithm settings so one-off commands (e.g. the
|
|
// keychain "export public key to host" flow) negotiate with the same
|
|
// KEX / cipher / host-key set as the interactive terminal. Without
|
|
// this, a host that needs the ECDSA skip or legacy algorithms would
|
|
// connect in the terminal but still fail the same handshake here.
|
|
algorithms: buildAlgorithms(payload.legacyAlgorithms, {
|
|
skipEcdsaHostKey: payload.skipEcdsaHostKey,
|
|
algorithmOverrides: payload.algorithmOverrides,
|
|
}),
|
|
};
|
|
|
|
let authAgent = null;
|
|
const effectivePrivateKey = inlineKey?.privateKey || identityFilePrivateKey;
|
|
const effectivePassphrase = inlineKey?.passphrase || identityFilePassphrase;
|
|
if (systemAuthAgent) {
|
|
connectOpts.agent = systemAuthAgent;
|
|
} else if (hasCertificate) {
|
|
authAgent = new NetcattyAgent({
|
|
mode: "certificate",
|
|
webContents: event.sender,
|
|
meta: {
|
|
label: payload.keyId || payload.username || "",
|
|
certificate: payload.certificate,
|
|
privateKey: effectivePrivateKey,
|
|
passphrase: effectivePassphrase,
|
|
},
|
|
});
|
|
connectOpts.agent = authAgent;
|
|
} else if (effectivePrivateKey) {
|
|
connectOpts.privateKey = effectivePrivateKey;
|
|
if (effectivePassphrase) {
|
|
connectOpts.passphrase = effectivePassphrase;
|
|
}
|
|
}
|
|
|
|
if (payload.password) connectOpts.password = payload.password;
|
|
|
|
let authBanner = "";
|
|
if (enableKeyboardInteractive) {
|
|
connectOpts.tryKeyboard = true;
|
|
|
|
const authConfig = buildAuthHandler({
|
|
authMethod: payload.authMethod,
|
|
requiresMfa: !!payload.requiresMfa,
|
|
privateKey: connectOpts.privateKey,
|
|
password: connectOpts.password,
|
|
passphrase: connectOpts.passphrase,
|
|
agent: connectOpts.agent,
|
|
username: connectOpts.username,
|
|
logPrefix: "[SSH Exec]",
|
|
defaultKeys,
|
|
sshAgentSocketOverride: fallbackAgentSocket,
|
|
allowAgentFallback: payload.useSshAgent !== false,
|
|
});
|
|
|
|
applyAuthToConnOpts(connectOpts, authConfig);
|
|
const execAuthPhase = authConfig.authPhase || { hadPartialSuccess: false };
|
|
|
|
conn.on("banner", (message) => {
|
|
authBanner = String(message || "").trim();
|
|
});
|
|
conn.on("keyboard-interactive", createKeyboardInteractiveHandler({
|
|
sender,
|
|
sessionId,
|
|
hostId: payload.hostId,
|
|
hostname: payload.hostname,
|
|
password: payload.password,
|
|
logPrefix: "[SSH Exec]",
|
|
scope: "external",
|
|
getAuthBanner: () => authBanner,
|
|
shouldSkipAutoFill: () => shouldSkipKiPasswordAutoFill(execAuthPhase),
|
|
}));
|
|
} else if (connectOpts.agent) {
|
|
const order = ["agent"];
|
|
if (connectOpts.password) order.push("password");
|
|
connectOpts.authHandler = order;
|
|
}
|
|
|
|
conn.connect(connectOpts);
|
|
});
|
|
}
|
|
|
|
return { execCommand };
|
|
}
|
|
}
|
|
|
|
module.exports = { createExecCommandApi, SSH_EXEC_COMMAND_MAX_OUTPUT_BYTES };
|