Files
Termix/src/backend/plugins/ctx-ssh-exec.ts
T
a89ccc6b07 v26.10.3 (#1692)
* feat: shell controller, TabStage, layout and theme plugins with repair view

* feat: move the classic layout into its own bundled plugin

* feat: move color themes into a bundled themes plugin

* fix: plugin tests and type checks work with a linked sdk

* fix: home tab, layout switch and missing layout fallbacks for looks

* chore: add classic layout and themes to the readme

* chore: drop example plugin links from the plugin guide

* feat: plugin filters by type, paged browse, hide plugins needing newer termix

* feat: move desktop, status, import, audit and branding features into plugins

* feat: host lock api so a plugin can load hosts from a config file (#1581)

* chore: add hosts config to the plugin table

* feat: login policy hook for plugins and optional https redirect (#1584)

* chore: add login security to the plugin table

* feat: api key socket tickets so a master can open remote sessions (#1654)

* feat: sync moves to a plugin, desktop gets multiple accounts (#1580, #1654)

* chore: add sync to the plugin table

* feat: move the sync screens and wizard out of core into the plugin (#1580)

* fix: add a neutral linked desktop hook for plugins (#1580)

* fix: show the linked account's name and admin flag in the app (#1580)

* feat: ssh transport providers, core ssh relay and plugin folders api

* feat: support FIDO2 security keys through ssh agent

* feat: add companion panels beside host tabs

* feat: mTLS client certificates for desktop server links

* feat: native web view for plugins in the desktop app

* fix: detect the desktop link window by Electron's default user agent

Fixes #1675

* fix: changelog for desktop link fix

* fix: keep text primary keys in the plugin data export

* fix: ship sharp binaries for Windows arm64 builds

* feat: client cert on the sync link types (sdk 1.0.18)

* chore: update translations

* feat: themed title bar for the desktop app (#1690)

* chore: changelog for the themed title bar

* fix: draw the title bar in the sidebar color

* feat: per host layout for the terminal panel bar

* chore: sdk 1.0.19 for the panel bar layout

* chore: bundle all 43 plugins for 26.10.3

* chore: update translations

---------

Co-authored-by: SirHephaistos <60616553+SirHephaistos@users.noreply.github.com>
Co-authored-by: Giovanny Aranda <garanda21@gmail.com>
2026-10-20 16:03:29 -05:00

124 lines
3.5 KiB
TypeScript

/**
* Runs one command on an open ssh2 client for ctx.ssh.exec, with a time limit
* and caps on how much output is kept.
*/
import type { PluginSshExecOptions } from "@termix-ssh/plugin-sdk/backend";
export const EXEC_DEFAULT_TIMEOUT_MS = 30_000;
export const EXEC_MAX_TIMEOUT_MS = 10 * 60_000;
export const EXEC_DEFAULT_MAX_OUTPUT = 256 * 1024;
export const EXEC_HARD_MAX_OUTPUT = 4 * 1024 * 1024;
export const EXEC_MAX_COMMAND_LENGTH = 64 * 1024;
interface ExecStream {
on: (event: string, listener: (...args: never[]) => void) => unknown;
stderr: { on: (event: string, listener: (chunk: Buffer) => void) => unknown };
write: (data: string) => unknown;
end: (data?: string) => unknown;
close: () => void;
}
export interface ExecClient {
exec: (
command: string,
callback: (error: Error | undefined, stream: ExecStream) => void,
) => unknown;
}
export interface ExecResult {
stdout: string;
stderr: string;
/** Null when the command was killed by a signal or hit the time limit. */
code: number | null;
truncated: boolean;
timedOut: boolean;
}
export function runExec(
client: ExecClient,
command: string,
options: PluginSshExecOptions = {},
): Promise<ExecResult> {
if (typeof command !== "string" || command.trim() === "") {
return Promise.reject(new Error("A command is required"));
}
if (command.length > EXEC_MAX_COMMAND_LENGTH) {
return Promise.reject(new Error("The command is too long"));
}
const timeoutMs = Math.min(
EXEC_MAX_TIMEOUT_MS,
Math.max(1000, options.timeoutMs ?? EXEC_DEFAULT_TIMEOUT_MS),
);
const maxOutput = Math.min(
EXEC_HARD_MAX_OUTPUT,
Math.max(1024, options.maxOutputBytes ?? EXEC_DEFAULT_MAX_OUTPUT),
);
return new Promise<ExecResult>((resolve, reject) => {
let settled = false;
let timedOut = false;
let truncated = false;
let stdout = "";
let stderr = "";
let timer: NodeJS.Timeout | null = null;
const finish = (code: number | null) => {
if (settled) return;
settled = true;
if (timer) clearTimeout(timer);
resolve({ stdout, stderr, code, truncated, timedOut });
};
const append = (current: string, chunk: Buffer): string => {
const room = maxOutput - Buffer.byteLength(current);
if (room <= 0) {
truncated = true;
return current;
}
if (chunk.length > room) {
truncated = true;
return current + chunk.subarray(0, room).toString("utf8");
}
return current + chunk.toString("utf8");
};
client.exec(command, (error, stream) => {
if (error) {
if (!settled) {
settled = true;
reject(error);
}
return;
}
timer = setTimeout(() => {
timedOut = true;
try {
stream.close();
} catch {
// already closed
}
finish(null);
}, timeoutMs);
stream.on("data", ((chunk: Buffer) => {
stdout = append(stdout, chunk);
}) as never);
stream.stderr.on("data", (chunk: Buffer) => {
stderr = append(stderr, chunk);
});
stream.on("close", ((code: number | null | undefined) => {
finish(typeof code === "number" ? code : null);
}) as never);
stream.on("error", ((streamError: Error) => {
if (!settled) {
settled = true;
if (timer) clearTimeout(timer);
reject(streamError);
}
}) as never);
if (options.stdin !== undefined) stream.end(options.stdin);
});
});
}