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 {...@@ -17,6 +17,27 @@ import {
17 resolveRemoteT3CliPackageSpec,17 resolveRemoteT3CliPackageSpec,
18 runSshCommand,18 runSshCommand,
19} from "./command.ts";19} 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
21const makeNeverFinishingProcess = () => {42const makeNeverFinishingProcess = () => {
22 let finish: ((exitCode: ChildProcessSpawner.ExitCode) => void) | null = null;43 let finish: ((exitCode: ChildProcessSpawner.ExitCode) => void) | null = null;
...@@ -124,6 +145,65 @@ describe("ssh command", () => {...@@ -124,6 +145,65 @@ describe("ssh command", () => {
124 }),145 }),
125 );146 );
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
127 it.effect("fails commands that never finish", () => {207 it.effect("fails commands that never finish", () => {
128 const spawner = ChildProcessSpawner.make(() => Effect.succeed(makeNeverFinishingProcess()));208 const spawner = ChildProcessSpawner.make(() => Effect.succeed(makeNeverFinishingProcess()));
129 const spawnerLayer = Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner);209 const spawnerLayer = Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner);
packages/ssh/src/command.ts+30-6
...@@ -15,6 +15,7 @@ import { SshCommandError, SshInvalidTargetError } from "./errors.ts";...@@ -15,6 +15,7 @@ import { SshCommandError, SshInvalidTargetError } from "./errors.ts";
1515
16const PUBLISHABLE_T3_VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u;16const PUBLISHABLE_T3_VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u;
17const DEFAULT_SSH_COMMAND_TIMEOUT_MS = 60_000;17const DEFAULT_SSH_COMMAND_TIMEOUT_MS = 60_000;
18const MAX_SSH_ERROR_OUTPUT_LENGTH = 4_000;
1819
19const encoder = new TextEncoder();20const encoder = new TextEncoder();
2021
...@@ -118,9 +119,28 @@ export const collectProcessOutput = <E>(...@@ -118,9 +119,28 @@ export const collectProcessOutput = <E>(
118 ),119 ),
119 );120 );
120121
121function normalizeSshErrorMessage(stderr: string, fallbackMessage: string): string {122function redactSshErrorOutput(output: string): string {
122 const cleaned = stderr.trim();123 const redacted = output.replace(
123 return cleaned.length > 0 ? cleaned : fallbackMessage;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;
124}144}
125145
126function sshTargetLogFields(target: DesktopSshEnvironmentTarget) {146function sshTargetLogFields(target: DesktopSshEnvironmentTarget) {
...@@ -226,20 +246,24 @@ const runSshCommandInScope = Effect.fn("ssh/command.runSshCommand.inScope")(func...@@ -226,20 +246,24 @@ const runSshCommandInScope = Effect.fn("ssh/command.runSshCommand.inScope")(func
226 );246 );
227247
228 if (exitCode !== 0) {248 if (exitCode !== 0) {
249 const diagnosticStdout = redactSshErrorOutput(stdout);
229 yield* Effect.logWarning("ssh.command.failed", {250 yield* Effect.logWarning("ssh.command.failed", {
230 ...sshTargetLogFields(target),251 ...sshTargetLogFields(target),
231 command: ["ssh", ...args],252 command: ["ssh", ...args],
232 exitCode,253 exitCode,
254 stdout: diagnosticStdout,
233 stderr,255 stderr,
234 });256 });
235 return yield* new SshCommandError({257 return yield* new SshCommandError({
236 command: ["ssh", ...args],258 command: ["ssh", ...args],
237 exitCode,259 exitCode,
260 stdout: diagnosticStdout,
238 stderr,261 stderr,
239 message: normalizeSshErrorMessage(262 message: normalizeSshErrorMessage({
263 stdout: diagnosticStdout,
240 stderr,264 stderr,
241 `SSH command failed for ${hostSpec} (exit ${exitCode}).`,265 fallbackMessage: `SSH command failed for ${hostSpec} (exit ${exitCode}).`,
242 ),266 }),
243 });267 });
244 }268 }
245269
packages/ssh/src/errors.ts+1
...@@ -14,6 +14,7 @@ export class SshCommandError extends Data.TaggedError("SshCommandError")<{...@@ -14,6 +14,7 @@ export class SshCommandError extends Data.TaggedError("SshCommandError")<{
14 readonly command: readonly string[];14 readonly command: readonly string[];
15 readonly exitCode: number | null;15 readonly exitCode: number | null;
16 readonly stderr: string;16 readonly stderr: string;
17 readonly stdout?: string;
17 readonly cause?: unknown;18 readonly cause?: unknown;
18}> {}19}> {}
1920