authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 12:31:49-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 12:31:49-07:00
log569b3e5959c19e87fec28480c40011eae2e465dd
treefce51743b7236dfc9774ccef51e7d6eca2ff785b
parentd7ff7e73336482d64449f72a4bd31d9ce9f4c01e
signature Signed by PGP key B5690EEEBB952194

[codex] Structure desktop SSH prompt presentation failures (#3429)

Co-authored-by: codex <codex@users.noreply.github.com>

3 files changed, 230 insertions(+), 38 deletions(-)

apps/desktop/src/ssh/DesktopSshEnvironment.test.ts+1
...@@ -23,6 +23,7 @@ describe("sshEnvironment", () => {...@@ -23,6 +23,7 @@ describe("sshEnvironment", () => {
23 const cause = new DesktopSshPasswordPrompts.DesktopSshPromptPresentationError({23 const cause = new DesktopSshPasswordPrompts.DesktopSshPromptPresentationError({
24 requestId: "prompt-1",24 requestId: "prompt-1",
25 destination: "devbox",25 destination: "devbox",
26 operation: "send-prompt-request",
26 cause: new Error("renderer send failed"),27 cause: new Error("renderer send failed"),
27 });28 });
2829
apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts+107-4
...@@ -17,7 +17,13 @@ interface SentMessage {...@@ -17,7 +17,13 @@ interface SentMessage {
17 readonly args: readonly unknown[];17 readonly args: readonly unknown[];
18}18}
1919
20function makeTestWindow() {20function makeTestWindow(
21 options: {
22 readonly isDestroyedError?: unknown;
23 readonly isMinimizedError?: unknown;
24 readonly sendError?: unknown;
25 } = {},
26) {
21 const listeners = new Map<string, Set<() => void>>();27 const listeners = new Map<string, Set<() => void>>();
22 const sentMessages: SentMessage[] = [];28 const sentMessages: SentMessage[] = [];
23 let destroyed = false;29 let destroyed = false;
...@@ -26,8 +32,18 @@ function makeTestWindow() {...@@ -26,8 +32,18 @@ function makeTestWindow() {
26 let focused = false;32 let focused = false;
2733
28 const window = {34 const window = {
29 isDestroyed: () => destroyed,35 isDestroyed: () => {
30 isMinimized: () => minimized,36 if (options.isDestroyedError !== undefined) {
37 throw options.isDestroyedError;
38 }
39 return destroyed;
40 },
41 isMinimized: () => {
42 if (options.isMinimizedError !== undefined) {
43 throw options.isMinimizedError;
44 }
45 return minimized;
46 },
31 restore: () => {47 restore: () => {
32 restored = true;48 restored = true;
33 minimized = false;49 minimized = false;
...@@ -45,7 +61,11 @@ function makeTestWindow() {...@@ -45,7 +61,11 @@ function makeTestWindow() {
45 },61 },
46 webContents: {62 webContents: {
47 send: (channel: string, ...args: readonly unknown[]) => {63 send: (channel: string, ...args: readonly unknown[]) => {
48 sentMessages.push({ channel, args });64 const message = { channel, args };
65 sentMessages.push(message);
66 if (options.sendError !== undefined) {
67 throw options.sendError;
68 }
49 },69 },
50 },70 },
51 };71 };
...@@ -55,6 +75,7 @@ function makeTestWindow() {...@@ -55,6 +75,7 @@ function makeTestWindow() {
55 sentMessages,75 sentMessages,
56 isRestored: () => restored,76 isRestored: () => restored,
57 isFocused: () => focused,77 isFocused: () => focused,
78 closedListenerCount: () => listeners.get("closed")?.size ?? 0,
58 close: () => {79 close: () => {
59 destroyed = true;80 destroyed = true;
60 const closedListeners = [...(listeners.get("closed") ?? [])];81 const closedListeners = [...(listeners.get("closed") ?? [])];
...@@ -107,6 +128,7 @@ describe("DesktopSshPasswordPrompts", () => {...@@ -107,6 +128,7 @@ describe("DesktopSshPasswordPrompts", () => {
107 })128 })
108 .pipe(Effect.forkScoped);129 .pipe(Effect.forkScoped);
109130
131 yield* Effect.yieldNow;
110 yield* Effect.yieldNow;132 yield* Effect.yieldNow;
111 assert.equal(testWindow.sentMessages.length, 1);133 assert.equal(testWindow.sentMessages.length, 1);
112 const sent = testWindow.sentMessages[0];134 const sent = testWindow.sentMessages[0];
...@@ -143,4 +165,85 @@ describe("DesktopSshPasswordPrompts", () => {...@@ -143,4 +165,85 @@ describe("DesktopSshPasswordPrompts", () => {
143 assert.equal(error.destination, "devbox");165 assert.equal(error.destination, "devbox");
144 }).pipe(Effect.provide(makeLayer(testWindow.window)), Effect.scoped);166 }).pipe(Effect.provide(makeLayer(testWindow.window)), Effect.scoped);
145 });167 });
168
169 it.effect("cleans up a prompt that fails during renderer delivery", () => {
170 const cause = new Error("renderer unavailable");
171 const testWindow = makeTestWindow({ sendError: cause });
172
173 return Effect.gen(function* () {
174 const prompts = yield* DesktopSshPasswordPrompts.DesktopSshPasswordPrompts;
175 const error = yield* prompts
176 .request({
177 destination: "devbox",
178 username: "julius",
179 prompt: "Enter the SSH password.",
180 attempt: 1,
181 })
182 .pipe(Effect.flip);
183
184 assert.instanceOf(error, DesktopSshPasswordPrompts.DesktopSshPromptPresentationError);
185 assert.equal(error.operation, "send-prompt-request");
186 assert.equal(error.destination, "devbox");
187 const requestId = error.requestId;
188 if (requestId === null) {
189 assert.fail("renderer delivery failures must retain their request id");
190 }
191 assert.equal(testWindow.closedListenerCount(), 0);
192
193 const resolveError = yield* prompts
194 .resolve({ requestId, password: "secret" })
195 .pipe(Effect.flip);
196 assert.instanceOf(resolveError, DesktopSshPasswordPrompts.DesktopSshPromptExpiredError);
197 }).pipe(Effect.provide(makeLayer(testWindow.window)), Effect.scoped);
198 });
199
200 it.effect("keeps a submitted password when a later presentation step fails", () => {
201 const testWindow = makeTestWindow({
202 isMinimizedError: new Error("failed to read minimized state"),
203 });
204
205 return Effect.gen(function* () {
206 const prompts = yield* DesktopSshPasswordPrompts.DesktopSshPasswordPrompts;
207 const requestFiber = yield* prompts
208 .request({
209 destination: "devbox",
210 username: "julius",
211 prompt: "Enter the SSH password.",
212 attempt: 1,
213 })
214 .pipe(Effect.forkScoped);
215
216 yield* Effect.yieldNow;
217 const sent = testWindow.sentMessages[0];
218 assert.ok(sent);
219 const request = sent.args[0] as { readonly requestId: string };
220 yield* prompts.resolve({ requestId: request.requestId, password: "secret" });
221 const password = yield* Fiber.join(requestFiber);
222
223 assert.equal(password, "secret");
224 assert.equal(testWindow.isFocused(), false);
225 assert.equal(testWindow.closedListenerCount(), 0);
226 }).pipe(Effect.provide(makeLayer(testWindow.window)), Effect.scoped);
227 });
228
229 it.effect("classifies a failed initial window availability check", () => {
230 const testWindow = makeTestWindow({ isDestroyedError: new Error("window unavailable") });
231
232 return Effect.gen(function* () {
233 const prompts = yield* DesktopSshPasswordPrompts.DesktopSshPasswordPrompts;
234 const error = yield* prompts
235 .request({
236 destination: "devbox",
237 username: "julius",
238 prompt: "Enter the SSH password.",
239 attempt: 1,
240 })
241 .pipe(Effect.flip);
242
243 assert.instanceOf(error, DesktopSshPasswordPrompts.DesktopSshPromptPresentationError);
244 assert.equal(error.operation, "check-window-before-request");
245 assert.equal(error.requestId, null);
246 assert.deepEqual(testWindow.sentMessages, []);
247 }).pipe(Effect.provide(makeLayer(testWindow.window)), Effect.scoped);
248 });
146});249});
apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts+122-34
...@@ -20,7 +20,26 @@ const DEFAULT_SSH_PASSWORD_PROMPT_TIMEOUT_MS = 3 * 60 * 1000;...@@ -20,7 +20,26 @@ const DEFAULT_SSH_PASSWORD_PROMPT_TIMEOUT_MS = 3 * 60 * 1000;
20type DesktopSshPasswordPromptResolutionInput =20type DesktopSshPasswordPromptResolutionInput =
21 typeof DesktopSshPasswordPromptResolutionInputSchema.Type;21 typeof DesktopSshPasswordPromptResolutionInputSchema.Type;
2222
23const WINDOW_UNAVAILABLE_MESSAGE = "T3 Code window is not available for SSH authentication.";23const DesktopSshPromptWindowAvailabilityStage = Schema.Literals([
24 "before-request",
25 "before-presentation",
26 "after-send",
27 "after-restore",
28]);
29
30const DesktopSshPromptPresentationOperation = Schema.Literals([
31 "check-window-before-request",
32 "check-window-before-presentation",
33 "register-window-close-listener",
34 "send-prompt-request",
35 "check-window-after-send",
36 "check-window-minimized",
37 "restore-window",
38 "check-window-after-restore",
39 "focus-window",
40 "remove-window-close-listener",
41]);
42type DesktopSshPromptPresentationOperation = typeof DesktopSshPromptPresentationOperation.Type;
2443
25export class DesktopSshPromptRequestIdGenerationError extends Schema.TaggedErrorClass<DesktopSshPromptRequestIdGenerationError>()(44export class DesktopSshPromptRequestIdGenerationError extends Schema.TaggedErrorClass<DesktopSshPromptRequestIdGenerationError>()(
26 "DesktopSshPromptRequestIdGenerationError",45 "DesktopSshPromptRequestIdGenerationError",
...@@ -38,20 +57,22 @@ export class DesktopSshPromptWindowUnavailableError extends Schema.TaggedErrorCl...@@ -38,20 +57,22 @@ export class DesktopSshPromptWindowUnavailableError extends Schema.TaggedErrorCl
38 "DesktopSshPromptWindowUnavailableError",57 "DesktopSshPromptWindowUnavailableError",
39 {58 {
40 destination: Schema.String,59 destination: Schema.String,
60 requestId: Schema.NullOr(Schema.String),
61 stage: DesktopSshPromptWindowAvailabilityStage,
41 },62 },
42) {63) {
43 override get message(): string {64 override get message(): string {
44 return WINDOW_UNAVAILABLE_MESSAGE;65 const request = this.requestId === null ? "before a request id was assigned" : this.requestId;
66 return `T3 Code window is unavailable during ${this.stage} for SSH authentication to ${this.destination} (request: ${request}).`;
45 }67 }
46}68}
4769
48const isDesktopSshPromptWindowUnavailableError = Schema.is(DesktopSshPromptWindowUnavailableError);
49
50export class DesktopSshPromptPresentationError extends Schema.TaggedErrorClass<DesktopSshPromptPresentationError>()(70export class DesktopSshPromptPresentationError extends Schema.TaggedErrorClass<DesktopSshPromptPresentationError>()(
51 "DesktopSshPromptPresentationError",71 "DesktopSshPromptPresentationError",
52 {72 {
53 requestId: Schema.String,73 requestId: Schema.NullOr(Schema.String),
54 destination: Schema.String,74 destination: Schema.String,
75 operation: DesktopSshPromptPresentationOperation,
55 cause: Schema.Defect(),76 cause: Schema.Defect(),
56 },77 },
57) {78) {
...@@ -263,9 +284,29 @@ export const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* (...@@ -263,9 +284,29 @@ export const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* (
263 "desktop.sshPasswordPrompts.request",284 "desktop.sshPasswordPrompts.request",
264 )(function* (input) {285 )(function* (input) {
265 const window = yield* electronWindow.main;286 const window = yield* electronWindow.main;
266 if (Option.isNone(window) || window.value.isDestroyed()) {287 if (Option.isNone(window)) {
267 return yield* new DesktopSshPromptWindowUnavailableError({288 return yield* new DesktopSshPromptWindowUnavailableError({
268 destination: input.destination,289 destination: input.destination,
290 requestId: null,
291 stage: "before-request",
292 });
293 }
294
295 const unavailableBeforeRequest = yield* Effect.try({
296 try: () => window.value.isDestroyed(),
297 catch: (cause) =>
298 new DesktopSshPromptPresentationError({
299 requestId: null,
300 destination: input.destination,
301 operation: "check-window-before-request",
302 cause,
303 }),
304 });
305 if (unavailableBeforeRequest) {
306 return yield* new DesktopSshPromptWindowUnavailableError({
307 destination: input.destination,
308 requestId: null,
309 stage: "before-request",
269 });310 });
270 }311 }
271312
...@@ -319,11 +360,25 @@ export const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* (...@@ -319,11 +360,25 @@ export const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* (
319 ),360 ),
320 );361 );
321 };362 };
322 const cleanup = Effect.sync(() => {363 const runPresentationOperation = <A>(
364 operation: DesktopSshPromptPresentationOperation,
365 evaluate: () => A,
366 ) =>
367 Effect.try({
368 try: evaluate,
369 catch: (cause) =>
370 new DesktopSshPromptPresentationError({
371 requestId,
372 destination: input.destination,
373 operation,
374 cause,
375 }),
376 });
377 const cleanup = runPresentationOperation("remove-window-close-listener", () => {
323 if (!window.value.isDestroyed()) {378 if (!window.value.isDestroyed()) {
324 window.value.removeListener("closed", cancelOnWindowClosed);379 window.value.removeListener("closed", cancelOnWindowClosed);
325 }380 }
326 }).pipe(Effect.andThen(removePending(pendingRef, requestId)), Effect.asVoid);381 }).pipe(Effect.orDie, Effect.ensuring(removePending(pendingRef, requestId)), Effect.asVoid);
327 const waitForPassword = Deferred.await(deferred).pipe(382 const waitForPassword = Deferred.await(deferred).pipe(
328 Effect.timeoutOption(Duration.millis(passwordPromptTimeoutMs)),383 Effect.timeoutOption(Duration.millis(passwordPromptTimeoutMs)),
329 Effect.flatMap(384 Effect.flatMap(
...@@ -339,40 +394,73 @@ export const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* (...@@ -339,40 +394,73 @@ export const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* (
339 }),394 }),
340 ),395 ),
341 );396 );
397 const preferSubmittedPassword = (error: DesktopSshPasswordPromptRequestError) =>
398 Deferred.poll(deferred).pipe(
399 Effect.flatMap(
400 Option.match({
401 onSome: (completion) => completion,
402 onNone: () =>
403 Ref.get(pendingRef).pipe(
404 Effect.flatMap((entries) =>
405 entries.has(requestId) ? Effect.fail(error) : Deferred.await(deferred),
406 ),
407 ),
408 }),
409 ),
410 );
342411
343 return yield* Effect.try({412 return yield* Effect.gen(function* () {
344 try: () => {413 const unavailableBeforePresentation = yield* runPresentationOperation(
345 if (window.value.isDestroyed()) {414 "check-window-before-presentation",
346 throw new DesktopSshPromptWindowUnavailableError({415 () => window.value.isDestroyed(),
347 destination: input.destination,416 );
348 });417 if (unavailableBeforePresentation) {
349 }418 return yield* new DesktopSshPromptWindowUnavailableError({
350 window.value.once("closed", cancelOnWindowClosed);419 destination: input.destination,
351 window.value.webContents.send(SSH_PASSWORD_PROMPT_CHANNEL, promptRequest);420 requestId,
352 if (window.value.isDestroyed()) {421 stage: "before-presentation",
353 throw new DesktopSshPromptWindowUnavailableError({422 });
423 }
424 yield* runPresentationOperation("register-window-close-listener", () =>
425 window.value.once("closed", cancelOnWindowClosed),
426 );
427 return yield* Effect.gen(function* () {
428 yield* runPresentationOperation("send-prompt-request", () =>
429 window.value.webContents.send(SSH_PASSWORD_PROMPT_CHANNEL, promptRequest),
430 );
431 yield* Effect.yieldNow;
432 const unavailableAfterSend = yield* runPresentationOperation(
433 "check-window-after-send",
434 () => window.value.isDestroyed(),
435 );
436 if (unavailableAfterSend) {
437 return yield* new DesktopSshPromptWindowUnavailableError({
354 destination: input.destination,438 destination: input.destination,
439 requestId,
440 stage: "after-send",
355 });441 });
356 }442 }
357 if (window.value.isMinimized()) {443 const minimized = yield* runPresentationOperation("check-window-minimized", () =>
358 window.value.restore();444 window.value.isMinimized(),
445 );
446 if (minimized) {
447 yield* runPresentationOperation("restore-window", () => window.value.restore());
359 }448 }
360 if (window.value.isDestroyed()) {449 const unavailableAfterRestore = yield* runPresentationOperation(
361 throw new DesktopSshPromptWindowUnavailableError({450 "check-window-after-restore",
451 () => window.value.isDestroyed(),
452 );
453 if (unavailableAfterRestore) {
454 return yield* new DesktopSshPromptWindowUnavailableError({
362 destination: input.destination,455 destination: input.destination,
456 requestId,
457 stage: "after-restore",
363 });458 });
364 }459 }
365 window.value.focus();460 yield* runPresentationOperation("focus-window", () => window.value.focus());
366 },461 return yield* waitForPassword;
367 catch: (cause) =>462 }).pipe(Effect.catch(preferSubmittedPassword));
368 isDesktopSshPromptWindowUnavailableError(cause)463 }).pipe(Effect.ensuring(cleanup));
369 ? cause
370 : new DesktopSshPromptPresentationError({
371 requestId,
372 destination: input.destination,
373 cause,
374 }),
375 }).pipe(Effect.andThen(waitForPassword), Effect.ensuring(cleanup));
376 });464 });
377465
378 return DesktopSshPasswordPrompts.of({466 return DesktopSshPasswordPrompts.of({