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", () => {
2323 const cause = new DesktopSshPasswordPrompts.DesktopSshPromptPresentationError({
2424 requestId: "prompt-1",
2525 destination: "devbox",
26 operation: "send-prompt-request",
2627 cause: new Error("renderer send failed"),
2728 });
2829
apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts+107-4
......@@ -17,7 +17,13 @@ interface SentMessage {
1717 readonly args: readonly unknown[];
1818}
1919
20function makeTestWindow() {
20function makeTestWindow(
21 options: {
22 readonly isDestroyedError?: unknown;
23 readonly isMinimizedError?: unknown;
24 readonly sendError?: unknown;
25 } = {},
26) {
2127 const listeners = new Map<string, Set<() => void>>();
2228 const sentMessages: SentMessage[] = [];
2329 let destroyed = false;
......@@ -26,8 +32,18 @@ function makeTestWindow() {
2632 let focused = false;
2733
2834 const window = {
29 isDestroyed: () => destroyed,
30 isMinimized: () => minimized,
35 isDestroyed: () => {
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 },
3147 restore: () => {
3248 restored = true;
3349 minimized = false;
......@@ -45,7 +61,11 @@ function makeTestWindow() {
4561 },
4662 webContents: {
4763 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 }
4969 },
5070 },
5171 };
......@@ -55,6 +75,7 @@ function makeTestWindow() {
5575 sentMessages,
5676 isRestored: () => restored,
5777 isFocused: () => focused,
78 closedListenerCount: () => listeners.get("closed")?.size ?? 0,
5879 close: () => {
5980 destroyed = true;
6081 const closedListeners = [...(listeners.get("closed") ?? [])];
......@@ -107,6 +128,7 @@ describe("DesktopSshPasswordPrompts", () => {
107128 })
108129 .pipe(Effect.forkScoped);
109130
131 yield* Effect.yieldNow;
110132 yield* Effect.yieldNow;
111133 assert.equal(testWindow.sentMessages.length, 1);
112134 const sent = testWindow.sentMessages[0];
......@@ -143,4 +165,85 @@ describe("DesktopSshPasswordPrompts", () => {
143165 assert.equal(error.destination, "devbox");
144166 }).pipe(Effect.provide(makeLayer(testWindow.window)), Effect.scoped);
145167 });
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 });
146249});
apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts+122-34
......@@ -20,7 +20,26 @@ const DEFAULT_SSH_PASSWORD_PROMPT_TIMEOUT_MS = 3 * 60 * 1000;
2020type DesktopSshPasswordPromptResolutionInput =
2121 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
2544export class DesktopSshPromptRequestIdGenerationError extends Schema.TaggedErrorClass<DesktopSshPromptRequestIdGenerationError>()(
2645 "DesktopSshPromptRequestIdGenerationError",
......@@ -38,20 +57,22 @@ export class DesktopSshPromptWindowUnavailableError extends Schema.TaggedErrorCl
3857 "DesktopSshPromptWindowUnavailableError",
3958 {
4059 destination: Schema.String,
60 requestId: Schema.NullOr(Schema.String),
61 stage: DesktopSshPromptWindowAvailabilityStage,
4162 },
4263) {
4364 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}).`;
4567 }
4668}
4769
48const isDesktopSshPromptWindowUnavailableError = Schema.is(DesktopSshPromptWindowUnavailableError);
49
5070export class DesktopSshPromptPresentationError extends Schema.TaggedErrorClass<DesktopSshPromptPresentationError>()(
5171 "DesktopSshPromptPresentationError",
5272 {
53 requestId: Schema.String,
73 requestId: Schema.NullOr(Schema.String),
5474 destination: Schema.String,
75 operation: DesktopSshPromptPresentationOperation,
5576 cause: Schema.Defect(),
5677 },
5778) {
......@@ -263,9 +284,29 @@ export const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* (
263284 "desktop.sshPasswordPrompts.request",
264285 )(function* (input) {
265286 const window = yield* electronWindow.main;
266 if (Option.isNone(window) || window.value.isDestroyed()) {
287 if (Option.isNone(window)) {
267288 return yield* new DesktopSshPromptWindowUnavailableError({
268289 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",
269310 });
270311 }
271312
......@@ -319,11 +360,25 @@ export const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* (
319360 ),
320361 );
321362 };
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", () => {
323378 if (!window.value.isDestroyed()) {
324379 window.value.removeListener("closed", cancelOnWindowClosed);
325380 }
326 }).pipe(Effect.andThen(removePending(pendingRef, requestId)), Effect.asVoid);
381 }).pipe(Effect.orDie, Effect.ensuring(removePending(pendingRef, requestId)), Effect.asVoid);
327382 const waitForPassword = Deferred.await(deferred).pipe(
328383 Effect.timeoutOption(Duration.millis(passwordPromptTimeoutMs)),
329384 Effect.flatMap(
......@@ -339,40 +394,73 @@ export const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* (
339394 }),
340395 ),
341396 );
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({
344 try: () => {
345 if (window.value.isDestroyed()) {
346 throw new DesktopSshPromptWindowUnavailableError({
347 destination: input.destination,
348 });
349 }
350 window.value.once("closed", cancelOnWindowClosed);
351 window.value.webContents.send(SSH_PASSWORD_PROMPT_CHANNEL, promptRequest);
352 if (window.value.isDestroyed()) {
353 throw new DesktopSshPromptWindowUnavailableError({
412 return yield* Effect.gen(function* () {
413 const unavailableBeforePresentation = yield* runPresentationOperation(
414 "check-window-before-presentation",
415 () => window.value.isDestroyed(),
416 );
417 if (unavailableBeforePresentation) {
418 return yield* new DesktopSshPromptWindowUnavailableError({
419 destination: input.destination,
420 requestId,
421 stage: "before-presentation",
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({
354438 destination: input.destination,
439 requestId,
440 stage: "after-send",
355441 });
356442 }
357 if (window.value.isMinimized()) {
358 window.value.restore();
443 const minimized = yield* runPresentationOperation("check-window-minimized", () =>
444 window.value.isMinimized(),
445 );
446 if (minimized) {
447 yield* runPresentationOperation("restore-window", () => window.value.restore());
359448 }
360 if (window.value.isDestroyed()) {
361 throw new DesktopSshPromptWindowUnavailableError({
449 const unavailableAfterRestore = yield* runPresentationOperation(
450 "check-window-after-restore",
451 () => window.value.isDestroyed(),
452 );
453 if (unavailableAfterRestore) {
454 return yield* new DesktopSshPromptWindowUnavailableError({
362455 destination: input.destination,
456 requestId,
457 stage: "after-restore",
363458 });
364459 }
365 window.value.focus();
366 },
367 catch: (cause) =>
368 isDesktopSshPromptWindowUnavailableError(cause)
369 ? cause
370 : new DesktopSshPromptPresentationError({
371 requestId,
372 destination: input.destination,
373 cause,
374 }),
375 }).pipe(Effect.andThen(waitForPassword), Effect.ensuring(cleanup));
460 yield* runPresentationOperation("focus-window", () => window.value.focus());
461 return yield* waitForPassword;
462 }).pipe(Effect.catch(preferSubmittedPassword));
463 }).pipe(Effect.ensuring(cleanup));
376464 });
377465
378466 return DesktopSshPasswordPrompts.of({