Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
111 changes: 107 additions & 4 deletions apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,13 @@ interface SentMessage {
readonly args: readonly unknown[];
}

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

const window = {
isDestroyed: () => destroyed,
isMinimized: () => minimized,
isDestroyed: () => {
if (options.isDestroyedError !== undefined) {
throw options.isDestroyedError;
}
return destroyed;
},
isMinimized: () => {
if (options.isMinimizedError !== undefined) {
throw options.isMinimizedError;
}
return minimized;
},
restore: () => {
restored = true;
minimized = false;
Expand All @@ -45,7 +61,11 @@ function makeTestWindow() {
},
webContents: {
send: (channel: string, ...args: readonly unknown[]) => {
sentMessages.push({ channel, args });
const message = { channel, args };
sentMessages.push(message);
if (options.sendError !== undefined) {
throw options.sendError;
}
},
},
};
Expand All @@ -55,6 +75,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 @@ -107,6 +128,7 @@ describe("DesktopSshPasswordPrompts", () => {
})
.pipe(Effect.forkScoped);

yield* Effect.yieldNow;
yield* Effect.yieldNow;
assert.equal(testWindow.sentMessages.length, 1);
const sent = testWindow.sentMessages[0];
Expand Down Expand Up @@ -143,4 +165,85 @@ 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("keeps a submitted password when a later presentation step fails", () => {
const testWindow = makeTestWindow({
isMinimizedError: new Error("failed to read minimized state"),
});

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

yield* Effect.yieldNow;
const sent = testWindow.sentMessages[0];
assert.ok(sent);
const request = sent.args[0] as { readonly requestId: string };
yield* prompts.resolve({ requestId: request.requestId, password: "secret" });
const password = yield* Fiber.join(requestFiber);

assert.equal(password, "secret");
assert.equal(testWindow.isFocused(), false);
assert.equal(testWindow.closedListenerCount(), 0);
}).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);
});
});
156 changes: 122 additions & 34 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 @@ -339,40 +394,73 @@ export const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* (
}),
),
);
const preferSubmittedPassword = (error: DesktopSshPasswordPromptRequestError) =>
Deferred.poll(deferred).pipe(
Effect.flatMap(
Option.match({
onSome: (completion) => completion,
onNone: () =>
Ref.get(pendingRef).pipe(
Effect.flatMap((entries) =>
entries.has(requestId) ? Effect.fail(error) : Deferred.await(deferred),
),
),
}),
),
);

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({
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),
);
return yield* Effect.gen(function* () {
yield* runPresentationOperation("send-prompt-request", () =>
window.value.webContents.send(SSH_PASSWORD_PROMPT_CHANNEL, promptRequest),
);
yield* Effect.yieldNow;
const unavailableAfterSend = yield* runPresentationOperation(
"check-window-after-send",
() => window.value.isDestroyed(),
);
if (unavailableAfterSend) {
return yield* new DesktopSshPromptWindowUnavailableError({
destination: input.destination,
requestId,
stage: "after-send",
});
}
if (window.value.isMinimized()) {
window.value.restore();
const minimized = yield* runPresentationOperation("check-window-minimized", () =>
window.value.isMinimized(),
);
if (minimized) {
yield* runPresentationOperation("restore-window", () => window.value.restore());
}
if (window.value.isDestroyed()) {
throw new DesktopSshPromptWindowUnavailableError({
const unavailableAfterRestore = yield* runPresentationOperation(
"check-window-after-restore",
() => window.value.isDestroyed(),
);
if (unavailableAfterRestore) {
return yield* new DesktopSshPromptWindowUnavailableError({
destination: input.destination,
requestId,
stage: "after-restore",
});
}
window.value.focus();
},
catch: (cause) =>
isDesktopSshPromptWindowUnavailableError(cause)
? cause
: new DesktopSshPromptPresentationError({
requestId,
destination: input.destination,
cause,
}),
}).pipe(Effect.andThen(waitForPassword), Effect.ensuring(cleanup));
yield* runPresentationOperation("focus-window", () => window.value.focus());
return yield* waitForPassword;
}).pipe(Effect.catch(preferSubmittedPassword));
}).pipe(Effect.ensuring(cleanup));
});

return DesktopSshPasswordPrompts.of({
Expand Down
Loading