sshService.ts•8.62 kB
import { Client as SshClient, type ConnectConfig } from "ssh2";
import { getConfig } from "../config/index.js";
import { ConcurrencyLimiter } from "../utils/concurrency.js";
import { createAbortError } from "../utils/abort.js";
import type { ProgressUpdate } from "../utils/progress.js";
import { logger } from "../utils/logger.js";
export interface SshCommandOptions {
  timeoutMs?: number;
  cwd?: string;
  env?: Record<string, string>;
  maxOutputBytes?: number;
  signal?: AbortSignal;
  onProgress?: (update: ProgressUpdate) => void;
}
export interface SshExecutionMetadata {
  requestId?: string;
  tool?: string;
}
export interface SshCommandResult {
  stdout: string;
  stderr: string;
  truncated: {
    stdout: boolean;
    stderr: boolean;
  };
  exitCode: number | null;
  signal?: string;
  durationMs: number;
}
interface TruncatingBuffer {
  push(chunk: Buffer): void;
  toString(): string;
  isTruncated(): boolean;
  size(): number;
}
const sshConcurrency = new ConcurrencyLimiter();
function createTruncatingBuffer(limit: number): TruncatingBuffer {
  const chunks: Buffer[] = [];
  let byteLength = 0;
  let truncated = false;
  return {
    push(chunk: Buffer) {
      if (truncated || limit <= 0) {
        truncated = limit <= 0 ? true : truncated;
        return;
      }
      const available = limit - byteLength;
      if (available <= 0) {
        truncated = true;
        return;
      }
      if (chunk.length > available) {
        chunks.push(chunk.subarray(0, available));
        byteLength += available;
        truncated = true;
      } else {
        chunks.push(chunk);
        byteLength += chunk.length;
      }
    },
    toString() {
      if (chunks.length === 0) {
        return "";
      }
      return Buffer.concat(chunks, byteLength).toString();
    },
    isTruncated() {
      return truncated;
    },
    size() {
      return byteLength;
    }
  };
}
function ensureCommandAllowed(command: string, patterns?: RegExp[]): void {
  if (!patterns || patterns.length === 0) {
    return;
  }
  const allowed = patterns.some((regex) => regex.test(command));
  if (!allowed) {
    throw new Error("Command is not permitted by policy");
  }
}
export async function executeSshCommand(
  profileName: string,
  command: string,
  options: SshCommandOptions = {},
  metadata: SshExecutionMetadata = {}
): Promise<SshCommandResult> {
  const config = getConfig();
  const profile = config.sshProfiles[profileName];
  if (!profile) {
    throw new Error(`SSH profile '${profileName}' not found`);
  }
  const skipAuthorization = config.localTestMode && (profileName === "local-test" || profile.host === "127.0.0.1" || profile.host === "localhost");
  if (!skipAuthorization) {
    ensureCommandAllowed(command, profile.policy.allowedCommandPatterns);
  }
  const timeoutLimit = Math.min(options.timeoutMs ?? profile.policy.maxExecutionMs, profile.policy.maxExecutionMs);
  const outputLimit = Math.min(options.maxOutputBytes ?? profile.policy.maxOutputBytes, profile.policy.maxOutputBytes);
  options.onProgress?.({ progress: 0, message: "Waiting for SSH availability" });
  const release = await sshConcurrency.acquire(profileName, profile.policy.maxConcurrent, options.signal);
  const connectionConfig: ConnectConfig = {
    host: profile.host,
    port: profile.port,
    username: profile.username,
    readyTimeout: timeoutLimit
  };
  if (profile.privateKey) {
    connectionConfig.privateKey = profile.privateKey;
    if (profile.passphrase) {
      connectionConfig.passphrase = profile.passphrase;
    }
  } else if (profile.password) {
    connectionConfig.password = profile.password;
  } else {
    throw new Error(`SSH profile '${profileName}' is missing authentication details`);
  }
  const start = Date.now();
  logger.info("Executing SSH command", {
    profile: profileName,
    requestId: metadata.requestId,
    tool: metadata.tool,
    command
  });
  options.onProgress?.({ progress: 0.1, message: "Connecting to remote host" });
  try {
    return await new Promise((resolve, reject) => {
      const sshClient = new SshClient();
      let timeoutHandle: NodeJS.Timeout | undefined;
      let aborted = false;
      let settled = false;
      let abortListener: (() => void) | undefined;
      const stdoutBuffer = createTruncatingBuffer(outputLimit);
      const stderrBuffer = createTruncatingBuffer(outputLimit);
      const finalize = (executor: () => void) => {
        if (settled) {
          return;
        }
        settled = true;
        executor();
      };
      const cleanup = () => {
        if (timeoutHandle) {
          clearTimeout(timeoutHandle);
          timeoutHandle = undefined;
        }
        if (abortListener && options.signal) {
          options.signal.removeEventListener("abort", abortListener);
        }
        sshClient.end();
      };
      const rejectWithError = (error: Error) => {
        cleanup();
        finalize(() => reject(error));
      };
      const onTimeout = () => {
        options.onProgress?.({ progress: 1, message: "SSH command timed out" });
        logger.warn("SSH command timed out", {
          profile: profileName,
          requestId: metadata.requestId,
          tool: metadata.tool,
          timeoutMs: timeoutLimit
        });
        rejectWithError(new Error(`SSH command timed out after ${timeoutLimit} ms`));
      };
      if (options.signal) {
        if (options.signal.aborted) {
          rejectedDueToAbort();
          return;
        }
        abortListener = () => rejectedDueToAbort();
        options.signal.addEventListener("abort", abortListener, { once: true });
      }
      function rejectedDueToAbort(): void {
        aborted = true;
        options.onProgress?.({ progress: 1, message: "SSH command cancelled" });
        sshClient.destroy();
        rejectWithError(createAbortError("SSH command cancelled"));
      }
      sshClient
        .on("ready", () => {
          if (aborted) {
            rejectWithError(createAbortError("SSH command cancelled"));
            return;
          }
          options.onProgress?.({ progress: 0.4, message: "Executing remote command" });
          const execOptions = {
            cwd: options.cwd,
            env: options.env
          };
          sshClient.exec(command, execOptions, (err, stream) => {
            if (err) {
              rejectWithError(err);
              return;
            }
            let exitCode: number | null = null;
            let signal: string | undefined;
            stream
              .on("close", (code: number | null, signalName?: string) => {
                exitCode = code;
                signal = signalName ?? undefined;
                cleanup();
                const durationMs = Date.now() - start;
                const result: SshCommandResult = {
                  stdout: stdoutBuffer.toString(),
                  stderr: stderrBuffer.toString(),
                  truncated: {
                    stdout: stdoutBuffer.isTruncated(),
                    stderr: stderrBuffer.isTruncated()
                  },
                  exitCode,
                  signal,
                  durationMs
                };
                logger.info("SSH command completed", {
                  profile: profileName,
                  requestId: metadata.requestId,
                  tool: metadata.tool,
                  exitCode,
                  signal,
                  durationMs,
                  stdoutBytes: stdoutBuffer.size(),
                  stderrBytes: stderrBuffer.size(),
                  stdoutTruncated: result.truncated.stdout,
                  stderrTruncated: result.truncated.stderr
                });
                options.onProgress?.({ progress: 1, message: "SSH command completed" });
                finalize(() => resolve(result));
              })
              .on("data", (chunk: Buffer) => {
                stdoutBuffer.push(chunk);
              })
              .stderr.on("data", (chunk: Buffer) => {
                stderrBuffer.push(chunk);
              });
          });
        })
        .on("error", (err) => {
          options.onProgress?.({ progress: 1, message: "SSH command failed" });
          logger.error("SSH command failed", {
            profile: profileName,
            requestId: metadata.requestId,
            tool: metadata.tool,
            error: err.message
          });
          rejectWithError(err);
        })
        .connect(connectionConfig);
      timeoutHandle = setTimeout(onTimeout, timeoutLimit);
    });
  } finally {
    release();
  }
}