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
5} from "@t3tools/contracts";5} from "@t3tools/contracts";
6import {6import {
7 SshCommandSpawnError,7 SshCommandSpawnError,
8 SshCommandTimeoutError,
9 SshHttpBridgeError,8 SshHttpBridgeError,
10 SshPasswordPromptWindowClosedError,9 SshPasswordPromptWindowClosedError,
11} from "@t3tools/ssh/errors";10} from "@t3tools/ssh/errors";
@@ -21,9 +20,9 @@ import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; · 9 unmodified lines
21 20
22import {21import {
23 DesktopSshEnvironmentRequestError,22 DesktopSshEnvironmentRequestError,
23 disconnectSshEnvironment,
24 ensureSshEnvironment,24 ensureSshEnvironment,
25 fetchSshEnvironmentDescriptor,25 fetchSshEnvironmentDescriptor,
26 toDesktopSshIpcPresentationError,
27} from "./sshEnvironment.ts";26} from "./sshEnvironment.ts";
28import * as DesktopSshEnvironment from "../../ssh/DesktopSshEnvironment.ts";27import * as DesktopSshEnvironment from "../../ssh/DesktopSshEnvironment.ts";
29import * as DesktopSshPasswordPrompts from "../../ssh/DesktopSshPasswordPrompts.ts";28import * as DesktopSshPasswordPrompts from "../../ssh/DesktopSshPasswordPrompts.ts";
@@ -95,7 +94,7 @@ describe("SSH environment IPC", () => { · 65 unmodified lines
95 }).pipe(Effect.provide(layer));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 const cause = new Error("ssh executable was not found");98 const cause = new Error("ssh executable was not found");
100 const structured = new SshCommandSpawnError({99 const structured = new SshCommandSpawnError({
101 command: "ssh",100 command: "ssh",
@@ -103,22 +102,35 @@ describe("SSH environment IPC", () => { · 1 unmodified line
103 target: "devbox",102 target: "devbox",
104 cause,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);120 return Effect.gen(function* () {
108 assert.equal(structured.message, "Failed to spawn SSH command for devbox.");121 const exits = yield* Effect.all([
109 assert.equal(presentation.message, cause.message);122 Effect.exit(ensureSshEnvironment.handler({ target })),
110 assert.strictEqual(presentation.cause, structured);123 Effect.exit(disconnectSshEnvironment.handler(target)),
111 });124 ]);
112 125 for (const exit of exits) {
113 it("passes structural SSH errors without process causes through the IPC boundary", () => {126 assert(Exit.isFailure(exit));
114 const structured = new SshCommandTimeoutError({127 const failure = Cause.findErrorOption(exit.cause);
115 command: "ssh",128 assert(Option.isSome(failure));
116 argumentCount: 0,129 assert.strictEqual(failure.value, structured);
117 target: "devbox",130 }
118 timeoutMs: 30_000,131 assert.equal(structured.message, "Failed to spawn SSH command for devbox.");
119 });132 assert.strictEqual(structured.cause, cause);
120 133 }).pipe(Effect.provide(layer));
121 assert.strictEqual(toDesktopSshIpcPresentationError(structured), structured);
122 });134 });
123 135
124 it.effect("fetches and decodes the remote environment descriptor", () => {136 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
26 AuthSessionState,26 AuthSessionState,
27 AuthWebSocketTicketResult,27 AuthWebSocketTicketResult,
28} from "@t3tools/contracts";28} from "@t3tools/contracts";
29import {29import { SshHttpBridgeError, type SshPasswordPromptError } from "@t3tools/ssh/errors";
30 SshCommandExecutionError,
31 SshCommandSpawnError,
32 SshHttpBridgeError,
33 type SshPasswordPromptError,
34 SshTunnelMonitorError,
35 SshTunnelSpawnError,
36} from "@t3tools/ssh/errors";
37import { resolveLoopbackSshHttpBaseUrl } from "@t3tools/ssh/tunnel";30import { resolveLoopbackSshHttpBaseUrl } from "@t3tools/ssh/tunnel";
38import * as Effect from "effect/Effect";31import * as Effect from "effect/Effect";
39import * as Schema from "effect/Schema";32import * as Schema from "effect/Schema";
@@ -83,26 +76,6 @@ const isEnvironmentOperationForbiddenError = Schema.is(EnvironmentOperationForbi · 43 unmodified lines
83const isEnvironmentRequestInvalidError = Schema.is(EnvironmentRequestInvalidError);76const isEnvironmentRequestInvalidError = Schema.is(EnvironmentRequestInvalidError);
84const isEnvironmentScopeRequiredError = Schema.is(EnvironmentScopeRequiredError);77const isEnvironmentScopeRequiredError = Schema.is(EnvironmentScopeRequiredError);
85const isSshHttpBridgeError = Schema.is(SshHttpBridgeError);78const isSshHttpBridgeError = Schema.is(SshHttpBridgeError);
86const 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.
98export 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
107function readSshHttpStatus(cause: DesktopSshEnvironmentRequestCause): number | null {80function readSshHttpStatus(cause: DesktopSshEnvironmentRequestCause): number | null {
108 if (isRemoteEnvironmentAuthUndeclaredStatusError(cause)) {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.
172export const ensureSshEnvironment = DesktopIpc.makeIpcMethod({148export const ensureSshEnvironment = DesktopIpc.makeIpcMethod({
173 channel: IpcChannels.ENSURE_SSH_ENVIRONMENT_CHANNEL,149 channel: IpcChannels.ENSURE_SSH_ENVIRONMENT_CHANNEL,
174 payload: DesktopSshEnvironmentEnsureInputSchema,150 payload: DesktopSshEnvironmentEnsureInputSchema,
@@ -185,7 +161,6 @@ export const ensureSshEnvironment = DesktopIpc.makeIpcMethod({ · 10 unmodified lines
185 SshPasswordPromptWindowClosedError: handleDesktopSshPasswordPromptCancellation,161 SshPasswordPromptWindowClosedError: handleDesktopSshPasswordPromptCancellation,
186 SshPasswordPromptServiceStoppedError: handleDesktopSshPasswordPromptCancellation,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 result: Schema.Void,171 result: Schema.Void,
197 handler: Effect.fn("desktop.ipc.sshEnvironment.disconnectEnvironment")(function* (target) {172 handler: Effect.fn("desktop.ipc.sshEnvironment.disconnectEnvironment")(function* (target) {
198 const sshEnvironment = yield* DesktopSshEnvironment.DesktopSshEnvironment;173 const sshEnvironment = yield* DesktopSshEnvironment.DesktopSshEnvironment;
199 yield* sshEnvironment174 yield* sshEnvironment.disconnectEnvironment(target);
200 .disconnectEnvironment(target)
201 .pipe(Effect.mapError(toDesktopSshIpcPresentationError));
202 }),175 }),
203});176});
204 177