import type { DesktopSshEnvironmentTarget } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Digest from "effect/crypto/Digest"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Hex from "effect/encoding/Hex"; 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/process"; import { buildSshChildEnvironment, type SshAuthOptions } from "./auth.ts"; import { SshCommandError, SshInvalidTargetError } from "./errors.ts"; 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(HostProcess.Platform, 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; /** Keep the end of long failed-command stdout, where the final cause usually is. */ readonly keepErrorOutputTail?: boolean; } 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 const remoteStateKey = Effect.fn("ssh/command.remoteStateKey")(function* ( target: DesktopSshEnvironmentTarget, ): Effect.fn.Return { const digestService = yield* Digest.Digest; const digest = yield* digestService.digest( Digest.sha256, encoder.encode(targetConnectionKey(target)), ); return Hex.encode(digest).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 ); } interface SshCommandOutput { stdout: string; stderr: string; } // Appends as chunks arrive so a timeout can still report what was printed. const collectProcessOutput = ( stream: Stream.Stream, output: SshCommandOutput, key: keyof SshCommandOutput, ): Effect.Effect => stream.pipe( Stream.decodeText(), Stream.runForEach((chunk) => Effect.sync(() => { output[key] += chunk; }), ), ); function redactSshErrorOutput(output: string, keepTail = false): string { const redacted = output.replace( /("(?:access_token|bearerToken|credential|pairingToken|token)"\s*:\s*")[^"]+(")/giu, "$1[redacted]$2", ); if (redacted.length <= MAX_SSH_ERROR_OUTPUT_LENGTH) return redacted; return keepTail ? `[truncated]\n${redacted.slice(-MAX_SSH_ERROR_OUTPUT_LENGTH)}` : `${redacted.slice(0, MAX_SSH_ERROR_OUTPUT_LENGTH)}\n[truncated]`; } export function shellSingleQuote(value: string): string { return `'${value.replaceAll("'", "'\\''")}'`; } const SAFE_SHELL_ARG = /^[\w@%+=:,./-]+$/u; // cmd.exe and PowerShell both read double quotes, but not POSIX single quotes. function terminalArg(value: string, platform: NodeJS.Platform): string { if (SAFE_SHELL_ARG.test(value)) return value; return platform === "win32" ? `"${value}"` : shellSingleQuote(value); } // ssh prints this for unknown and changed host keys when it cannot ask. The app // never answers host key prompts, so the user checks the fingerprint in a terminal. function hostKeyVerificationMessage(input: { readonly target: DesktopSshEnvironmentTarget; readonly hostSpec: string; readonly stderr: string; readonly platform: NodeJS.Platform; }): string | null { const { target, hostSpec, stderr, platform } = input; if (!stderr.includes("Host key verification failed")) return null; const command = [ "ssh", ...(target.port !== null ? ["-p", String(target.port)] : []), ...(hostSpec.startsWith("-") ? ["--"] : []), hostSpec, ] .map((arg) => terminalArg(arg, platform)) .join(" "); const guidance = stderr.includes("REMOTE HOST IDENTIFICATION HAS CHANGED") ? `The host key for ${hostSpec} has changed since it was trusted. Only if you expect this change, remove the old key with the \`ssh-keygen -R\` command in the output below, run \`${command}\` in a terminal, check that the new host key fingerprint is the one you expect, then try again.` : `SSH could not verify the host key for ${hostSpec}. Run \`${command}\` in a terminal, check that the host key fingerprint is the one you expect, then try again.`; return `${guidance}\n\n${stderr.trim()}`; } 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; } export 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, output: SshCommandOutput, ): 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"), }), // Cold installs need a long command budget; a lost connection must fail sooner. "-o", "ServerAliveInterval=15", "-o", "ServerAliveCountMax=3", ...(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 [, , exitCode] = yield* Effect.all( [ collectProcessOutput(child.stdout, output, "stdout"), collectProcessOutput(child.stderr, output, "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, }), ), ); const { stdout, stderr } = output; if (exitCode !== 0) { const diagnosticStdout = redactSshErrorOutput(stdout, input.keepErrorOutputTail); 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: hostKeyVerificationMessage({ target, hostSpec, stderr, platform: yield* HostProcess.Platform, }) ?? 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 > { const output: SshCommandOutput = { stdout: "", stderr: "" }; return yield* Effect.scopedWith((commandScope) => runSshCommandInScope(target, input, commandScope, output), ).pipe( Effect.timeoutOption(Duration.millis(input.timeoutMs ?? DEFAULT_SSH_COMMAND_TIMEOUT_MS)), Effect.flatMap((result) => Option.match(result, { onSome: Effect.succeed, // The enclosing operation owns the span for this inline handler. onNone: Effect.fnUntraced(function* () { const diagnosticStdout = redactSshErrorOutput(output.stdout, input.keepErrorOutputTail); const { stderr } = output; 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, stdout: diagnosticStdout, stderr, }); return yield* new SshCommandError({ command: ["ssh"], exitCode: null, stdout: diagnosticStdout, 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, }), ), ), ); });