Open History to see the last commit
1import { assert, describe, it } from "@effect/vitest";
2import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
3import * as NodeServices from "@effect/platform-node/NodeServices";
4import * as HostProcess from "@t3tools/shared/HostProcess";
5import * as Effect from "effect/Effect";
6import * as Fiber from "effect/Fiber";
7import * as Layer from "effect/Layer";
8import * as Result from "effect/Result";
9import * as Schema from "effect/Schema";
10import * as Sink from "effect/Sink";
11import * as Stream from "effect/Stream";
12import * as TestClock from "effect/testing/TestClock";
13import { ChildProcess, ChildProcessSpawner } from "effect/process";
14
15import { parseSshResolveOutput, remoteStateKey, runSshCommand } from "./command.ts";
16import { SshCommandError } from "./errors.ts";
17
18const encoder = new TextEncoder();
19const isSshCommandError = Schema.is(SshCommandError);
20
21const makeFailedProcess = (input: { readonly stdout: string; readonly stderr?: string }) => {
22 const stdoutStream = Stream.make(encoder.encode(input.stdout));
23 const stderrStream = input.stderr ? Stream.make(encoder.encode(input.stderr)) : Stream.empty;
24 return ChildProcessSpawner.makeHandle({
25 pid: ChildProcessSpawner.ProcessId(123),
26 stdout: stdoutStream,
27 stderr: stderrStream,
28 all: Stream.empty,
29 exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(1)),
30 isRunning: Effect.succeed(false),
31 kill: () => Effect.void,
32 stdin: Sink.drain,
33 getInputFd: () => Sink.drain,
34 getOutputFd: () => Stream.empty,
35 unref: Effect.succeed(Effect.void),
36 });
37};
38
39describe("ssh command", () => {
40 it.effect("parses resolved ssh config output into a target", () =>
41 Effect.sync(() => {
42 assert.deepEqual(
43 parseSshResolveOutput(
44 "devbox",
45 ["hostname devbox.example.com", "user julius", "port 2222", ""].join("\n"),
46 ),
47 {
48 alias: "devbox",
49 hostname: "devbox.example.com",
50 username: "julius",
51 port: 2222,
52 },
53 );
54 }),
55 );
56
57 it.effect("passes an option-shaped alias to ssh as the destination", () => {
58 const spawned: Array<ReadonlyArray<string>> = [];
59 const spawner = ChildProcessSpawner.make((command) => {
60 if (ChildProcess.isStandardCommand(command)) spawned.push(command.args.map(String));
61 return Effect.succeed(makeFailedProcess({ stdout: "", stderr: "bad hostname\n" }));
62 });
63 return runSshCommand(
64 { alias: "-oProxyCommand=false", hostname: "devbox", username: null, port: null },
65 { remoteCommandArgs: ["sh", "-s"] },
66 ).pipe(
67 Effect.flip,
68 Effect.map(() => {
69 assert.deepEqual(spawned[0]?.slice(-4), ["--", "-oProxyCommand=false", "sh", "-s"]);
70 }),
71 Effect.provide(
72 Layer.merge(
73 Layer.mergeAll(NodeServices.layer, NodeCrypto.layer),
74 Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner),
75 ),
76 ),
77 );
78 });
79
80 // Remote servers store state under this key, so it must not change across releases.
81 it.effect("derives a stable remote state key", () =>
82 Effect.gen(function* () {
83 assert.equal(
84 yield* remoteStateKey({
85 alias: "devbox",
86 hostname: "devbox.example.com",
87 username: "julius",
88 port: 2222,
89 }),
90 "711bc738002d72fd",
91 );
92 assert.equal(
93 yield* remoteStateKey({
94 alias: "fixture",
95 hostname: "fixture",
96 username: null,
97 port: null,
98 }),
99 "326264c4f08c8a0c",
100 );
101 }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))),
102 );
103
104 it.live.each([
105 { name: "default head", keepErrorOutputTail: undefined, exitCode: 1, stderr: "" },
106 {
107 name: "opt-in tail",
108 keepErrorOutputTail: true,
109 exitCode: 1,
110 stderr: "stderr failure ".repeat(400),
111 },
112 { name: "successful output", keepErrorOutputTail: true, exitCode: 0, stderr: "" },
113 ])("preserves process output: $name", ({ keepErrorOutputTail, exitCode, stderr }) =>
114 Effect.gen(function* () {
115 const prefix = `HEAD\n${"verbose-prefix ".repeat(400)}`;
116 const secret = "synthetic-secret".repeat(400);
117 const diagnostic = "\nPANIC: emulator SDK is broken\n";
118 const stdout = `${prefix}{"credential":"${secret}"}${diagnostic}`;
119 const nativeSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
120 // Replace only the SSH endpoint with a real, deterministic process.
121 const spawner = ChildProcessSpawner.make(() =>
122 nativeSpawner.spawn(
123 ChildProcess.make(process.execPath, [
124 "-e",
125 `process.stdout.write(${JSON.stringify(stdout)}); process.stderr.write(${JSON.stringify(stderr)}); process.exitCode = ${exitCode};`,
126 ]),
127 ),
128 );
129 const command = runSshCommand(
130 { alias: "synthetic-fixture", hostname: "synthetic-fixture", username: null, port: null },
131 keepErrorOutputTail === undefined ? {} : { keepErrorOutputTail },
132 ).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner));
133 if (exitCode === 0) {
134 assert.deepEqual(yield* command, { stdout, stderr });
135 return;
136 }
137 const failure = yield* Effect.flip(command);
138 assert.instanceOf(failure, SshCommandError);
139 assert.equal(failure.exitCode, 1);
140 assert.equal(failure.stderr, stderr);
141 assert.equal(failure.message, stderr.trim() || failure.stdout?.trim());
142 const redacted = `${prefix}{"credential":"[redacted]"}${diagnostic}`;
143 assert.equal(
144 failure.stdout,
145 keepErrorOutputTail
146 ? `[truncated]\n${redacted.slice(-4_000)}`
147 : `${redacted.slice(0, 4_000)}\n[truncated]`,
148 );
149 }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))),
150 );
151
152 it.live.skipIf(HostProcess.Platform.defaultValue() === "win32")(
153 "a timed-out command leaves no helper running that ignored SIGTERM",
154 () =>
155 Effect.gen(function* () {
156 const nativeSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
157 // Stands in for ssh and a ProxyCommand: ssh exits on SIGTERM, the proxy ignores it.
158 const spawner = ChildProcessSpawner.make((command) =>
159 nativeSpawner.spawn(
160 ChildProcess.make(
161 process.execPath,
162 [
163 "-e",
164 `const proxy = require("node:child_process").spawn("sh", ["-c", "trap '' TERM; exec sleep 60"], { stdio: "ignore" });
165 process.stdout.write(proxy.pid + "\\n");
166 setInterval(() => {}, 1000);`,
167 ],
168 command._tag === "StandardCommand" ? command.options : {},
169 ),
170 ),
171 );
172 const failure = yield* runSshCommand(
173 { alias: "synthetic-fixture", hostname: "synthetic-fixture", username: null, port: null },
174 { timeoutMs: 1_000, keepErrorOutputTail: true },
175 ).pipe(
176 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
177 Effect.flip,
178 );
179 assert.instanceOf(failure, SshCommandError);
180 assert.equal(failure.message, "SSH command timed out after 1000ms.");
181 const proxyPid = Number(failure.stdout?.trim());
182 assert.isTrue(Number.isSafeInteger(proxyPid) && proxyPid > 1);
183 const state = (yield* nativeSpawner.string(
184 ChildProcess.make("ps", ["-p", String(proxyPid), "-o", "stat="]),
185 )).trim();
186 if (state !== "" && !state.startsWith("Z")) process.kill(proxyPid, "SIGKILL");
187 assert.isTrue(state === "" || state.startsWith("Z"), `proxy still running (${state})`);
188 }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))),
189 );
190
191 it.live("enables server-alive checks for long remote commands", () =>
192 Effect.gen(function* () {
193 // Ask OpenSSH for its effective configuration without opening a connection.
194 const { stdout } = yield* runSshCommand(
195 { alias: "synthetic-fixture", hostname: "synthetic-fixture", username: null, port: null },
196 { preHostArgs: ["-F", "none", "-G"], timeoutMs: 900_000 },
197 );
198 assert.match(stdout, /^serveraliveinterval 15\r?$/mu);
199 assert.match(stdout, /^serveralivecountmax 3\r?$/mu);
200 }).pipe(Effect.provide(NodeServices.layer)),
201 );
202
203 it.effect("redacts credentials from stdout in non-zero command failures", () => {
204 const spawner = ChildProcessSpawner.make(() =>
205 Effect.succeed(makeFailedProcess({ stdout: '{"credential":"pairing-secret"}\n' })),
206 );
207 const layerSpawner = Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner);
208 const layerProcess = Layer.mergeAll(NodeServices.layer, NodeCrypto.layer, layerSpawner);
209
210 return Effect.gen(function* () {
211 const result = yield* Effect.result(
212 runSshCommand(
213 {
214 alias: "devbox",
215 hostname: "devbox.example.com",
216 username: "julius",
217 port: 2222,
218 },
219 { remoteCommandArgs: ["sh", "-s"] },
220 ),
221 );
222
223 assert.isTrue(Result.isFailure(result));
224 if (Result.isFailure(result)) {
225 assert.instanceOf(result.failure, SshCommandError);
226 assert.equal(result.failure.message, '{"credential":"[redacted]"}');
227 assert.equal(result.failure.stdout, '{"credential":"[redacted]"}\n');
228 }
229 }).pipe(Effect.provide(layerProcess));
230 });
231
232 it.effect("keeps redacted output the command emitted before timing out", () => {
233 const spawner = ChildProcessSpawner.make(() =>
234 Effect.succeed(
235 ChildProcessSpawner.makeHandle({
236 pid: ChildProcessSpawner.ProcessId(123),
237 stdout: Stream.concat(
238 Stream.make(encoder.encode('PROGRESS: extracting\n{"credential":"pairing-secret"}\n')),
239 Stream.never,
240 ),
241 stderr: Stream.concat(
242 Stream.make(encoder.encode("REMOTE: waiting for archive extraction\n")),
243 Stream.never,
244 ),
245 all: Stream.empty,
246 exitCode: Effect.never,
247 isRunning: Effect.succeed(true),
248 kill: () => Effect.void,
249 stdin: Sink.drain,
250 getInputFd: () => Sink.drain,
251 getOutputFd: () => Stream.empty,
252 unref: Effect.succeed(Effect.void),
253 }),
254 ),
255 );
256 const layerSpawner = Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner);
257
258 return Effect.gen(function* () {
259 const fiber = yield* runSshCommand(
260 { alias: "devbox", hostname: "devbox", username: null, port: null },
261 { remoteCommandArgs: ["sh", "-s"], timeoutMs: 1_000, keepErrorOutputTail: true },
262 ).pipe(Effect.flip, Effect.forkChild);
263 yield* TestClock.adjust("1 second");
264 const failure = yield* Fiber.join(fiber);
265
266 assert.instanceOf(failure, SshCommandError);
267 assert.equal(failure.exitCode, null);
268 assert.equal(failure.stdout, 'PROGRESS: extracting\n{"credential":"[redacted]"}\n');
269 assert.equal(failure.stderr, "REMOTE: waiting for archive extraction\n");
270 }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer, layerSpawner)));
271 });
272
273 const changedKeyStderr =
274 "@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@\r\n" +
275 "@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @\r\n" +
276 " ssh-keygen -f '/home/me/.ssh/known_hosts' -R 'devbox'\r\n" +
277 "Host key verification failed.\r\n";
278
279 it.effect.each([
280 {
281 name: "quotes the terminal command for a POSIX shell",
282 target: { alias: "my host", username: "o'brien" },
283 platform: "linux",
284 stderr: "Host key verification failed.\r\n",
285 expected: ["`ssh 'o'\\''brien@my host'`"],
286 },
287 {
288 name: "quotes the terminal command for a Windows shell",
289 target: { alias: "my host", username: "me" },
290 platform: "win32",
291 stderr: "Host key verification failed.\r\n",
292 expected: ['`ssh "me@my host"`'],
293 },
294 {
295 name: "keeps an option-shaped alias out of the option list",
296 target: { alias: "-oProxyCommand=false", username: null },
297 platform: "linux",
298 stderr: "Host key verification failed.\r\n",
299 expected: ["`ssh -- -oProxyCommand=false`"],
300 },
301 {
302 name: "tells the user to remove a changed host key first",
303 target: { alias: "devbox", username: null },
304 platform: "linux",
305 stderr: changedKeyStderr,
306 expected: ["has changed", "`ssh-keygen -R`", "`ssh devbox`", "-R 'devbox'"],
307 },
308 ] as const)("$name", ({ target, platform, stderr, expected }) => {
309 const spawner = ChildProcessSpawner.make(() =>
310 Effect.succeed(makeFailedProcess({ stdout: "", stderr })),
311 );
312 return runSshCommand({ ...target, hostname: "devbox", port: null }).pipe(
313 Effect.flip,
314 Effect.map((error) => {
315 assert.isTrue(isSshCommandError(error));
316 for (const text of expected) assert.include(error.message, text);
317 if (isSshCommandError(error)) assert.equal(error.stderr, stderr);
318 }),
319 Effect.provideService(HostProcess.Platform, platform),
320 Effect.provide(
321 Layer.mergeAll(
322 NodeServices.layer,
323 NodeCrypto.layer,
324 Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner),
325 ),
326 ),
327 );
328 });
329});