| author | |
| committer | |
| log | f5849f7d78e4ebea277c4a9b5086d592f2aae3a4 |
| tree | 145e70f138d1c8fbfb3b6c769eb66b7d77443929 |
| parent | 6e6163255d85607db0705cac8bfdf99e05119aca |
| signature | Signed by PGP key B5690EEEBB952194 |
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"; |
| 20 | import { SshCommandError } from "./errors.ts"; | ||
| 21 | |||
| 22 | const encoder = new TextEncoder(); | ||
| 23 | |||
| 24 | const 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 | }; | ||
| 20 | 41 | ||
| 21 | const makeNeverFinishingProcess = () => { | 42 | const 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 | ); |
| 126 | 147 | ||
| 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"; |
| 15 | 15 | ||
| 16 | const PUBLISHABLE_T3_VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u; | 16 | const PUBLISHABLE_T3_VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u; |
| 17 | const DEFAULT_SSH_COMMAND_TIMEOUT_MS = 60_000; | 17 | const DEFAULT_SSH_COMMAND_TIMEOUT_MS = 60_000; |
| 18 | const MAX_SSH_ERROR_OUTPUT_LENGTH = 4_000; | ||
| 18 | 19 | ||
| 19 | const encoder = new TextEncoder(); | 20 | const encoder = new TextEncoder(); |
| 20 | 21 | ||
| ... | @@ -118,9 +119,28 @@ export const collectProcessOutput = <E>( | ... | @@ -118,9 +119,28 @@ export const collectProcessOutput = <E>( |
| 118 | ), | 119 | ), |
| 119 | ); | 120 | ); |
| 120 | 121 | ||
| 121 | function normalizeSshErrorMessage(stderr: string, fallbackMessage: string): string { | 122 | function 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 | |||
| 132 | function 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 | } |
| 125 | 145 | ||
| 126 | function sshTargetLogFields(target: DesktopSshEnvironmentTarget) { | 146 | function 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 | ); |
| 227 | 247 | ||
| 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 | } |
| 245 | 269 |
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 | }> {} |
| 19 | 20 |