History

fix(desktop): preserve structural SSH IPC errors

Julius Marminge committed 4 months ago

Commit 9ed4bf41 parent 44dd90f2 files · +35 −50Browse files

Unified · Split

2 files
  1. apps/desktop/src/ipc/methods/sshEnvironment.test.ts +30 −18
  2. apps/desktop/src/ipc/methods/sshEnvironment.ts +5 −32
apps/desktop/src/ipc/methods/sshEnvironment.test.ts+30−18
@@ -5,7 +5,6 @@ import { · 4 unmodified lines
55 } from "@t3tools/contracts";
66 import {
77 SshCommandSpawnError,
8− SshCommandTimeoutError,
98 SshHttpBridgeError,
109 SshPasswordPromptWindowClosedError,
1110 } from "@t3tools/ssh/errors";
@@ -21,9 +20,9 @@ import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; · 9 unmodified lines
2120
2221 import {
2322 DesktopSshEnvironmentRequestError,
23+ disconnectSshEnvironment,
2424 ensureSshEnvironment,
2525 fetchSshEnvironmentDescriptor,
26− toDesktopSshIpcPresentationError,
2726 } from "./sshEnvironment.ts";
2827 import * as DesktopSshEnvironment from "../../ssh/DesktopSshEnvironment.ts";
2928 import * as DesktopSshPasswordPrompts from "../../ssh/DesktopSshPasswordPrompts.ts";
@@ -95,7 +94,7 @@ describe("SSH environment IPC", () => { · 65 unmodified lines
9594 }).pipe(Effect.provide(layer));
9695 });
9796
98− it("presents legacy process causes without weakening structured errors", () => {
97+ it.effect("preserves structural SSH errors and exact causes at the IPC boundary", () => {
9998 const cause = new Error("ssh executable was not found");
10099 const structured = new SshCommandSpawnError({
101100 command: "ssh",
@@ -103,22 +102,35 @@ describe("SSH environment IPC", () => { · 1 unmodified line
103102 target: "devbox",
104103 cause,
105104 });
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+ );
106119
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));
122134 });
123135
124136 it.effect("fetches and decodes the remote environment descriptor", () => {
apps/desktop/src/ipc/methods/sshEnvironment.ts+5−32
@@ -26,14 +26,7 @@ import { · 25 unmodified lines
2626 AuthSessionState,
2727 AuthWebSocketTicketResult,
2828 } 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";
3730 import { resolveLoopbackSshHttpBaseUrl } from "@t3tools/ssh/tunnel";
3831 import * as Effect from "effect/Effect";
3932 import * as Schema from "effect/Schema";
@@ -83,26 +76,6 @@ const isEnvironmentOperationForbiddenError = Schema.is(EnvironmentOperationForbi · 43 unmodified lines
8376 const isEnvironmentRequestInvalidError = Schema.is(EnvironmentRequestInvalidError);
8477 const isEnvironmentScopeRequiredError = Schema.is(EnvironmentScopeRequiredError);
8578 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−}
10679
10780 function readSshHttpStatus(cause: DesktopSshEnvironmentRequestCause): number | null {
10881 if (isRemoteEnvironmentAuthUndeclaredStatusError(cause)) {
@@ -169,6 +142,9 @@ export const discoverSshHosts = DesktopIpc.makeIpcMethod({ · 60 unmodified lines
169142 }),
170143 });
171144
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.
172148 export const ensureSshEnvironment = DesktopIpc.makeIpcMethod({
173149 channel: IpcChannels.ENSURE_SSH_ENVIRONMENT_CHANNEL,
174150 payload: DesktopSshEnvironmentEnsureInputSchema,
@@ -185,7 +161,6 @@ export const ensureSshEnvironment = DesktopIpc.makeIpcMethod({ · 10 unmodified lines
185161 SshPasswordPromptWindowClosedError: handleDesktopSshPasswordPromptCancellation,
186162 SshPasswordPromptServiceStoppedError: handleDesktopSshPasswordPromptCancellation,
187163 }),
188− Effect.mapError(toDesktopSshIpcPresentationError),
189164 );
190165 }),
191166 });
@@ -196,9 +171,7 @@ export const disconnectSshEnvironment = DesktopIpc.makeIpcMethod({ · 4 unmodified lines
196171 result: Schema.Void,
197172 handler: Effect.fn("desktop.ipc.sshEnvironment.disconnectEnvironment")(function* (target) {
198173 const sshEnvironment = yield* DesktopSshEnvironment.DesktopSshEnvironment;
199− yield* sshEnvironment
200− .disconnectEnvironment(target)
201− .pipe(Effect.mapError(toDesktopSshIpcPresentationError));
174+ yield* sshEnvironment.disconnectEnvironment(target);
202175 }),
203176 });
204177