From f4865f4169bf7e7c91be5be2d70ac6f17b7c7e86 Mon Sep 17 00:00:00 2001 From: Mike Olson Date: Tue, 2 Jun 2026 21:28:21 -0400 Subject: [PATCH] fix(ssh): Surface redacted stdout for failed commands --- packages/ssh/src/command.test.ts | 80 ++++++++++++++++++++++++++++++++ packages/ssh/src/command.ts | 36 +++++++++++--- packages/ssh/src/errors.ts | 1 + 3 files changed, 111 insertions(+), 6 deletions(-) diff --git a/packages/ssh/src/command.test.ts b/packages/ssh/src/command.test.ts index d95ffed49dc..e5b621f87aa 100644 --- a/packages/ssh/src/command.test.ts +++ b/packages/ssh/src/command.test.ts @@ -17,6 +17,27 @@ import { resolveRemoteT3CliPackageSpec, runSshCommand, } from "./command.ts"; +import { SshCommandError } from "./errors.ts"; + +const encoder = new TextEncoder(); + +const makeFailedProcess = (input: { readonly stdout: string; readonly stderr?: string }) => { + const stdoutStream = Stream.make(encoder.encode(input.stdout)); + const stderrStream = input.stderr ? Stream.make(encoder.encode(input.stderr)) : Stream.empty; + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(123), + stdout: stdoutStream, + stderr: stderrStream, + all: Stream.empty, + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(1)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + stdin: Sink.drain, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + unref: Effect.succeed(Effect.void), + }); +}; const makeNeverFinishingProcess = () => { let finish: ((exitCode: ChildProcessSpawner.ExitCode) => void) | null = null; @@ -124,6 +145,65 @@ describe("ssh command", () => { }), ); + it.effect("includes stdout in non-zero command failures when stderr is empty", () => { + const spawner = ChildProcessSpawner.make(() => + Effect.succeed(makeFailedProcess({ stdout: "Pairing token creation failed\n" })), + ); + const spawnerLayer = Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner); + const processLayer = Layer.mergeAll(NodeServices.layer, spawnerLayer); + + return Effect.gen(function* () { + const result = yield* Effect.result( + runSshCommand( + { + alias: "devbox", + hostname: "devbox.example.com", + username: "julius", + port: 2222, + }, + { remoteCommandArgs: ["sh", "-s"] }, + ), + ); + + assert.isTrue(Result.isFailure(result)); + if (Result.isFailure(result)) { + assert.instanceOf(result.failure, SshCommandError); + assert.equal(result.failure.message, "Pairing token creation failed"); + assert.equal(result.failure.stdout, "Pairing token creation failed\n"); + assert.equal(result.failure.stderr, ""); + } + }).pipe(Effect.provide(processLayer)); + }); + + it.effect("redacts credentials from stdout in non-zero command failures", () => { + const spawner = ChildProcessSpawner.make(() => + Effect.succeed(makeFailedProcess({ stdout: '{"credential":"pairing-secret"}\n' })), + ); + const spawnerLayer = Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner); + const processLayer = Layer.mergeAll(NodeServices.layer, spawnerLayer); + + return Effect.gen(function* () { + const result = yield* Effect.result( + runSshCommand( + { + alias: "devbox", + hostname: "devbox.example.com", + username: "julius", + port: 2222, + }, + { remoteCommandArgs: ["sh", "-s"] }, + ), + ); + + assert.isTrue(Result.isFailure(result)); + if (Result.isFailure(result)) { + assert.instanceOf(result.failure, SshCommandError); + assert.equal(result.failure.message, '{"credential":"[redacted]"}'); + assert.equal(result.failure.stdout, '{"credential":"[redacted]"}\n'); + } + }).pipe(Effect.provide(processLayer)); + }); + it.effect("fails commands that never finish", () => { const spawner = ChildProcessSpawner.make(() => Effect.succeed(makeNeverFinishingProcess())); const spawnerLayer = Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner); diff --git a/packages/ssh/src/command.ts b/packages/ssh/src/command.ts index dc8839378cd..06aa1109edf 100644 --- a/packages/ssh/src/command.ts +++ b/packages/ssh/src/command.ts @@ -15,6 +15,7 @@ import { SshCommandError, SshInvalidTargetError } from "./errors.ts"; const PUBLISHABLE_T3_VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u; const DEFAULT_SSH_COMMAND_TIMEOUT_MS = 60_000; +const MAX_SSH_ERROR_OUTPUT_LENGTH = 4_000; const encoder = new TextEncoder(); @@ -118,9 +119,28 @@ export const collectProcessOutput = ( ), ); -function normalizeSshErrorMessage(stderr: string, fallbackMessage: string): string { - const cleaned = stderr.trim(); - return cleaned.length > 0 ? cleaned : fallbackMessage; +function redactSshErrorOutput(output: string): string { + const redacted = output.replace( + /("(?:access_token|bearerToken|credential|pairingToken|token)"\s*:\s*")[^"]+(")/giu, + "$1[redacted]$2", + ); + return redacted.length > MAX_SSH_ERROR_OUTPUT_LENGTH + ? `${redacted.slice(0, MAX_SSH_ERROR_OUTPUT_LENGTH)}\n[truncated]` + : redacted; +} + +function normalizeSshErrorMessage(input: { + readonly stdout?: string; + readonly stderr: string; + readonly fallbackMessage: string; +}): string { + const cleanedStderr = input.stderr.trim(); + if (cleanedStderr.length > 0) { + return cleanedStderr; + } + + const cleanedStdout = input.stdout?.trim() ?? ""; + return cleanedStdout.length > 0 ? cleanedStdout : input.fallbackMessage; } function sshTargetLogFields(target: DesktopSshEnvironmentTarget) { @@ -226,20 +246,24 @@ const runSshCommandInScope = Effect.fn("ssh/command.runSshCommand.inScope")(func ); if (exitCode !== 0) { + const diagnosticStdout = redactSshErrorOutput(stdout); yield* Effect.logWarning("ssh.command.failed", { ...sshTargetLogFields(target), command: ["ssh", ...args], exitCode, + stdout: diagnosticStdout, stderr, }); return yield* new SshCommandError({ command: ["ssh", ...args], exitCode, + stdout: diagnosticStdout, stderr, - message: normalizeSshErrorMessage( + message: normalizeSshErrorMessage({ + stdout: diagnosticStdout, stderr, - `SSH command failed for ${hostSpec} (exit ${exitCode}).`, - ), + fallbackMessage: `SSH command failed for ${hostSpec} (exit ${exitCode}).`, + }), }); } diff --git a/packages/ssh/src/errors.ts b/packages/ssh/src/errors.ts index 357aaef091d..f1ba40b560c 100644 --- a/packages/ssh/src/errors.ts +++ b/packages/ssh/src/errors.ts @@ -14,6 +14,7 @@ export class SshCommandError extends Data.TaggedError("SshCommandError")<{ readonly command: readonly string[]; readonly exitCode: number | null; readonly stderr: string; + readonly stdout?: string; readonly cause?: unknown; }> {}