import * as NodeCrypto from "node:crypto"; import type { DesktopSshEnvironmentTarget, DesktopUpdateChannel } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { buildSshChildEnvironment, type SshAuthOptions } from "./auth.ts"; import { SshCommandError, SshInvalidTargetError } from "./errors.ts"; const PUBLISHABLE_T3_VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u; const DEFAULT_SSH_COMMAND_TIMEOUT_MS = 60_000; const MAX_SSH_ERROR_OUTPUT_LENGTH = 4_000; /** * ssh is a real executable everywhere (`ssh.exe` on Windows), so it is always * spawned directly — cmd.exe shell mode would re-tokenize arguments such as * identity-file paths containing spaces. */ const sshCommandForPlatform = (platform: NodeJS.Platform): string => platform === "win32" ? "ssh.exe" : "ssh"; export const resolveSshCommand = Effect.map(HostProcessPlatform, sshCommandForPlatform); const encoder = new TextEncoder(); export interface SshCommandResult { readonly stdout: string; readonly stderr: string; } export interface RunSshCommandOptions extends SshAuthOptions { readonly preHostArgs?: ReadonlyArray; readonly remoteCommandArgs?: ReadonlyArray; readonly stdin?: string; readonly timeoutMs?: number; } export function parseSshResolveOutput(alias: string, stdout: string): DesktopSshEnvironmentTarget { const values = new Map(); for (const line of stdout.split(/\r?\n/u)) { const trimmed = line.trim(); if (trimmed.length === 0) { continue; } const [key, ...rest] = trimmed.split(/\s+/u); if (!key || rest.length === 0 || values.has(key)) { continue; } values.set(key, rest.join(" ").trim()); } const hostname = values.get("hostname")?.trim() || alias; const username = values.get("user")?.trim() || null; const rawPort = values.get("port")?.trim() ?? ""; const parsedPort = Number.parseInt(rawPort, 10); return { alias, hostname, username, port: Number.isInteger(parsedPort) ? parsedPort : null, }; } export function targetConnectionKey(target: DesktopSshEnvironmentTarget): string { return `${target.alias}\u0000${target.hostname}\u0000${target.username ?? ""}\u0000${target.port ?? ""}`; } export function remoteStateKey(target: DesktopSshEnvironmentTarget): string { return NodeCrypto.createHash("sha256") .update(targetConnectionKey(target)) .digest("hex") .slice(0, 16); } function buildSshHostSpec(target: DesktopSshEnvironmentTarget): string { const destination = target.alias.trim() || target.hostname.trim(); if (destination.length === 0) { throw new Error("SSH target is missing its alias/hostname."); } return target.username ? `${target.username}@${destination}` : destination; } export const buildSshHostSpecEffect = ( target: DesktopSshEnvironmentTarget, ): Effect.Effect => Effect.try({ try: () => buildSshHostSpec(target), catch: (cause) => new SshInvalidTargetError({ message: cause instanceof Error ? cause.message : "SSH target is invalid.", }), }); export function baseSshArgs( target: DesktopSshEnvironmentTarget, input?: { readonly batchMode?: "yes" | "no" }, ): string[] { return [ "-o", `BatchMode=${input?.batchMode ?? "no"}`, "-o", "ConnectTimeout=10", ...(target.port !== null ? ["-p", String(target.port)] : []), ]; } export function getLastNonEmptyOutputLine(stdout: string): string | null { return ( stdout .trim() .split(/\r?\n/u) .map((entry) => entry.trim()) .findLast((entry) => entry.length > 0) ?? null ); } export const collectProcessOutput = ( stream: Stream.Stream, ): Effect.Effect => stream.pipe( Stream.decodeText(), Stream.runFold( () => "", (acc, chunk) => acc + chunk, ), ); function redactSshErrorOutput(output: string): string { const redacted = output.replace( /("(?:access_token|bearerToken|credential|pairingToken|token)"\s*:\s*")[^"]+(")/giu, "$1[redacted]$2", ); return redacted.length > MAX_SSH_ERROR_OUTPUT_LENGTH ? `${redacted.slice(0, MAX_SSH_ERROR_OUTPUT_LENGTH)}\n[truncated]` : redacted; } function normalizeSshErrorMessage(input: { readonly stdout?: string; readonly stderr: string; readonly fallbackMessage: string; }): string { const cleanedStderr = input.stderr.trim(); if (cleanedStderr.length > 0) { return cleanedStderr; } const cleanedStdout = input.stdout?.trim() ?? ""; return cleanedStdout.length > 0 ? cleanedStdout : input.fallbackMessage; } function sshTargetLogFields(target: DesktopSshEnvironmentTarget) { return { alias: target.alias, hostname: target.hostname, username: target.username, port: target.port, }; } function stdinStream(input: string | undefined) { return input === undefined ? Stream.empty : Stream.make(encoder.encode(input)); } const runSshCommandInScope = Effect.fn("ssh/command.runSshCommand.inScope")(function* ( target: DesktopSshEnvironmentTarget, input: RunSshCommandOptions, commandScope: Scope.Scope, ): Effect.fn.Return< SshCommandResult, SshCommandError | SshInvalidTargetError, ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path > { const hostSpec = yield* buildSshHostSpecEffect(target); const environment = yield* buildSshChildEnvironment({ ...(input.interactiveAuth === undefined ? {} : { interactiveAuth: input.interactiveAuth }), ...(input.authSecret === undefined ? {} : { authSecret: input.authSecret }), }).pipe( Effect.mapError( (cause) => new SshCommandError({ command: ["ssh"], exitCode: null, stderr: "", message: "Failed to prepare SSH authentication helpers.", cause, }), ), ); const args = [ ...baseSshArgs(target, { batchMode: input.batchMode ?? (input.interactiveAuth ? "no" : "yes"), }), ...(input.preHostArgs ?? []), hostSpec, ...(input.remoteCommandArgs ?? []), ]; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const sshCommand = yield* resolveSshCommand; yield* Effect.logDebug("ssh.command.start", { ...sshTargetLogFields(target), command: [sshCommand, ...args], hasStdin: input.stdin !== undefined, timeoutMs: input.timeoutMs ?? DEFAULT_SSH_COMMAND_TIMEOUT_MS, }); const child = yield* spawner .spawn( ChildProcess.make(sshCommand, args, { env: environment, extendEnv: true, stdin: { stream: stdinStream(input.stdin), endOnDone: true, }, }), ) .pipe( Effect.provideService(Scope.Scope, commandScope), Effect.mapError( (cause) => new SshCommandError({ command: [sshCommand, ...args], exitCode: null, stderr: "", message: cause instanceof Error ? cause.message : `Failed to spawn SSH command for ${hostSpec}.`, cause, }), ), ); const [stdout, stderr, exitCode] = yield* Effect.all( [ collectProcessOutput(child.stdout), collectProcessOutput(child.stderr), child.exitCode.pipe(Effect.map(Number)), ], { concurrency: "unbounded" }, ).pipe( Effect.mapError( (cause) => new SshCommandError({ command: ["ssh", ...args], exitCode: null, stderr: "", message: cause instanceof Error ? cause.message : `Failed to run SSH command for ${hostSpec}.`, cause, }), ), ); if (exitCode !== 0) { const diagnosticStdout = redactSshErrorOutput(stdout); yield* Effect.logWarning("ssh.command.failed", { ...sshTargetLogFields(target), command: ["ssh", ...args], exitCode, stdout: diagnosticStdout, stderr, }); return yield* new SshCommandError({ command: ["ssh", ...args], exitCode, stdout: diagnosticStdout, stderr, message: normalizeSshErrorMessage({ stdout: diagnosticStdout, stderr, fallbackMessage: `SSH command failed for ${hostSpec} (exit ${exitCode}).`, }), }); } yield* Effect.logDebug("ssh.command.succeeded", { ...sshTargetLogFields(target), command: ["ssh", ...args], }); return { stdout, stderr }; }); export const runSshCommand = Effect.fn("ssh/command.runSshCommand")(function* ( target: DesktopSshEnvironmentTarget, input: RunSshCommandOptions = {}, ): Effect.fn.Return< SshCommandResult, SshCommandError | SshInvalidTargetError, ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path > { return yield* Effect.scopedWith((commandScope) => runSshCommandInScope(target, input, commandScope), ).pipe( Effect.timeoutOption(Duration.millis(input.timeoutMs ?? DEFAULT_SSH_COMMAND_TIMEOUT_MS)), Effect.flatMap((result) => Option.match(result, { onSome: Effect.succeed, onNone: () => Effect.gen(function* () { yield* Effect.logWarning("ssh.command.timedOut", { ...sshTargetLogFields(target), timeoutMs: input.timeoutMs ?? DEFAULT_SSH_COMMAND_TIMEOUT_MS, remoteCommandArgs: input.remoteCommandArgs ?? [], preHostArgs: input.preHostArgs ?? [], hasStdin: input.stdin !== undefined, }); return yield* new SshCommandError({ command: ["ssh"], exitCode: null, stderr: "", message: `SSH command timed out after ${input.timeoutMs ?? DEFAULT_SSH_COMMAND_TIMEOUT_MS}ms.`, }); }), }), ), ); }); export const resolveSshTarget = Effect.fn("ssh/command.resolveSshTarget")(function* ( alias: string, ): Effect.fn.Return< DesktopSshEnvironmentTarget, SshCommandError | SshInvalidTargetError, ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path > { const trimmedAlias = alias.trim(); if (trimmedAlias.length === 0) { return yield* new SshInvalidTargetError({ message: "SSH host alias is required." }); } yield* Effect.logDebug("ssh.target.resolve.start", { alias: trimmedAlias }); return yield* runSshCommand( { alias: trimmedAlias, hostname: trimmedAlias, username: null, port: null, }, { preHostArgs: ["-G"] }, ).pipe( Effect.map((result) => parseSshResolveOutput(trimmedAlias, result.stdout)), Effect.tap((target) => Effect.logDebug("ssh.target.resolve.succeeded", sshTargetLogFields(target)), ), Effect.catch((cause) => Effect.logDebug("ssh.target.resolve.fallback", { alias: trimmedAlias, cause }).pipe( Effect.as({ alias: trimmedAlias, hostname: trimmedAlias, username: null, port: null, }), ), ), ); }); export function resolveRemoteT3CliPackageSpec(input: { readonly appVersion: string; readonly updateChannel: DesktopUpdateChannel; readonly isDevelopment?: boolean; }): string { const appVersion = input.appVersion.trim(); if (!input.isDevelopment && PUBLISHABLE_T3_VERSION_PATTERN.test(appVersion)) { return `t3@${appVersion}`; } if (input.isDevelopment) { return "t3@nightly"; } return input.updateChannel === "nightly" ? "t3@nightly" : "t3@latest"; }