From 009dcd9e17816574097a47d4439c0978fc86b8e2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 19 Jun 2026 18:43:49 -0700 Subject: [PATCH 1/3] Migrate desktop shell and SSH Effect services Co-authored-by: codex --- .../src/shell/DesktopShellEnvironment.ts | 45 ++-- apps/desktop/src/ssh/DesktopSshEnvironment.ts | 42 ++-- .../src/ssh/DesktopSshPasswordPrompts.ts | 193 ++++++++++-------- 3 files changed, 151 insertions(+), 129 deletions(-) diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.ts b/apps/desktop/src/shell/DesktopShellEnvironment.ts index 13ac35b6297..a48e896b7f5 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.ts @@ -3,7 +3,8 @@ import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import * as ChildProcess from "effect/unstable/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; @@ -19,13 +20,11 @@ interface WindowsProbeOptions { readonly loadProfile: boolean; } -export interface DesktopShellEnvironmentShape { - readonly installIntoProcess: Effect.Effect; -} - export class DesktopShellEnvironment extends Context.Service< DesktopShellEnvironment, - DesktopShellEnvironmentShape + { + readonly installIntoProcess: Effect.Effect; + } >()("@t3tools/desktop/shell/DesktopShellEnvironment") {} const LOGIN_SHELL_ENV_NAMES = [ @@ -336,20 +335,20 @@ const installShellEnvironment = ( return Effect.void; }; -export const layer = Layer.effect( - DesktopShellEnvironment, - Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - return DesktopShellEnvironment.of({ - installIntoProcess: installShellEnvironment({ - env: process.env, - platform: environment.platform, - userShell: Option.none(), - }).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - Effect.withSpan("desktop.shellEnvironment.installIntoProcess"), - ), - }); - }), -); +export const make = Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const installIntoProcess: DesktopShellEnvironment["Service"]["installIntoProcess"] = + installShellEnvironment({ + env: process.env, + platform: environment.platform, + userShell: Option.none(), + }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.withSpan("desktop.shellEnvironment.installIntoProcess"), + ); + + return DesktopShellEnvironment.of({ installIntoProcess }); +}); + +export const layer = Layer.effect(DesktopShellEnvironment, make); diff --git a/apps/desktop/src/ssh/DesktopSshEnvironment.ts b/apps/desktop/src/ssh/DesktopSshEnvironment.ts index 595d3bea304..614c5ea0e6b 100644 --- a/apps/desktop/src/ssh/DesktopSshEnvironment.ts +++ b/apps/desktop/src/ssh/DesktopSshEnvironment.ts @@ -4,11 +4,7 @@ import type { DesktopSshEnvironmentTarget, } from "@t3tools/contracts"; import * as NetService from "@t3tools/shared/Net"; -import { - SshPasswordPrompt, - type SshPasswordPromptShape, - type SshPasswordRequest, -} from "@t3tools/ssh/auth"; +import { SshPasswordPrompt, type SshPasswordRequest } from "@t3tools/ssh/auth"; import { discoverSshHosts } from "@t3tools/ssh/config"; import { SshCommandError, @@ -25,8 +21,8 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; -import { HttpClient } from "effect/unstable/http"; -import { ChildProcessSpawner } from "effect/unstable/process"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import * as DesktopSshPasswordPrompts from "./DesktopSshPasswordPrompts.ts"; @@ -52,22 +48,20 @@ export type DesktopSshEnvironmentError = | DesktopSshEnvironmentDiscoverError | DesktopSshEnvironmentOperationError; -export interface DesktopSshEnvironmentShape { - readonly discoverHosts: (input?: { - readonly homeDir?: string; - }) => Effect.Effect; - readonly ensureEnvironment: ( - target: DesktopSshEnvironmentTarget, - options?: { readonly issuePairingToken?: boolean }, - ) => Effect.Effect; - readonly disconnectEnvironment: ( - target: DesktopSshEnvironmentTarget, - ) => Effect.Effect; -} - export class DesktopSshEnvironment extends Context.Service< DesktopSshEnvironment, - DesktopSshEnvironmentShape + { + readonly discoverHosts: (input?: { + readonly homeDir?: string; + }) => Effect.Effect; + readonly ensureEnvironment: ( + target: DesktopSshEnvironmentTarget, + options?: { readonly issuePairingToken?: boolean }, + ) => Effect.Effect; + readonly disconnectEnvironment: ( + target: DesktopSshEnvironmentTarget, + ) => Effect.Effect; + } >()("@t3tools/desktop/ssh/DesktopSshEnvironment") {} export interface DesktopSshEnvironmentLayerOptions { @@ -89,8 +83,8 @@ export function isDesktopSshPasswordPromptCancellation( } const makePasswordPrompt = ( - prompts: DesktopSshPasswordPrompts.DesktopSshPasswordPromptsShape, -): SshPasswordPromptShape => ({ + prompts: DesktopSshPasswordPrompts.DesktopSshPasswordPrompts["Service"], +): SshPasswordPrompt["Service"] => ({ isAvailable: true, request: (request: SshPasswordRequest) => prompts.request(request).pipe( @@ -104,7 +98,7 @@ const makePasswordPrompt = ( ), }); -const make = Effect.gen(function* () { +export const make = Effect.gen(function* () { const manager = yield* SshEnvironmentManager; const prompts = yield* DesktopSshPasswordPrompts.DesktopSshPasswordPrompts; const runtimeContext = yield* Effect.context(); diff --git a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts index 1d50f9ca325..d44d9dbc35e 100644 --- a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts +++ b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts @@ -3,7 +3,6 @@ import { DesktopSshPasswordPromptResolutionInputSchema } from "@t3tools/contract import type { SshPasswordRequest } from "@t3tools/ssh/auth"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; -import * as Data from "effect/Data"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; @@ -11,84 +10,101 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; import * as IpcChannels from "../ipc/channels.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; const DEFAULT_SSH_PASSWORD_PROMPT_TIMEOUT_MS = 3 * 60 * 1000; -const WINDOW_UNAVAILABLE_MESSAGE = "T3 Code window is not available for SSH authentication."; type DesktopSshPasswordPromptResolutionInput = typeof DesktopSshPasswordPromptResolutionInputSchema.Type; -export class DesktopSshPromptUnavailableError extends Data.TaggedError( +const windowUnavailableMessage = (destination: string): string => + `T3 Code window is not available for SSH authentication to ${destination}.`; + +export class DesktopSshPromptUnavailableError extends Schema.TaggedErrorClass()( "DesktopSshPromptUnavailableError", -)<{ - readonly reason: string; -}> { - override get message() { - return this.reason; + { + destination: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Secure randomness is unavailable for SSH authentication to ${this.destination}.`; } } -export class DesktopSshPromptWindowUnavailableError extends Data.TaggedError( +export class DesktopSshPromptWindowUnavailableError extends Schema.TaggedErrorClass()( "DesktopSshPromptWindowUnavailableError", -)<{ - readonly destination: string; -}> { - override get message() { - return WINDOW_UNAVAILABLE_MESSAGE; + { + destination: Schema.String, + }, +) { + override get message(): string { + return windowUnavailableMessage(this.destination); } } -export class DesktopSshPromptSendError extends Data.TaggedError("DesktopSshPromptSendError")<{ - readonly requestId: string; - readonly destination: string; - readonly cause: unknown; -}> { - override get message() { - return WINDOW_UNAVAILABLE_MESSAGE; +const isDesktopSshPromptWindowUnavailableError = Schema.is(DesktopSshPromptWindowUnavailableError); + +export class DesktopSshPromptSendError extends Schema.TaggedErrorClass()( + "DesktopSshPromptSendError", + { + requestId: Schema.String, + destination: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to send SSH password prompt ${this.requestId} for ${this.destination}.`; } } -export class DesktopSshPromptTimedOutError extends Data.TaggedError( +export class DesktopSshPromptTimedOutError extends Schema.TaggedErrorClass()( "DesktopSshPromptTimedOutError", -)<{ - readonly requestId: string; - readonly destination: string; -}> { - override get message() { + { + requestId: Schema.String, + destination: Schema.String, + }, +) { + override get message(): string { return `SSH authentication timed out for ${this.destination}.`; } } -export class DesktopSshPromptCancelledError extends Data.TaggedError( +export class DesktopSshPromptCancelledError extends Schema.TaggedErrorClass()( "DesktopSshPromptCancelledError", -)<{ - readonly requestId: string; - readonly destination: string; - readonly reason: string; -}> { - override get message() { + { + requestId: Schema.String, + destination: Schema.String, + reason: Schema.String, + }, +) { + override get message(): string { return this.reason; } } -export class DesktopSshPromptInvalidRequestIdError extends Data.TaggedError( +export class DesktopSshPromptInvalidRequestIdError extends Schema.TaggedErrorClass()( "DesktopSshPromptInvalidRequestIdError", -)<{ - readonly requestId: string; -}> { - override get message() { - return "Invalid SSH password prompt id."; + { + requestId: Schema.String, + }, +) { + override get message(): string { + return `Invalid SSH password prompt id: ${this.requestId}.`; } } -export class DesktopSshPromptExpiredError extends Data.TaggedError("DesktopSshPromptExpiredError")<{ - readonly requestId: string; -}> { - override get message() { - return "SSH password prompt expired. Try connecting again."; +export class DesktopSshPromptExpiredError extends Schema.TaggedErrorClass()( + "DesktopSshPromptExpiredError", + { + requestId: Schema.String, + }, +) { + override get message(): string { + return `SSH password prompt ${this.requestId} expired. Try connecting again.`; } } @@ -107,28 +123,27 @@ export type DesktopSshPasswordPromptError = | DesktopSshPasswordPromptRequestError | DesktopSshPasswordPromptResolveError; -export function isDesktopSshPasswordPromptCancellation( - error: unknown, -): error is DesktopSshPromptCancelledError | DesktopSshPromptTimedOutError { - return ( - error instanceof DesktopSshPromptCancelledError || - error instanceof DesktopSshPromptTimedOutError - ); -} +export const DesktopSshPasswordPromptCancellation = Schema.Union([ + DesktopSshPromptCancelledError, + DesktopSshPromptTimedOutError, +]); +export type DesktopSshPasswordPromptCancellation = typeof DesktopSshPasswordPromptCancellation.Type; -export interface DesktopSshPasswordPromptsShape { - readonly request: ( - request: SshPasswordRequest, - ) => Effect.Effect; - readonly resolve: ( - input: DesktopSshPasswordPromptResolutionInput, - ) => Effect.Effect; - readonly cancelPending: (reason: string) => Effect.Effect; -} +export const isDesktopSshPasswordPromptCancellation = Schema.is( + DesktopSshPasswordPromptCancellation, +); export class DesktopSshPasswordPrompts extends Context.Service< DesktopSshPasswordPrompts, - DesktopSshPasswordPromptsShape + { + readonly request: ( + request: SshPasswordRequest, + ) => Effect.Effect; + readonly resolve: ( + input: DesktopSshPasswordPromptResolutionInput, + ) => Effect.Effect; + readonly cancelPending: (reason: string) => Effect.Effect; + } >()("@t3tools/desktop/ssh/DesktopSshPasswordPrompts") {} interface PendingSshPasswordPrompt { @@ -137,7 +152,7 @@ interface PendingSshPasswordPrompt { readonly deferred: Deferred.Deferred; } -interface LayerOptions { +export interface DesktopSshPasswordPromptsOptions { readonly passwordPromptTimeoutMs?: number; } @@ -161,14 +176,16 @@ const failPending = ( error: DesktopSshPasswordPromptRequestError, ) => Deferred.fail(pending.deferred, error).pipe(Effect.asVoid); -const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* (options: LayerOptions = {}) { +export const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* ( + options: DesktopSshPasswordPromptsOptions = {}, +) { const electronWindow = yield* ElectronWindow.ElectronWindow; const crypto = yield* Crypto.Crypto; const pendingRef = yield* Ref.make(new Map()); const passwordPromptTimeoutMs = options.passwordPromptTimeoutMs ?? DEFAULT_SSH_PASSWORD_PROMPT_TIMEOUT_MS; - const cancelPending = (reason: string): Effect.Effect => + const cancelPending: DesktopSshPasswordPrompts["Service"]["cancelPending"] = (reason) => Ref.getAndSet(pendingRef, new Map()).pipe( Effect.flatMap((pending) => Effect.forEach( @@ -192,9 +209,9 @@ const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* (options: La cancelPending("SSH password prompt service stopped.").pipe(Effect.ignore), ); - const resolve = Effect.fn("desktop.sshPasswordPrompts.resolve")(function* ( - input: DesktopSshPasswordPromptResolutionInput, - ): Effect.fn.Return { + const resolve: DesktopSshPasswordPrompts["Service"]["resolve"] = Effect.fn( + "desktop.sshPasswordPrompts.resolve", + )(function* (input) { const requestId = input.requestId.trim(); if (requestId.length === 0) { return yield* new DesktopSshPromptInvalidRequestIdError({ requestId: input.requestId }); @@ -221,9 +238,9 @@ const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* (options: La yield* Deferred.succeed(entry.deferred, input.password).pipe(Effect.asVoid); }); - const request = Effect.fn("desktop.sshPasswordPrompts.request")(function* ( - input: SshPasswordRequest, - ): Effect.fn.Return { + const request: DesktopSshPasswordPrompts["Service"]["request"] = Effect.fn( + "desktop.sshPasswordPrompts.request", + )(function* (input) { const window = yield* electronWindow.main; if (Option.isNone(window) || window.value.isDestroyed()) { return yield* new DesktopSshPromptWindowUnavailableError({ @@ -233,7 +250,11 @@ const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* (options: La const requestId = yield* crypto.randomUUIDv4.pipe( Effect.mapError( - () => new DesktopSshPromptUnavailableError({ reason: "Secure randomness is unavailable." }), + (cause) => + new DesktopSshPromptUnavailableError({ + destination: input.destination, + cause, + }), ), ); const now = yield* DateTime.now; @@ -302,27 +323,35 @@ const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* (options: La return yield* Effect.try({ try: () => { if (window.value.isDestroyed()) { - throw new Error(WINDOW_UNAVAILABLE_MESSAGE); + throw new DesktopSshPromptWindowUnavailableError({ + destination: input.destination, + }); } window.value.once("closed", cancelOnWindowClosed); window.value.webContents.send(IpcChannels.SSH_PASSWORD_PROMPT_CHANNEL, promptRequest); if (window.value.isDestroyed()) { - throw new Error(WINDOW_UNAVAILABLE_MESSAGE); + throw new DesktopSshPromptWindowUnavailableError({ + destination: input.destination, + }); } if (window.value.isMinimized()) { window.value.restore(); } if (window.value.isDestroyed()) { - throw new Error(WINDOW_UNAVAILABLE_MESSAGE); + throw new DesktopSshPromptWindowUnavailableError({ + destination: input.destination, + }); } window.value.focus(); }, catch: (cause) => - new DesktopSshPromptSendError({ - requestId, - destination: input.destination, - cause, - }), + isDesktopSshPromptWindowUnavailableError(cause) + ? cause + : new DesktopSshPromptSendError({ + requestId, + destination: input.destination, + cause, + }), }).pipe(Effect.andThen(waitForPassword), Effect.ensuring(cleanup)); }); @@ -333,5 +362,5 @@ const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* (options: La }); }); -export const layer = (options: LayerOptions = {}) => +export const layer = (options: DesktopSshPasswordPromptsOptions = {}) => Layer.effect(DesktopSshPasswordPrompts, make(options)); From a6dec0edb908d428f2cbc6bfc4899c28caedaf83 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 19 Jun 2026 22:14:26 -0700 Subject: [PATCH 2/3] Use named IPC channel import Co-authored-by: codex --- .../src/ssh/DesktopSshEnvironment.test.ts | 1 - apps/desktop/src/ssh/DesktopSshEnvironment.ts | 20 ++--- .../src/ssh/DesktopSshPasswordPrompts.ts | 81 ++++++++++++------- 3 files changed, 60 insertions(+), 42 deletions(-) diff --git a/apps/desktop/src/ssh/DesktopSshEnvironment.test.ts b/apps/desktop/src/ssh/DesktopSshEnvironment.test.ts index 77c86be39d2..f8289fbb6ca 100644 --- a/apps/desktop/src/ssh/DesktopSshEnvironment.test.ts +++ b/apps/desktop/src/ssh/DesktopSshEnvironment.test.ts @@ -104,7 +104,6 @@ describe("sshEnvironment", () => { Layer.succeed(DesktopSshPasswordPrompts.DesktopSshPasswordPrompts, { request: () => Effect.die("unexpected password prompt request"), resolve: () => Effect.die("unexpected password prompt resolution"), - cancelPending: () => Effect.void, }), ), Layer.provideMerge(NodeServices.layer), diff --git a/apps/desktop/src/ssh/DesktopSshEnvironment.ts b/apps/desktop/src/ssh/DesktopSshEnvironment.ts index 614c5ea0e6b..77d9dd4916c 100644 --- a/apps/desktop/src/ssh/DesktopSshEnvironment.ts +++ b/apps/desktop/src/ssh/DesktopSshEnvironment.ts @@ -4,7 +4,7 @@ import type { DesktopSshEnvironmentTarget, } from "@t3tools/contracts"; import * as NetService from "@t3tools/shared/Net"; -import { SshPasswordPrompt, type SshPasswordRequest } from "@t3tools/ssh/auth"; +import * as SshAuth from "@t3tools/ssh/auth"; import { discoverSshHosts } from "@t3tools/ssh/config"; import { SshCommandError, @@ -15,7 +15,7 @@ import { SshPasswordPromptError, SshReadinessError, } from "@t3tools/ssh/errors"; -import { SshEnvironmentManager, type RemoteT3RunnerOptions } from "@t3tools/ssh/tunnel"; +import * as SshTunnel from "@t3tools/ssh/tunnel"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -66,7 +66,7 @@ export class DesktopSshEnvironment extends Context.Service< export interface DesktopSshEnvironmentLayerOptions { readonly resolveCliPackageSpec?: () => string; - readonly resolveCliRunner?: Effect.Effect; + readonly resolveCliRunner?: Effect.Effect; } function discoverDesktopSshHostsEffect(input?: { readonly homeDir?: string }) { @@ -84,9 +84,9 @@ export function isDesktopSshPasswordPromptCancellation( const makePasswordPrompt = ( prompts: DesktopSshPasswordPrompts.DesktopSshPasswordPrompts["Service"], -): SshPasswordPrompt["Service"] => ({ +): SshAuth.SshPasswordPrompt["Service"] => ({ isAvailable: true, - request: (request: SshPasswordRequest) => + request: (request: SshAuth.SshPasswordRequest) => prompts.request(request).pipe( Effect.mapError( (cause) => @@ -99,10 +99,10 @@ const makePasswordPrompt = ( }); export const make = Effect.gen(function* () { - const manager = yield* SshEnvironmentManager; + const manager = yield* SshTunnel.SshEnvironmentManager; const prompts = yield* DesktopSshPasswordPrompts.DesktopSshPasswordPrompts; const runtimeContext = yield* Effect.context(); - const passwordPrompt = SshPasswordPrompt.of(makePasswordPrompt(prompts)); + const passwordPrompt = SshAuth.SshPasswordPrompt.of(makePasswordPrompt(prompts)); return DesktopSshEnvironment.of({ discoverHosts: (input) => @@ -114,7 +114,7 @@ export const make = Effect.gen(function* () { manager .ensureEnvironment(target, ensureOptions) .pipe( - Effect.provideService(SshPasswordPrompt, passwordPrompt), + Effect.provideService(SshAuth.SshPasswordPrompt, passwordPrompt), Effect.provide(runtimeContext), Effect.withSpan("desktop.ssh.ensureEnvironment"), ), @@ -122,7 +122,7 @@ export const make = Effect.gen(function* () { manager .disconnectEnvironment(target) .pipe( - Effect.provideService(SshPasswordPrompt, passwordPrompt), + Effect.provideService(SshAuth.SshPasswordPrompt, passwordPrompt), Effect.provide(runtimeContext), Effect.withSpan("desktop.ssh.disconnectEnvironment"), ), @@ -132,7 +132,7 @@ export const make = Effect.gen(function* () { export const layer = (options: DesktopSshEnvironmentLayerOptions = {}) => Layer.effect(DesktopSshEnvironment, make).pipe( Layer.provide( - SshEnvironmentManager.layer({ + SshTunnel.SshEnvironmentManager.layer({ ...(options.resolveCliPackageSpec === undefined ? {} : { resolveCliPackageSpec: options.resolveCliPackageSpec }), diff --git a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts index d44d9dbc35e..a009a8f6bca 100644 --- a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts +++ b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts @@ -12,26 +12,25 @@ import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; -import * as IpcChannels from "../ipc/channels.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; +import { SSH_PASSWORD_PROMPT_CHANNEL } from "../ipc/channels.ts"; const DEFAULT_SSH_PASSWORD_PROMPT_TIMEOUT_MS = 3 * 60 * 1000; type DesktopSshPasswordPromptResolutionInput = typeof DesktopSshPasswordPromptResolutionInputSchema.Type; -const windowUnavailableMessage = (destination: string): string => - `T3 Code window is not available for SSH authentication to ${destination}.`; +const WINDOW_UNAVAILABLE_MESSAGE = "T3 Code window is not available for SSH authentication."; -export class DesktopSshPromptUnavailableError extends Schema.TaggedErrorClass()( - "DesktopSshPromptUnavailableError", +export class DesktopSshPromptRequestIdGenerationError extends Schema.TaggedErrorClass()( + "DesktopSshPromptRequestIdGenerationError", { destination: Schema.String, cause: Schema.Defect(), }, ) { override get message(): string { - return `Secure randomness is unavailable for SSH authentication to ${this.destination}.`; + return "Secure randomness is unavailable."; } } @@ -42,14 +41,14 @@ export class DesktopSshPromptWindowUnavailableError extends Schema.TaggedErrorCl }, ) { override get message(): string { - return windowUnavailableMessage(this.destination); + return WINDOW_UNAVAILABLE_MESSAGE; } } const isDesktopSshPromptWindowUnavailableError = Schema.is(DesktopSshPromptWindowUnavailableError); -export class DesktopSshPromptSendError extends Schema.TaggedErrorClass()( - "DesktopSshPromptSendError", +export class DesktopSshPromptPresentationError extends Schema.TaggedErrorClass()( + "DesktopSshPromptPresentationError", { requestId: Schema.String, destination: Schema.String, @@ -57,7 +56,7 @@ export class DesktopSshPromptSendError extends Schema.TaggedErrorClass()( + "DesktopSshPromptWindowClosedError", + { + requestId: Schema.String, + destination: Schema.String, + }, +) { + override get message(): string { + return "SSH authentication was cancelled because the app window closed."; + } +} + +export class DesktopSshPromptServiceStoppedError extends Schema.TaggedErrorClass()( + "DesktopSshPromptServiceStoppedError", + { + requestId: Schema.String, + destination: Schema.String, + }, +) { + override get message(): string { + return "SSH password prompt service stopped."; } } @@ -93,7 +115,7 @@ export class DesktopSshPromptInvalidRequestIdError extends Schema.TaggedErrorCla }, ) { override get message(): string { - return `Invalid SSH password prompt id: ${this.requestId}.`; + return "Invalid SSH password prompt id."; } } @@ -104,16 +126,18 @@ export class DesktopSshPromptExpiredError extends Schema.TaggedErrorClass Effect.Effect; - readonly cancelPending: (reason: string) => Effect.Effect; } >()("@t3tools/desktop/ssh/DesktopSshPasswordPrompts") {} @@ -185,7 +210,7 @@ export const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* ( const passwordPromptTimeoutMs = options.passwordPromptTimeoutMs ?? DEFAULT_SSH_PASSWORD_PROMPT_TIMEOUT_MS; - const cancelPending: DesktopSshPasswordPrompts["Service"]["cancelPending"] = (reason) => + const cancelPending = () => Ref.getAndSet(pendingRef, new Map()).pipe( Effect.flatMap((pending) => Effect.forEach( @@ -193,10 +218,9 @@ export const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* ( (entry) => failPending( entry, - new DesktopSshPromptCancelledError({ + new DesktopSshPromptServiceStoppedError({ requestId: entry.requestId, destination: entry.destination, - reason, }), ), { discard: true }, @@ -205,9 +229,7 @@ export const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* ( Effect.asVoid, ); - yield* Effect.addFinalizer(() => - cancelPending("SSH password prompt service stopped.").pipe(Effect.ignore), - ); + yield* Effect.addFinalizer(() => cancelPending().pipe(Effect.ignore)); const resolve: DesktopSshPasswordPrompts["Service"]["resolve"] = Effect.fn( "desktop.sshPasswordPrompts.resolve", @@ -229,7 +251,6 @@ export const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* ( new DesktopSshPromptCancelledError({ requestId, destination: entry.destination, - reason: `SSH authentication cancelled for ${entry.destination}.`, }), ); return; @@ -251,7 +272,7 @@ export const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* ( const requestId = yield* crypto.randomUUIDv4.pipe( Effect.mapError( (cause) => - new DesktopSshPromptUnavailableError({ + new DesktopSshPromptRequestIdGenerationError({ destination: input.destination, cause, }), @@ -288,10 +309,9 @@ export const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* ( onSome: (pending) => failPending( pending, - new DesktopSshPromptCancelledError({ + new DesktopSshPromptWindowClosedError({ requestId, destination: input.destination, - reason: "SSH authentication was cancelled because the app window closed.", }), ), }), @@ -328,7 +348,7 @@ export const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* ( }); } window.value.once("closed", cancelOnWindowClosed); - window.value.webContents.send(IpcChannels.SSH_PASSWORD_PROMPT_CHANNEL, promptRequest); + window.value.webContents.send(SSH_PASSWORD_PROMPT_CHANNEL, promptRequest); if (window.value.isDestroyed()) { throw new DesktopSshPromptWindowUnavailableError({ destination: input.destination, @@ -347,7 +367,7 @@ export const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* ( catch: (cause) => isDesktopSshPromptWindowUnavailableError(cause) ? cause - : new DesktopSshPromptSendError({ + : new DesktopSshPromptPresentationError({ requestId, destination: input.destination, cause, @@ -358,7 +378,6 @@ export const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* ( return DesktopSshPasswordPrompts.of({ request, resolve, - cancelPending, }); }); From 1a291d8ac5be4141c15e2ebe7a9c983a362ebcb2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 00:25:52 -0700 Subject: [PATCH 3/3] Clarify SSH prompt presentation errors Co-authored-by: codex --- .../src/ssh/DesktopSshEnvironment.test.ts | 14 ++++++ apps/desktop/src/ssh/DesktopSshEnvironment.ts | 44 +++++++++++++++---- .../src/ssh/DesktopSshPasswordPrompts.ts | 2 +- 3 files changed, 50 insertions(+), 10 deletions(-) diff --git a/apps/desktop/src/ssh/DesktopSshEnvironment.test.ts b/apps/desktop/src/ssh/DesktopSshEnvironment.test.ts index f8289fbb6ca..baed2610286 100644 --- a/apps/desktop/src/ssh/DesktopSshEnvironment.test.ts +++ b/apps/desktop/src/ssh/DesktopSshEnvironment.test.ts @@ -19,6 +19,20 @@ function makeTempHomeDir() { } describe("sshEnvironment", () => { + it("keeps prompt presentation diagnostics distinct from the legacy wrapper message", () => { + const cause = new DesktopSshPasswordPrompts.DesktopSshPromptPresentationError({ + requestId: "prompt-1", + destination: "devbox", + cause: new Error("renderer send failed"), + }); + + assert.equal(cause.message, "Failed to present SSH password prompt for devbox."); + assert.equal( + DesktopSshEnvironment.toSshPasswordPromptError(cause).message, + "T3 Code window is not available for SSH authentication.", + ); + }); + it("treats password prompt timeouts as cancellable authentication prompts", () => { assert.equal( DesktopSshEnvironment.isDesktopSshPasswordPromptCancellation( diff --git a/apps/desktop/src/ssh/DesktopSshEnvironment.ts b/apps/desktop/src/ssh/DesktopSshEnvironment.ts index 77d9dd4916c..31e84ae995e 100644 --- a/apps/desktop/src/ssh/DesktopSshEnvironment.ts +++ b/apps/desktop/src/ssh/DesktopSshEnvironment.ts @@ -82,20 +82,46 @@ export function isDesktopSshPasswordPromptCancellation( ); } +function unexpectedPasswordPromptError(error: never): never { + throw new Error(`Unhandled desktop SSH password prompt error: ${String(error)}`); +} + +export function toSshPasswordPromptError( + cause: DesktopSshPasswordPrompts.DesktopSshPasswordPromptRequestError, +): SshPasswordPromptError { + let message: string; + switch (cause._tag) { + case "DesktopSshPromptRequestIdGenerationError": + message = "Secure randomness is unavailable."; + break; + case "DesktopSshPromptWindowUnavailableError": + case "DesktopSshPromptPresentationError": + message = "T3 Code window is not available for SSH authentication."; + break; + case "DesktopSshPromptTimedOutError": + message = `SSH authentication timed out for ${cause.destination}.`; + break; + case "DesktopSshPromptCancelledError": + message = `SSH authentication cancelled for ${cause.destination}.`; + break; + case "DesktopSshPromptWindowClosedError": + message = "SSH authentication was cancelled because the app window closed."; + break; + case "DesktopSshPromptServiceStoppedError": + message = "SSH password prompt service stopped."; + break; + default: + return unexpectedPasswordPromptError(cause); + } + return new SshPasswordPromptError({ message, cause }); +} + const makePasswordPrompt = ( prompts: DesktopSshPasswordPrompts.DesktopSshPasswordPrompts["Service"], ): SshAuth.SshPasswordPrompt["Service"] => ({ isAvailable: true, request: (request: SshAuth.SshPasswordRequest) => - prompts.request(request).pipe( - Effect.mapError( - (cause) => - new SshPasswordPromptError({ - message: cause.message, - cause, - }), - ), - ), + prompts.request(request).pipe(Effect.mapError(toSshPasswordPromptError)), }); export const make = Effect.gen(function* () { diff --git a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts index a009a8f6bca..c933bca3cb0 100644 --- a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts +++ b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts @@ -56,7 +56,7 @@ export class DesktopSshPromptPresentationError extends Schema.TaggedErrorClass