1import type { DesktopSshEnvironmentTarget } from "@t3tools/contracts";
2import * as HostProcess from "@t3tools/shared/HostProcess";
3import * as Crypto from "effect/Crypto";
4import * as Duration from "effect/Duration";
5import * as Effect from "effect/Effect";
6import * as Hex from "effect/encoding/Hex";
7import * as FileSystem from "effect/FileSystem";
8import * as Option from "effect/Option";
9import * as Path from "effect/Path";
10import * as Scope from "effect/Scope";
11import * as Stream from "effect/Stream";
12import { ChildProcess, ChildProcessSpawner } from "effect/process";
13
14import { buildSshChildEnvironment, type SshAuthOptions } from "./auth.ts";
15import { SshCommandError, SshInvalidTargetError } from "./errors.ts";
16
17const DEFAULT_SSH_COMMAND_TIMEOUT_MS = 60_000;
18const MAX_SSH_ERROR_OUTPUT_LENGTH = 4_000;
19
20/**
21 * ssh is a real executable everywhere (`ssh.exe` on Windows), so it is always
22 * spawned directly — cmd.exe shell mode would re-tokenize arguments such as
23 * identity-file paths containing spaces.
24 */
25const sshCommandForPlatform = (platform: NodeJS.Platform): string =>
26 platform === "win32" ? "ssh.exe" : "ssh";
27
28export const resolveSshCommand = Effect.map(HostProcess.Platform, sshCommandForPlatform);
29
30const encoder = new TextEncoder();
31
32export interface SshCommandResult {
33 readonly stdout: string;
34 readonly stderr: string;
35}
36
37export interface RunSshCommandOptions extends SshAuthOptions {
38 readonly preHostArgs?: ReadonlyArray<string>;
39 readonly remoteCommandArgs?: ReadonlyArray<string>;
40 readonly stdin?: string;
41 readonly timeoutMs?: number;
42}
43
44export function parseSshResolveOutput(alias: string, stdout: string): DesktopSshEnvironmentTarget {
45 const values = new Map<string, string>();
46 for (const line of stdout.split(/\r?\n/u)) {
47 const trimmed = line.trim();
48 if (trimmed.length === 0) {
49 continue;
50 }
51 const [key, ...rest] = trimmed.split(/\s+/u);
52 if (!key || rest.length === 0 || values.has(key)) {
53 continue;
54 }
55 values.set(key, rest.join(" ").trim());
56 }
57
58 const hostname = values.get("hostname")?.trim() || alias;
59 const username = values.get("user")?.trim() || null;
60 const rawPort = values.get("port")?.trim() ?? "";
61 const parsedPort = Number.parseInt(rawPort, 10);
62
63 return {
64 alias,
65 hostname,
66 username,
67 port: Number.isInteger(parsedPort) ? parsedPort : null,
68 };
69}
70
71export function targetConnectionKey(target: DesktopSshEnvironmentTarget): string {
72 return `${target.alias}\u0000${target.hostname}\u0000${target.username ?? ""}\u0000${target.port ?? ""}`;
73}
74
75/** Names the remote state directory for a target: the first 16 hex chars of its SHA-256 key. */
76export const remoteStateKey = Effect.fn("ssh/command.remoteStateKey")(function* (
77 target: DesktopSshEnvironmentTarget,
78): Effect.fn.Return<string, never, Crypto.Crypto> {
79 const crypto = yield* Crypto.Crypto;
80 const digest = yield* crypto
81 .digest("SHA-256", encoder.encode(targetConnectionKey(target)))
82 .pipe(Effect.orDie);
83 return Hex.encode(digest).slice(0, 16);
84});
85
86function buildSshHostSpec(target: DesktopSshEnvironmentTarget): string {
87 const destination = target.alias.trim() || target.hostname.trim();
88 if (destination.length === 0) {
89 throw new Error("SSH target is missing its alias/hostname.");
90 }
91 return target.username ? `${target.username}@${destination}` : destination;
92}
93
94export const buildSshHostSpecEffect = (
95 target: DesktopSshEnvironmentTarget,
96): Effect.Effect<string, SshInvalidTargetError> =>
97 Effect.try({
98 try: () => buildSshHostSpec(target),
99 catch: (cause) =>
100 new SshInvalidTargetError({
101 message: cause instanceof Error ? cause.message : "SSH target is invalid.",
102 }),
103 });
104
105export function baseSshArgs(
106 target: DesktopSshEnvironmentTarget,
107 input?: { readonly batchMode?: "yes" | "no" },
108): string[] {
109 return [
110 "-o",
111 `BatchMode=${input?.batchMode ?? "no"}`,
112 "-o",
113 "ConnectTimeout=10",
114 ...(target.port !== null ? ["-p", String(target.port)] : []),
115 ];
116}
117
118export function getLastNonEmptyOutputLine(stdout: string): string | null {
119 return (
120 stdout
121 .trim()
122 .split(/\r?\n/u)
123 .map((entry) => entry.trim())
124 .findLast((entry) => entry.length > 0) ?? null
125 );
126}
127
128export const collectProcessOutput = <E>(
129 stream: Stream.Stream<Uint8Array, E>,
130): Effect.Effect<string, E> =>
131 stream.pipe(
132 Stream.decodeText(),
133 Stream.runFold(
134 () => "",
135 (acc, chunk) => acc + chunk,
136 ),
137 );
138
139function redactSshErrorOutput(output: string): string {
140 const redacted = output.replace(
141 /("(?:access_token|bearerToken|credential|pairingToken|token)"\s*:\s*")[^"]+(")/giu,
142 "$1[redacted]$2",
143 );
144 return redacted.length > MAX_SSH_ERROR_OUTPUT_LENGTH
145 ? `${redacted.slice(0, MAX_SSH_ERROR_OUTPUT_LENGTH)}\n[truncated]`
146 : redacted;
147}
148
149function normalizeSshErrorMessage(input: {
150 readonly stdout?: string;
151 readonly stderr: string;
152 readonly fallbackMessage: string;
153}): string {
154 const cleanedStderr = input.stderr.trim();
155 if (cleanedStderr.length > 0) {
156 return cleanedStderr;
157 }
158
159 const cleanedStdout = input.stdout?.trim() ?? "";
160 return cleanedStdout.length > 0 ? cleanedStdout : input.fallbackMessage;
161}
162
163function sshTargetLogFields(target: DesktopSshEnvironmentTarget) {
164 return {
165 alias: target.alias,
166 hostname: target.hostname,
167 username: target.username,
168 port: target.port,
169 };
170}
171
172function stdinStream(input: string | undefined) {
173 return input === undefined ? Stream.empty : Stream.make(encoder.encode(input));
174}
175
176const runSshCommandInScope = Effect.fn("ssh/command.runSshCommand.inScope")(function* (
177 target: DesktopSshEnvironmentTarget,
178 input: RunSshCommandOptions,
179 commandScope: Scope.Scope,
180): Effect.fn.Return<
181 SshCommandResult,
182 SshCommandError | SshInvalidTargetError,
183 ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path
184> {
185 const hostSpec = yield* buildSshHostSpecEffect(target);
186 const environment = yield* buildSshChildEnvironment({
187 ...(input.interactiveAuth === undefined ? {} : { interactiveAuth: input.interactiveAuth }),
188 ...(input.authSecret === undefined ? {} : { authSecret: input.authSecret }),
189 }).pipe(
190 Effect.mapError(
191 (cause) =>
192 new SshCommandError({
193 command: ["ssh"],
194 exitCode: null,
195 stderr: "",
196 message: "Failed to prepare SSH authentication helpers.",
197 cause,
198 }),
199 ),
200 );
201 const args = [
202 ...baseSshArgs(target, {
203 batchMode: input.batchMode ?? (input.interactiveAuth ? "no" : "yes"),
204 }),
205 ...(input.preHostArgs ?? []),
206 hostSpec,
207 ...(input.remoteCommandArgs ?? []),
208 ];
209 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
210 const sshCommand = yield* resolveSshCommand;
211 yield* Effect.logDebug("ssh.command.start", {
212 ...sshTargetLogFields(target),
213 command: [sshCommand, ...args],
214 hasStdin: input.stdin !== undefined,
215 timeoutMs: input.timeoutMs ?? DEFAULT_SSH_COMMAND_TIMEOUT_MS,
216 });
217 const child = yield* spawner
218 .spawn(
219 ChildProcess.make(sshCommand, args, {
220 env: environment,
221 extendEnv: true,
222 stdin: {
223 stream: stdinStream(input.stdin),
224 endOnDone: true,
225 },
226 }),
227 )
228 .pipe(
229 Effect.provideService(Scope.Scope, commandScope),
230 Effect.mapError(
231 (cause) =>
232 new SshCommandError({
233 command: [sshCommand, ...args],
234 exitCode: null,
235 stderr: "",
236 message:
237 cause instanceof Error
238 ? cause.message
239 : `Failed to spawn SSH command for ${hostSpec}.`,
240 cause,
241 }),
242 ),
243 );
244
245 const [stdout, stderr, exitCode] = yield* Effect.all(
246 [
247 collectProcessOutput(child.stdout),
248 collectProcessOutput(child.stderr),
249 child.exitCode.pipe(Effect.map(Number)),
250 ],
251 { concurrency: "unbounded" },
252 ).pipe(
253 Effect.mapError(
254 (cause) =>
255 new SshCommandError({
256 command: ["ssh", ...args],
257 exitCode: null,
258 stderr: "",
259 message:
260 cause instanceof Error ? cause.message : `Failed to run SSH command for ${hostSpec}.`,
261 cause,
262 }),
263 ),
264 );
265
266 if (exitCode !== 0) {
267 const diagnosticStdout = redactSshErrorOutput(stdout);
268 yield* Effect.logWarning("ssh.command.failed", {
269 ...sshTargetLogFields(target),
270 command: ["ssh", ...args],
271 exitCode,
272 stdout: diagnosticStdout,
273 stderr,
274 });
275 return yield* new SshCommandError({
276 command: ["ssh", ...args],
277 exitCode,
278 stdout: diagnosticStdout,
279 stderr,
280 message: normalizeSshErrorMessage({
281 stdout: diagnosticStdout,
282 stderr,
283 fallbackMessage: `SSH command failed for ${hostSpec} (exit ${exitCode}).`,
284 }),
285 });
286 }
287
288 yield* Effect.logDebug("ssh.command.succeeded", {
289 ...sshTargetLogFields(target),
290 command: ["ssh", ...args],
291 });
292 return { stdout, stderr };
293});
294
295export const runSshCommand = Effect.fn("ssh/command.runSshCommand")(function* (
296 target: DesktopSshEnvironmentTarget,
297 input: RunSshCommandOptions = {},
298): Effect.fn.Return<
299 SshCommandResult,
300 SshCommandError | SshInvalidTargetError,
301 ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path
302> {
303 return yield* Effect.scopedWith((commandScope) =>
304 runSshCommandInScope(target, input, commandScope),
305 ).pipe(
306 Effect.timeoutOption(Duration.millis(input.timeoutMs ?? DEFAULT_SSH_COMMAND_TIMEOUT_MS)),
307 Effect.flatMap((result) =>
308 Option.match(result, {
309 onSome: Effect.succeed,
310 onNone: () =>
311 Effect.gen(function* () {
312 yield* Effect.logWarning("ssh.command.timedOut", {
313 ...sshTargetLogFields(target),
314 timeoutMs: input.timeoutMs ?? DEFAULT_SSH_COMMAND_TIMEOUT_MS,
315 remoteCommandArgs: input.remoteCommandArgs ?? [],
316 preHostArgs: input.preHostArgs ?? [],
317 hasStdin: input.stdin !== undefined,
318 });
319 return yield* new SshCommandError({
320 command: ["ssh"],
321 exitCode: null,
322 stderr: "",
323 message: `SSH command timed out after ${input.timeoutMs ?? DEFAULT_SSH_COMMAND_TIMEOUT_MS}ms.`,
324 });
325 }),
326 }),
327 ),
328 );
329});
330
331export const resolveSshTarget = Effect.fn("ssh/command.resolveSshTarget")(function* (
332 alias: string,
333): Effect.fn.Return<
334 DesktopSshEnvironmentTarget,
335 SshCommandError | SshInvalidTargetError,
336 ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path
337> {
338 const trimmedAlias = alias.trim();
339 if (trimmedAlias.length === 0) {
340 return yield* new SshInvalidTargetError({ message: "SSH host alias is required." });
341 }
342
343 yield* Effect.logDebug("ssh.target.resolve.start", { alias: trimmedAlias });
344 return yield* runSshCommand(
345 {
346 alias: trimmedAlias,
347 hostname: trimmedAlias,
348 username: null,
349 port: null,
350 },
351 { preHostArgs: ["-G"] },
352 ).pipe(
353 Effect.map((result) => parseSshResolveOutput(trimmedAlias, result.stdout)),
354 Effect.tap((target) =>
355 Effect.logDebug("ssh.target.resolve.succeeded", sshTargetLogFields(target)),
356 ),
357 Effect.catch((cause) =>
358 Effect.logDebug("ssh.target.resolve.fallback", { alias: trimmedAlias, cause }).pipe(
359 Effect.as({
360 alias: trimmedAlias,
361 hostname: trimmedAlias,
362 username: null,
363 port: null,
364 }),
365 ),
366 ),
367 );
368});