Skip to content
Source
Sign inSign up
Open History to see the last commit

command.ts

438 lines14.2 KB

1import type { DesktopSshEnvironmentTarget } from "@t3tools/contracts";
2import * as HostProcess from "@t3tools/shared/HostProcess";
3import * as Digest from "effect/crypto/Digest";
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;
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 /** Keep the end of long failed-command stdout, where the final cause usually is. */
43 readonly keepErrorOutputTail?: boolean;
45​
46export function parseSshResolveOutput(alias: string, stdout: string): DesktopSshEnvironmentTarget {
47 const values = new Map<string, string>();
48 for (const line of stdout.split(/\r?\n/u)) {
49 const trimmed = line.trim();
50 if (trimmed.length === 0) {
51 continue;
52 }
53 const [key, ...rest] = trimmed.split(/\s+/u);
54 if (!key || rest.length === 0 || values.has(key)) {
55 continue;
56 }
57 values.set(key, rest.join(" ").trim());
58 }
59​
60 const hostname = values.get("hostname")?.trim() || alias;
61 const username = values.get("user")?.trim() || null;
62 const rawPort = values.get("port")?.trim() ?? "";
63 const parsedPort = Number.parseInt(rawPort, 10);
64​
65 return {
66 alias,
67 hostname,
68 username,
69 port: Number.isInteger(parsedPort) ? parsedPort : null,
70 };
72​
73export function targetConnectionKey(target: DesktopSshEnvironmentTarget): string {
74 return `${target.alias}\u0000${target.hostname}\u0000${target.username ?? ""}\u0000${target.port ?? ""}`;
76​
77export const remoteStateKey = Effect.fn("ssh/command.remoteStateKey")(function* (
78 target: DesktopSshEnvironmentTarget,
79): Effect.fn.Return<string, never, Digest.Digest> {
80 const digestService = yield* Digest.Digest;
81 const digest = yield* digestService.digest(
82 Digest.sha256,
83 encoder.encode(targetConnectionKey(target)),
84 );
85 return Hex.encode(digest).slice(0, 16);
86});
87​
88function buildSshHostSpec(target: DesktopSshEnvironmentTarget): string {
89 const destination = target.alias.trim() || target.hostname.trim();
90 if (destination.length === 0) {
91 throw new Error("SSH target is missing its alias/hostname.");
92 }
93 return target.username ? `${target.username}@${destination}` : destination;
95​
96export const buildSshHostSpecEffect = (
97 target: DesktopSshEnvironmentTarget,
98): Effect.Effect<string, SshInvalidTargetError> =>
99 Effect.try({
100 try: () => buildSshHostSpec(target),
101 catch: (cause) =>
102 new SshInvalidTargetError({
103 message: cause instanceof Error ? cause.message : "SSH target is invalid.",
104 }),
105 });
106​
107export function baseSshArgs(
108 target: DesktopSshEnvironmentTarget,
109 input?: { readonly batchMode?: "yes" | "no" },
110): string[] {
111 return [
112 "-o",
113 `BatchMode=${input?.batchMode ?? "no"}`,
114 "-o",
115 "ConnectTimeout=10",
116 ...(target.port !== null ? ["-p", String(target.port)] : []),
117 ];
119​
120export function getLastNonEmptyOutputLine(stdout: string): string | null {
121 return (
122 stdout
123 .trim()
124 .split(/\r?\n/u)
125 .map((entry) => entry.trim())
126 .findLast((entry) => entry.length > 0) ?? null
127 );
129​
130interface SshCommandOutput {
131 stdout: string;
132 stderr: string;
134​
135// Appends as chunks arrive so a timeout can still report what was printed.
136const collectProcessOutput = <E>(
137 stream: Stream.Stream<Uint8Array, E>,
138 output: SshCommandOutput,
139 key: keyof SshCommandOutput,
140): Effect.Effect<void, E> =>
141 stream.pipe(
142 Stream.decodeText(),
143 Stream.runForEach((chunk) =>
144 Effect.sync(() => {
145 output[key] += chunk;
146 }),
147 ),
148 );
149​
150function redactSshErrorOutput(output: string, keepTail = false): string {
151 const redacted = output.replace(
152 /("(?:access_token|bearerToken|credential|pairingToken|token)"\s*:\s*")[^"]+(")/giu,
153 "$1[redacted]$2",
154 );
155 if (redacted.length <= MAX_SSH_ERROR_OUTPUT_LENGTH) return redacted;
156 return keepTail
157 ? `[truncated]\n${redacted.slice(-MAX_SSH_ERROR_OUTPUT_LENGTH)}`
158 : `${redacted.slice(0, MAX_SSH_ERROR_OUTPUT_LENGTH)}\n[truncated]`;
160​
161export function shellSingleQuote(value: string): string {
162 return `'${value.replaceAll("'", "'\\''")}'`;
164​
165const SAFE_SHELL_ARG = /^[\w@%+=:,./-]+$/u;
166​
167// cmd.exe and PowerShell both read double quotes, but not POSIX single quotes.
168function terminalArg(value: string, platform: NodeJS.Platform): string {
169 if (SAFE_SHELL_ARG.test(value)) return value;
170 return platform === "win32" ? `"${value}"` : shellSingleQuote(value);
172​
173// ssh prints this for unknown and changed host keys when it cannot ask. The app
174// never answers host key prompts, so the user checks the fingerprint in a terminal.
175function hostKeyVerificationMessage(input: {
176 readonly target: DesktopSshEnvironmentTarget;
177 readonly hostSpec: string;
178 readonly stderr: string;
179 readonly platform: NodeJS.Platform;
180}): string | null {
181 const { target, hostSpec, stderr, platform } = input;
182 if (!stderr.includes("Host key verification failed")) return null;
183 const command = [
184 "ssh",
185 ...(target.port !== null ? ["-p", String(target.port)] : []),
186 ...(hostSpec.startsWith("-") ? ["--"] : []),
187 hostSpec,
188 ]
189 .map((arg) => terminalArg(arg, platform))
190 .join(" ");
191 const guidance = stderr.includes("REMOTE HOST IDENTIFICATION HAS CHANGED")
192 ? `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.`
193 : `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.`;
194 return `${guidance}\n\n${stderr.trim()}`;
196​
197function normalizeSshErrorMessage(input: {
198 readonly stdout?: string;
199 readonly stderr: string;
200 readonly fallbackMessage: string;
201}): string {
202 const cleanedStderr = input.stderr.trim();
203 if (cleanedStderr.length > 0) {
204 return cleanedStderr;
205 }
206​
207 const cleanedStdout = input.stdout?.trim() ?? "";
208 return cleanedStdout.length > 0 ? cleanedStdout : input.fallbackMessage;
210​
211export function sshTargetLogFields(target: DesktopSshEnvironmentTarget) {
212 return {
213 alias: target.alias,
214 hostname: target.hostname,
215 username: target.username,
216 port: target.port,
217 };
219​
220function stdinStream(input: string | undefined) {
221 return input === undefined ? Stream.empty : Stream.make(encoder.encode(input));
223​
224const runSshCommandInScope = Effect.fn("ssh/command.runSshCommand.inScope")(function* (
225 target: DesktopSshEnvironmentTarget,
226 input: RunSshCommandOptions,
227 commandScope: Scope.Scope,
228 output: SshCommandOutput,
229): Effect.fn.Return<
230 SshCommandResult,
231 SshCommandError | SshInvalidTargetError,
232 ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path
233> {
234 const hostSpec = yield* buildSshHostSpecEffect(target);
235 const environment = yield* buildSshChildEnvironment({
236 ...(input.interactiveAuth === undefined ? {} : { interactiveAuth: input.interactiveAuth }),
237 ...(input.authSecret === undefined ? {} : { authSecret: input.authSecret }),
238 }).pipe(
239 Effect.mapError(
240 (cause) =>
241 new SshCommandError({
242 command: ["ssh"],
243 exitCode: null,
244 stderr: "",
245 message: "Failed to prepare SSH authentication helpers.",
246 cause,
247 }),
248 ),
249 );
250 const args = [
251 ...baseSshArgs(target, {
252 batchMode: input.batchMode ?? (input.interactiveAuth ? "no" : "yes"),
253 }),
254 // Cold installs need a long command budget; a lost connection must fail sooner.
255 "-o",
256 "ServerAliveInterval=15",
257 "-o",
258 "ServerAliveCountMax=3",
259 ...(input.preHostArgs ?? []),
260 "--",
261 hostSpec,
262 ...(input.remoteCommandArgs ?? []),
263 ];
264 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
265 const sshCommand = yield* resolveSshCommand;
266 yield* Effect.logDebug("ssh.command.start", {
267 ...sshTargetLogFields(target),
268 command: [sshCommand, ...args],
269 hasStdin: input.stdin !== undefined,
270 timeoutMs: input.timeoutMs ?? DEFAULT_SSH_COMMAND_TIMEOUT_MS,
271 });
272 const child = yield* spawner
273 .spawn(
274 ChildProcess.make(sshCommand, args, {
275 env: environment,
276 extendEnv: true,
277 forceKillAfter: "1 second",
278 stdin: {
279 stream: stdinStream(input.stdin),
280 endOnDone: true,
281 },
282 }),
283 )
284 .pipe(
285 Effect.provideService(Scope.Scope, commandScope),
286 Effect.mapError(
287 (cause) =>
288 new SshCommandError({
289 command: [sshCommand, ...args],
290 exitCode: null,
291 stderr: "",
292 message:
293 cause instanceof Error
294 ? cause.message
295 : `Failed to spawn SSH command for ${hostSpec}.`,
296 cause,
297 }),
298 ),
299 );
300​
301 const [, , exitCode] = yield* Effect.all(
302 [
303 collectProcessOutput(child.stdout, output, "stdout"),
304 collectProcessOutput(child.stderr, output, "stderr"),
305 child.exitCode.pipe(Effect.map(Number)),
306 ],
307 { concurrency: "unbounded" },
308 ).pipe(
309 Effect.mapError(
310 (cause) =>
311 new SshCommandError({
312 command: ["ssh", ...args],
313 exitCode: null,
314 stderr: "",
315 message:
316 cause instanceof Error ? cause.message : `Failed to run SSH command for ${hostSpec}.`,
317 cause,
318 }),
319 ),
320 );
321​
322 const { stdout, stderr } = output;
323 if (exitCode !== 0) {
324 const diagnosticStdout = redactSshErrorOutput(stdout, input.keepErrorOutputTail);
325 yield* Effect.logWarning("ssh.command.failed", {
326 ...sshTargetLogFields(target),
327 command: ["ssh", ...args],
328 exitCode,
329 stdout: diagnosticStdout,
330 stderr,
331 });
332 return yield* new SshCommandError({
333 command: ["ssh", ...args],
334 exitCode,
335 stdout: diagnosticStdout,
336 stderr,
337 message:
338 hostKeyVerificationMessage({
339 target,
340 hostSpec,
341 stderr,
342 platform: yield* HostProcess.Platform,
343 }) ??
344 normalizeSshErrorMessage({
345 stdout: diagnosticStdout,
346 stderr,
347 fallbackMessage: `SSH command failed for ${hostSpec} (exit ${exitCode}).`,
348 }),
349 });
350 }
351​
352 yield* Effect.logDebug("ssh.command.succeeded", {
353 ...sshTargetLogFields(target),
354 command: ["ssh", ...args],
355 });
356 return { stdout, stderr };
357});
358​
359export const runSshCommand = Effect.fn("ssh/command.runSshCommand")(function* (
360 target: DesktopSshEnvironmentTarget,
361 input: RunSshCommandOptions = {},
362): Effect.fn.Return<
363 SshCommandResult,
364 SshCommandError | SshInvalidTargetError,
365 ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path
366> {
367 const output: SshCommandOutput = { stdout: "", stderr: "" };
368 return yield* Effect.scopedWith((commandScope) =>
369 runSshCommandInScope(target, input, commandScope, output),
370 ).pipe(
371 Effect.timeoutOption(Duration.millis(input.timeoutMs ?? DEFAULT_SSH_COMMAND_TIMEOUT_MS)),
372 Effect.flatMap((result) =>
373 Option.match(result, {
374 onSome: Effect.succeed,
375 // The enclosing operation owns the span for this inline handler.
376 onNone: Effect.fnUntraced(function* () {
377 const diagnosticStdout = redactSshErrorOutput(output.stdout, input.keepErrorOutputTail);
378 const { stderr } = output;
379 yield* Effect.logWarning("ssh.command.timedOut", {
380 ...sshTargetLogFields(target),
381 timeoutMs: input.timeoutMs ?? DEFAULT_SSH_COMMAND_TIMEOUT_MS,
382 remoteCommandArgs: input.remoteCommandArgs ?? [],
383 preHostArgs: input.preHostArgs ?? [],
384 hasStdin: input.stdin !== undefined,
385 stdout: diagnosticStdout,
386 stderr,
387 });
388 return yield* new SshCommandError({
389 command: ["ssh"],
390 exitCode: null,
391 stdout: diagnosticStdout,
392 stderr,
393 message: `SSH command timed out after ${input.timeoutMs ?? DEFAULT_SSH_COMMAND_TIMEOUT_MS}ms.`,
394 });
395 }),
396 }),
397 ),
398 );
399});
400​
401export const resolveSshTarget = Effect.fn("ssh/command.resolveSshTarget")(function* (
402 alias: string,
403): Effect.fn.Return<
404 DesktopSshEnvironmentTarget,
405 SshCommandError | SshInvalidTargetError,
406 ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path
407> {
408 const trimmedAlias = alias.trim();
409 if (trimmedAlias.length === 0) {
410 return yield* new SshInvalidTargetError({ message: "SSH host alias is required." });
411 }
412​
413 yield* Effect.logDebug("ssh.target.resolve.start", { alias: trimmedAlias });
414 return yield* runSshCommand(
415 {
416 alias: trimmedAlias,
417 hostname: trimmedAlias,
418 username: null,
419 port: null,
420 },
421 { preHostArgs: ["-G"] },
422 ).pipe(
423 Effect.map((result) => parseSshResolveOutput(trimmedAlias, result.stdout)),
424 Effect.tap((target) =>
425 Effect.logDebug("ssh.target.resolve.succeeded", sshTargetLogFields(target)),
426 ),
427 Effect.catch((cause) =>
428 Effect.logDebug("ssh.target.resolve.fallback", { alias: trimmedAlias, cause }).pipe(
429 Effect.as({
430 alias: trimmedAlias,
431 hostname: trimmedAlias,
432 username: null,
433 port: null,
434 }),
435 ),
436 ),
437 );
438});