authorgravatar for mwolson@member.fsf.orgMike Olson <mwolson@member.fsf.org> 2026-06-03 21:50:13-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-03 18:50:13-07:00
logf5849f7d78e4ebea277c4a9b5086d592f2aae3a4
tree145e70f138d1c8fbfb3b6c769eb66b7d77443929
parent6e6163255d85607db0705cac8bfdf99e05119aca
signature Signed by PGP key B5690EEEBB952194

fix(ssh): Surface redacted stdout for failed commands (#2920)


3 files changed, 111 insertions(+), 6 deletions(-)

packages/ssh/src/command.test.ts+80
......@@ -17,6 +17,27 @@ import {
1717 resolveRemoteT3CliPackageSpec,
1818 runSshCommand,
1919} from "./command.ts";
20import { SshCommandError } from "./errors.ts";
21
22const encoder = new TextEncoder();
23
24const makeFailedProcess = (input: { readonly stdout: string; readonly stderr?: string }) => {
25 const stdoutStream = Stream.make(encoder.encode(input.stdout));
26 const stderrStream = input.stderr ? Stream.make(encoder.encode(input.stderr)) : Stream.empty;
27 return ChildProcessSpawner.makeHandle({
28 pid: ChildProcessSpawner.ProcessId(123),
29 stdout: stdoutStream,
30 stderr: stderrStream,
31 all: Stream.empty,
32 exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(1)),
33 isRunning: Effect.succeed(false),
34 kill: () => Effect.void,
35 stdin: Sink.drain,
36 getInputFd: () => Sink.drain,
37 getOutputFd: () => Stream.empty,
38 unref: Effect.succeed(Effect.void),
39 });
40};
2041
2142const makeNeverFinishingProcess = () => {
2243 let finish: ((exitCode: ChildProcessSpawner.ExitCode) => void) | null = null;
......@@ -124,6 +145,65 @@ describe("ssh command", () => {
124145 }),
125146 );
126147
148 it.effect("includes stdout in non-zero command failures when stderr is empty", () => {
149 const spawner = ChildProcessSpawner.make(() =>
150 Effect.succeed(makeFailedProcess({ stdout: "Pairing token creation failed\n" })),
151 );
152 const spawnerLayer = Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner);
153 const processLayer = Layer.mergeAll(NodeServices.layer, spawnerLayer);
154
155 return Effect.gen(function* () {
156 const result = yield* Effect.result(
157 runSshCommand(
158 {
159 alias: "devbox",
160 hostname: "devbox.example.com",
161 username: "julius",
162 port: 2222,
163 },
164 { remoteCommandArgs: ["sh", "-s"] },
165 ),
166 );
167
168 assert.isTrue(Result.isFailure(result));
169 if (Result.isFailure(result)) {
170 assert.instanceOf(result.failure, SshCommandError);
171 assert.equal(result.failure.message, "Pairing token creation failed");
172 assert.equal(result.failure.stdout, "Pairing token creation failed\n");
173 assert.equal(result.failure.stderr, "");
174 }
175 }).pipe(Effect.provide(processLayer));
176 });
177
178 it.effect("redacts credentials from stdout in non-zero command failures", () => {
179 const spawner = ChildProcessSpawner.make(() =>
180 Effect.succeed(makeFailedProcess({ stdout: '{"credential":"pairing-secret"}\n' })),
181 );
182 const spawnerLayer = Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner);
183 const processLayer = Layer.mergeAll(NodeServices.layer, spawnerLayer);
184
185 return Effect.gen(function* () {
186 const result = yield* Effect.result(
187 runSshCommand(
188 {
189 alias: "devbox",
190 hostname: "devbox.example.com",
191 username: "julius",
192 port: 2222,
193 },
194 { remoteCommandArgs: ["sh", "-s"] },
195 ),
196 );
197
198 assert.isTrue(Result.isFailure(result));
199 if (Result.isFailure(result)) {
200 assert.instanceOf(result.failure, SshCommandError);
201 assert.equal(result.failure.message, '{"credential":"[redacted]"}');
202 assert.equal(result.failure.stdout, '{"credential":"[redacted]"}\n');
203 }
204 }).pipe(Effect.provide(processLayer));
205 });
206
127207 it.effect("fails commands that never finish", () => {
128208 const spawner = ChildProcessSpawner.make(() => Effect.succeed(makeNeverFinishingProcess()));
129209 const spawnerLayer = Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner);
packages/ssh/src/command.ts+30-6
......@@ -15,6 +15,7 @@ import { SshCommandError, SshInvalidTargetError } from "./errors.ts";
1515
1616const PUBLISHABLE_T3_VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u;
1717const DEFAULT_SSH_COMMAND_TIMEOUT_MS = 60_000;
18const MAX_SSH_ERROR_OUTPUT_LENGTH = 4_000;
1819
1920const encoder = new TextEncoder();
2021
......@@ -118,9 +119,28 @@ export const collectProcessOutput = <E>(
118119 ),
119120 );
120121
121function normalizeSshErrorMessage(stderr: string, fallbackMessage: string): string {
122 const cleaned = stderr.trim();
123 return cleaned.length > 0 ? cleaned : fallbackMessage;
122function redactSshErrorOutput(output: string): string {
123 const redacted = output.replace(
124 /("(?:access_token|bearerToken|credential|pairingToken|token)"\s*:\s*")[^"]+(")/giu,
125 "$1[redacted]$2",
126 );
127 return redacted.length > MAX_SSH_ERROR_OUTPUT_LENGTH
128 ? `${redacted.slice(0, MAX_SSH_ERROR_OUTPUT_LENGTH)}\n[truncated]`
129 : redacted;
130}
131
132function normalizeSshErrorMessage(input: {
133 readonly stdout?: string;
134 readonly stderr: string;
135 readonly fallbackMessage: string;
136}): string {
137 const cleanedStderr = input.stderr.trim();
138 if (cleanedStderr.length > 0) {
139 return cleanedStderr;
140 }
141
142 const cleanedStdout = input.stdout?.trim() ?? "";
143 return cleanedStdout.length > 0 ? cleanedStdout : input.fallbackMessage;
124144}
125145
126146function sshTargetLogFields(target: DesktopSshEnvironmentTarget) {
......@@ -226,20 +246,24 @@ const runSshCommandInScope = Effect.fn("ssh/command.runSshCommand.inScope")(func
226246 );
227247
228248 if (exitCode !== 0) {
249 const diagnosticStdout = redactSshErrorOutput(stdout);
229250 yield* Effect.logWarning("ssh.command.failed", {
230251 ...sshTargetLogFields(target),
231252 command: ["ssh", ...args],
232253 exitCode,
254 stdout: diagnosticStdout,
233255 stderr,
234256 });
235257 return yield* new SshCommandError({
236258 command: ["ssh", ...args],
237259 exitCode,
260 stdout: diagnosticStdout,
238261 stderr,
239 message: normalizeSshErrorMessage(
262 message: normalizeSshErrorMessage({
263 stdout: diagnosticStdout,
240264 stderr,
241 `SSH command failed for ${hostSpec} (exit ${exitCode}).`,
242 ),
265 fallbackMessage: `SSH command failed for ${hostSpec} (exit ${exitCode}).`,
266 }),
243267 });
244268 }
245269
packages/ssh/src/errors.ts+1
......@@ -14,6 +14,7 @@ export class SshCommandError extends Data.TaggedError("SshCommandError")<{
1414 readonly command: readonly string[];
1515 readonly exitCode: number | null;
1616 readonly stderr: string;
17 readonly stdout?: string;
1718 readonly cause?: unknown;
1819}> {}
1920