Unified · Split
| @@ -5,7 +5,6 @@ import { · 4 unmodified lines | ||
| 5 | 5 | } from "@t3tools/contracts"; |
| 6 | 6 | import { |
| 7 | 7 | SshCommandSpawnError, |
| 8 | − SshCommandTimeoutError, | |
| 9 | 8 | SshHttpBridgeError, |
| 10 | 9 | SshPasswordPromptWindowClosedError, |
| 11 | 10 | } from "@t3tools/ssh/errors"; |
| @@ -21,9 +20,9 @@ import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; · 9 unmodified lines | ||
| 21 | 20 | |
| 22 | 21 | import { |
| 23 | 22 | DesktopSshEnvironmentRequestError, |
| 23 | + disconnectSshEnvironment, | |
| 24 | 24 | ensureSshEnvironment, |
| 25 | 25 | fetchSshEnvironmentDescriptor, |
| 26 | − toDesktopSshIpcPresentationError, | |
| 27 | 26 | } from "./sshEnvironment.ts"; |
| 28 | 27 | import * as DesktopSshEnvironment from "../../ssh/DesktopSshEnvironment.ts"; |
| 29 | 28 | import * as DesktopSshPasswordPrompts from "../../ssh/DesktopSshPasswordPrompts.ts"; |
| @@ -95,7 +94,7 @@ describe("SSH environment IPC", () => { · 65 unmodified lines | ||
| 95 | 94 | }).pipe(Effect.provide(layer)); |
| 96 | 95 | }); |
| 97 | 96 | |
| 98 | − it("presents legacy process causes without weakening structured errors", () => { | |
| 97 | + it.effect("preserves structural SSH errors and exact causes at the IPC boundary", () => { | |
| 99 | 98 | const cause = new Error("ssh executable was not found"); |
| 100 | 99 | const structured = new SshCommandSpawnError({ |
| 101 | 100 | command: "ssh", |
| @@ -103,22 +102,35 @@ describe("SSH environment IPC", () => { · 1 unmodified line | ||
| 103 | 102 | target: "devbox", |
| 104 | 103 | cause, |
| 105 | 104 | }); |
| 105 | + const target = { | |
| 106 | + alias: "devbox", | |
| 107 | + hostname: "devbox.example.test", | |
| 108 | + username: "developer", | |
| 109 | + port: 22, | |
| 110 | + }; | |
| 111 | + const layer = Layer.succeed( | |
| 112 | + DesktopSshEnvironment.DesktopSshEnvironment, | |
| 113 | + DesktopSshEnvironment.DesktopSshEnvironment.of({ | |
| 114 | + discoverHosts: () => Effect.die("unexpected host discovery"), | |
| 115 | + ensureEnvironment: () => Effect.fail(structured), | |
| 116 | + disconnectEnvironment: () => Effect.fail(structured), | |
| 117 | + }), | |
| 118 | + ); | |
| 106 | 119 | |
| 107 | − const presentation = toDesktopSshIpcPresentationError(structured); | |
| 108 | − assert.equal(structured.message, "Failed to spawn SSH command for devbox."); | |
| 109 | − assert.equal(presentation.message, cause.message); | |
| 110 | − assert.strictEqual(presentation.cause, structured); | |
| 111 | − }); | |
| 112 | − | |
| 113 | − it("passes structural SSH errors without process causes through the IPC boundary", () => { | |
| 114 | − const structured = new SshCommandTimeoutError({ | |
| 115 | − command: "ssh", | |
| 116 | − argumentCount: 0, | |
| 117 | − target: "devbox", | |
| 118 | − timeoutMs: 30_000, | |
| 119 | − }); | |
| 120 | − | |
| 121 | − assert.strictEqual(toDesktopSshIpcPresentationError(structured), structured); | |
| 120 | + return Effect.gen(function* () { | |
| 121 | + const exits = yield* Effect.all([ | |
| 122 | + Effect.exit(ensureSshEnvironment.handler({ target })), | |
| 123 | + Effect.exit(disconnectSshEnvironment.handler(target)), | |
| 124 | + ]); | |
| 125 | + for (const exit of exits) { | |
| 126 | + assert(Exit.isFailure(exit)); | |
| 127 | + const failure = Cause.findErrorOption(exit.cause); | |
| 128 | + assert(Option.isSome(failure)); | |
| 129 | + assert.strictEqual(failure.value, structured); | |
| 130 | + } | |
| 131 | + assert.equal(structured.message, "Failed to spawn SSH command for devbox."); | |
| 132 | + assert.strictEqual(structured.cause, cause); | |
| 133 | + }).pipe(Effect.provide(layer)); | |
| 122 | 134 | }); |
| 123 | 135 | |
| 124 | 136 | it.effect("fetches and decodes the remote environment descriptor", () => { |
| @@ -26,14 +26,7 @@ import { · 25 unmodified lines | ||
| 26 | 26 | AuthSessionState, |
| 27 | 27 | AuthWebSocketTicketResult, |
| 28 | 28 | } from "@t3tools/contracts"; |
| 29 | −import { | |
| 30 | − SshCommandExecutionError, | |
| 31 | − SshCommandSpawnError, | |
| 32 | − SshHttpBridgeError, | |
| 33 | − type SshPasswordPromptError, | |
| 34 | − SshTunnelMonitorError, | |
| 35 | − SshTunnelSpawnError, | |
| 36 | −} from "@t3tools/ssh/errors"; | |
| 29 | +import { SshHttpBridgeError, type SshPasswordPromptError } from "@t3tools/ssh/errors"; | |
| 37 | 30 | import { resolveLoopbackSshHttpBaseUrl } from "@t3tools/ssh/tunnel"; |
| 38 | 31 | import * as Effect from "effect/Effect"; |
| 39 | 32 | import * as Schema from "effect/Schema"; |
| @@ -83,26 +76,6 @@ const isEnvironmentOperationForbiddenError = Schema.is(EnvironmentOperationForbi · 43 unmodified lines | ||
| 83 | 76 | const isEnvironmentRequestInvalidError = Schema.is(EnvironmentRequestInvalidError); |
| 84 | 77 | const isEnvironmentScopeRequiredError = Schema.is(EnvironmentScopeRequiredError); |
| 85 | 78 | const isSshHttpBridgeError = Schema.is(SshHttpBridgeError); |
| 86 | −const isSshCausePresentationError = Schema.is( | |
| 87 | − Schema.Union([ | |
| 88 | − SshCommandSpawnError, | |
| 89 | − SshCommandExecutionError, | |
| 90 | − SshTunnelSpawnError, | |
| 91 | − SshTunnelMonitorError, | |
| 92 | − ]), | |
| 93 | −); | |
| 94 | − | |
| 95 | −// Electron only forwards the message from a rejected ipcRenderer.invoke promise. Keep the typed, | |
| 96 | −// structural SSH error intact as the cause for main-process diagnostics while preserving the | |
| 97 | −// actionable process message that the renderer historically presented to the user. | |
| 98 | −export function toDesktopSshIpcPresentationError( | |
| 99 | − error: DesktopSshEnvironment.DesktopSshEnvironmentOperationError, | |
| 100 | −): DesktopSshEnvironment.DesktopSshEnvironmentOperationError | Error { | |
| 101 | − if (isSshCausePresentationError(error) && error.cause instanceof Error) { | |
| 102 | − return new Error(error.cause.message, { cause: error }); | |
| 103 | − } | |
| 104 | − return error; | |
| 105 | −} | |
| 106 | 79 | |
| 107 | 80 | function readSshHttpStatus(cause: DesktopSshEnvironmentRequestCause): number | null { |
| 108 | 81 | if (isRemoteEnvironmentAuthUndeclaredStatusError(cause)) { |
| @@ -169,6 +142,9 @@ export const discoverSshHosts = DesktopIpc.makeIpcMethod({ · 60 unmodified lines | ||
| 169 | 142 | }), |
| 170 | 143 | }); |
| 171 | 144 | |
| 145 | +// Electron forwards only the message from a rejected ipcRenderer.invoke promise to the renderer. | |
| 146 | +// Preserve typed SSH errors at this boundary so that message stays structural while main-process | |
| 147 | +// diagnostics retain the exact cause and error fields. | |
| 172 | 148 | export const ensureSshEnvironment = DesktopIpc.makeIpcMethod({ |
| 173 | 149 | channel: IpcChannels.ENSURE_SSH_ENVIRONMENT_CHANNEL, |
| 174 | 150 | payload: DesktopSshEnvironmentEnsureInputSchema, |
| @@ -185,7 +161,6 @@ export const ensureSshEnvironment = DesktopIpc.makeIpcMethod({ · 10 unmodified lines | ||
| 185 | 161 | SshPasswordPromptWindowClosedError: handleDesktopSshPasswordPromptCancellation, |
| 186 | 162 | SshPasswordPromptServiceStoppedError: handleDesktopSshPasswordPromptCancellation, |
| 187 | 163 | }), |
| 188 | − Effect.mapError(toDesktopSshIpcPresentationError), | |
| 189 | 164 | ); |
| 190 | 165 | }), |
| 191 | 166 | }); |
| @@ -196,9 +171,7 @@ export const disconnectSshEnvironment = DesktopIpc.makeIpcMethod({ · 4 unmodified lines | ||
| 196 | 171 | result: Schema.Void, |
| 197 | 172 | handler: Effect.fn("desktop.ipc.sshEnvironment.disconnectEnvironment")(function* (target) { |
| 198 | 173 | const sshEnvironment = yield* DesktopSshEnvironment.DesktopSshEnvironment; |
| 199 | − yield* sshEnvironment | |
| 200 | − .disconnectEnvironment(target) | |
| 201 | − .pipe(Effect.mapError(toDesktopSshIpcPresentationError)); | |
| 174 | + yield* sshEnvironment.disconnectEnvironment(target); | |
| 202 | 175 | }), |
| 203 | 176 | }); |
| 204 | 177 | |