[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
219
electron/bridges/sshBridge/execCommand.cjs
Normal file
219
electron/bridges/sshBridge/execCommand.cjs
Normal file
@@ -0,0 +1,219 @@
|
||||
/* 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 };
|
||||
Reference in New Issue
Block a user