Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/desktop/src/ssh/DesktopSshEnvironment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ describe("sshEnvironment", () => {
const cause = new DesktopSshPasswordPrompts.DesktopSshPromptPresentationError({
requestId: "prompt-1",
destination: "devbox",
operation: "send-prompt-request",
cause: new Error("renderer send failed"),
});

Expand Down
67 changes: 65 additions & 2 deletions apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ interface SentMessage {
readonly args: readonly unknown[];
}

function makeTestWindow() {
function makeTestWindow(
options: { readonly isDestroyedError?: unknown; readonly sendError?: unknown } = {},
) {
const listeners = new Map<string, Set<() => void>>();
const sentMessages: SentMessage[] = [];
let destroyed = false;
Expand All @@ -26,7 +28,12 @@ function makeTestWindow() {
let focused = false;

const window = {
isDestroyed: () => destroyed,
isDestroyed: () => {
if (options.isDestroyedError !== undefined) {
throw options.isDestroyedError;
}
return destroyed;
},
isMinimized: () => minimized,
restore: () => {
restored = true;
Expand All @@ -46,6 +53,9 @@ function makeTestWindow() {
webContents: {
send: (channel: string, ...args: readonly unknown[]) => {
sentMessages.push({ channel, args });
if (options.sendError !== undefined) {
throw options.sendError;
}
},
},
};
Expand All @@ -55,6 +65,7 @@ function makeTestWindow() {
sentMessages,
isRestored: () => restored,
isFocused: () => focused,
closedListenerCount: () => listeners.get("closed")?.size ?? 0,
close: () => {
destroyed = true;
const closedListeners = [...(listeners.get("closed") ?? [])];
Expand Down Expand Up @@ -143,4 +154,56 @@ describe("DesktopSshPasswordPrompts", () => {
assert.equal(error.destination, "devbox");
}).pipe(Effect.provide(makeLayer(testWindow.window)), Effect.scoped);
});

it.effect("cleans up a prompt that fails during renderer delivery", () => {
const cause = new Error("renderer unavailable");
const testWindow = makeTestWindow({ sendError: cause });

return Effect.gen(function* () {
const prompts = yield* DesktopSshPasswordPrompts.DesktopSshPasswordPrompts;
const error = yield* prompts
.request({
destination: "devbox",
username: "julius",
prompt: "Enter the SSH password.",
attempt: 1,
})
.pipe(Effect.flip);

assert.instanceOf(error, DesktopSshPasswordPrompts.DesktopSshPromptPresentationError);
assert.equal(error.operation, "send-prompt-request");
assert.equal(error.destination, "devbox");
const requestId = error.requestId;
if (requestId === null) {
assert.fail("renderer delivery failures must retain their request id");
}
assert.equal(testWindow.closedListenerCount(), 0);

const resolveError = yield* prompts
.resolve({ requestId, password: "secret" })
.pipe(Effect.flip);
assert.instanceOf(resolveError, DesktopSshPasswordPrompts.DesktopSshPromptExpiredError);
}).pipe(Effect.provide(makeLayer(testWindow.window)), Effect.scoped);
});

it.effect("classifies a failed initial window availability check", () => {
const testWindow = makeTestWindow({ isDestroyedError: new Error("window unavailable") });

return Effect.gen(function* () {
const prompts = yield* DesktopSshPasswordPrompts.DesktopSshPasswordPrompts;
const error = yield* prompts
.request({
destination: "devbox",
username: "julius",
prompt: "Enter the SSH password.",
attempt: 1,
})
.pipe(Effect.flip);

assert.instanceOf(error, DesktopSshPasswordPrompts.DesktopSshPromptPresentationError);
assert.equal(error.operation, "check-window-before-request");
assert.equal(error.requestId, null);
assert.deepEqual(testWindow.sentMessages, []);
}).pipe(Effect.provide(makeLayer(testWindow.window)), Effect.scoped);
});
});
152 changes: 111 additions & 41 deletions apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,26 @@ const DEFAULT_SSH_PASSWORD_PROMPT_TIMEOUT_MS = 3 * 60 * 1000;
type DesktopSshPasswordPromptResolutionInput =
typeof DesktopSshPasswordPromptResolutionInputSchema.Type;

const WINDOW_UNAVAILABLE_MESSAGE = "T3 Code window is not available for SSH authentication.";
const DesktopSshPromptWindowAvailabilityStage = Schema.Literals([
"before-request",
"before-presentation",
"after-send",
"after-restore",
]);

const DesktopSshPromptPresentationOperation = Schema.Literals([
"check-window-before-request",
"check-window-before-presentation",
"register-window-close-listener",
"send-prompt-request",
"check-window-after-send",
"check-window-minimized",
"restore-window",
"check-window-after-restore",
"focus-window",
"remove-window-close-listener",
]);
type DesktopSshPromptPresentationOperation = typeof DesktopSshPromptPresentationOperation.Type;

export class DesktopSshPromptRequestIdGenerationError extends Schema.TaggedErrorClass<DesktopSshPromptRequestIdGenerationError>()(
"DesktopSshPromptRequestIdGenerationError",
Expand All @@ -38,20 +57,22 @@ export class DesktopSshPromptWindowUnavailableError extends Schema.TaggedErrorCl
"DesktopSshPromptWindowUnavailableError",
{
destination: Schema.String,
requestId: Schema.NullOr(Schema.String),
stage: DesktopSshPromptWindowAvailabilityStage,
},
) {
override get message(): string {
return WINDOW_UNAVAILABLE_MESSAGE;
const request = this.requestId === null ? "before a request id was assigned" : this.requestId;
return `T3 Code window is unavailable during ${this.stage} for SSH authentication to ${this.destination} (request: ${request}).`;
}
}

const isDesktopSshPromptWindowUnavailableError = Schema.is(DesktopSshPromptWindowUnavailableError);

export class DesktopSshPromptPresentationError extends Schema.TaggedErrorClass<DesktopSshPromptPresentationError>()(
"DesktopSshPromptPresentationError",
{
requestId: Schema.String,
requestId: Schema.NullOr(Schema.String),
destination: Schema.String,
operation: DesktopSshPromptPresentationOperation,
cause: Schema.Defect(),
},
) {
Expand Down Expand Up @@ -263,9 +284,29 @@ export const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* (
"desktop.sshPasswordPrompts.request",
)(function* (input) {
const window = yield* electronWindow.main;
if (Option.isNone(window) || window.value.isDestroyed()) {
if (Option.isNone(window)) {
return yield* new DesktopSshPromptWindowUnavailableError({
destination: input.destination,
requestId: null,
stage: "before-request",
});
}

const unavailableBeforeRequest = yield* Effect.try({
try: () => window.value.isDestroyed(),
catch: (cause) =>
new DesktopSshPromptPresentationError({
requestId: null,
destination: input.destination,
operation: "check-window-before-request",
cause,
}),
});
if (unavailableBeforeRequest) {
return yield* new DesktopSshPromptWindowUnavailableError({
destination: input.destination,
requestId: null,
stage: "before-request",
});
}

Expand Down Expand Up @@ -319,11 +360,25 @@ export const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* (
),
);
};
const cleanup = Effect.sync(() => {
const runPresentationOperation = <A>(
operation: DesktopSshPromptPresentationOperation,
evaluate: () => A,
) =>
Effect.try({
try: evaluate,
catch: (cause) =>
new DesktopSshPromptPresentationError({
requestId,
destination: input.destination,
operation,
cause,
}),
});
const cleanup = runPresentationOperation("remove-window-close-listener", () => {
if (!window.value.isDestroyed()) {
window.value.removeListener("closed", cancelOnWindowClosed);
}
}).pipe(Effect.andThen(removePending(pendingRef, requestId)), Effect.asVoid);
}).pipe(Effect.orDie, Effect.ensuring(removePending(pendingRef, requestId)), Effect.asVoid);
const waitForPassword = Deferred.await(deferred).pipe(
Effect.timeoutOption(Duration.millis(passwordPromptTimeoutMs)),
Effect.flatMap(
Expand All @@ -340,39 +395,54 @@ export const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* (
),
);

return yield* Effect.try({
try: () => {
if (window.value.isDestroyed()) {
throw new DesktopSshPromptWindowUnavailableError({
destination: input.destination,
});
}
window.value.once("closed", cancelOnWindowClosed);
window.value.webContents.send(SSH_PASSWORD_PROMPT_CHANNEL, promptRequest);
if (window.value.isDestroyed()) {
throw new DesktopSshPromptWindowUnavailableError({
destination: input.destination,
});
}
if (window.value.isMinimized()) {
window.value.restore();
}
if (window.value.isDestroyed()) {
throw new DesktopSshPromptWindowUnavailableError({
destination: input.destination,
});
}
window.value.focus();
},
catch: (cause) =>
isDesktopSshPromptWindowUnavailableError(cause)
? cause
: new DesktopSshPromptPresentationError({
requestId,
destination: input.destination,
cause,
}),
}).pipe(Effect.andThen(waitForPassword), Effect.ensuring(cleanup));
return yield* Effect.gen(function* () {
const unavailableBeforePresentation = yield* runPresentationOperation(
"check-window-before-presentation",
() => window.value.isDestroyed(),
);
if (unavailableBeforePresentation) {
return yield* new DesktopSshPromptWindowUnavailableError({
destination: input.destination,
requestId,
stage: "before-presentation",
});
}
yield* runPresentationOperation("register-window-close-listener", () =>
window.value.once("closed", cancelOnWindowClosed),
);
yield* runPresentationOperation("send-prompt-request", () =>
window.value.webContents.send(SSH_PASSWORD_PROMPT_CHANNEL, promptRequest),
);
const unavailableAfterSend = yield* runPresentationOperation("check-window-after-send", () =>
window.value.isDestroyed(),
);
if (unavailableAfterSend) {
return yield* new DesktopSshPromptWindowUnavailableError({
destination: input.destination,
requestId,
stage: "after-send",
});
}
const minimized = yield* runPresentationOperation("check-window-minimized", () =>
window.value.isMinimized(),
);
if (minimized) {
yield* runPresentationOperation("restore-window", () => window.value.restore());
}
const unavailableAfterRestore = yield* runPresentationOperation(
"check-window-after-restore",
() => window.value.isDestroyed(),
);
if (unavailableAfterRestore) {
return yield* new DesktopSshPromptWindowUnavailableError({
destination: input.destination,
requestId,
stage: "after-restore",
});
}
yield* runPresentationOperation("focus-window", () => window.value.focus());
return yield* waitForPassword;
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
}).pipe(Effect.ensuring(cleanup));
});

return DesktopSshPasswordPrompts.of({
Expand Down
Loading