Skip to content
Open
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
93 changes: 93 additions & 0 deletions apps/web/src/components/desktop/SshPasswordPromptDialog.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import type { DesktopSshPasswordPromptRequest } from "@t3tools/contracts";
import { act } from "react";
import type { ReactNode } from "react";
import { create } from "react-test-renderer";
import type { ReactTestRenderer, ReactTestRendererJSON } from "react-test-renderer";
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";

// Base UI owns browser portals and focus management; use host elements in the
// non-DOM renderer while exercising the real prompt state and user-facing copy.
vi.mock("@base-ui/react/dialog", () => ({
Dialog: {
createHandle: () => ({}),
Root: ({ children }: { children: ReactNode }) => children,
Portal: ({ children }: { children: ReactNode }) => children,
Backdrop: "div",
Viewport: "div",
Popup: "section",
Title: "h2",
Description: "p",
},
}));

import { SshPasswordPromptDialog } from "./SshPasswordPromptDialog";

function visibleText(
node: ReactTestRendererJSON | ReactTestRendererJSON[] | string | null,
): string {
if (typeof node === "string") return node;
if (node === null) return "";
return (Array.isArray(node) ? node : (node.children ?? [])).map(visibleText).join("");
}

describe("SshPasswordPromptDialog verification copy", () => {
let renderer: ReactTestRenderer;
let receivePrompt: (request: DesktopSshPasswordPromptRequest) => void;

beforeEach(async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-09-09T00:00:00Z"));
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.stubGlobal("window", {
requestAnimationFrame: () => 0,
cancelAnimationFrame: () => undefined,
setInterval,
clearInterval,
desktopBridge: {
onSshPasswordPrompt: (listener: typeof receivePrompt) => {
receivePrompt = listener;
return () => undefined;
},
},
});
await act(async () => {
renderer = create(<SshPasswordPromptDialog />);
});
});

afterEach(async () => {
await act(async () => renderer.unmount());
vi.useRealTimers();
vi.unstubAllGlobals();
});

async function openPrompt() {
await act(async () => {
receivePrompt({
requestId: "ssh-verification-test",
destination: "devbox",
username: "julius",
prompt: "Enter the SSH password or verification code for julius@devbox.",
expiresAt: "2026-09-09T00:01:00Z",
});
});
}

it("explains password and verification code authentication without promising keys bypass 2FA", async () => {
await openPrompt();
const text = visibleText(renderer.toJSON());
expect(text).toContain("SSH verification required");
expect(text).toContain("Enter the password or verification code required by julius@devbox.");
expect(text).toContain(
"SSH keys may replace a password, but your server can still require a verification code.",
);
});

it("describes an expired verification request without calling it a password prompt", async () => {
await openPrompt();
await act(async () => vi.advanceTimersByTime(60_000));
expect(visibleText(renderer.toJSON())).toContain(
"This SSH verification prompt expired. Try connecting again.",
);
});
});
18 changes: 10 additions & 8 deletions apps/web/src/components/desktop/SshPasswordPromptDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,9 @@ function formatRemainingSeconds(seconds: number): string {
}

function getPromptErrorMessage(error: unknown): string {
const message = error instanceof Error ? error.message : "SSH password prompt failed.";
const message = error instanceof Error ? error.message : "SSH verification prompt failed.";
return message.includes("expired") || message.includes("no longer pending")
? "This SSH password prompt expired. Try connecting again."
? "This SSH verification prompt expired. Try connecting again."
: message;
}

Expand Down Expand Up @@ -101,7 +101,7 @@ function ActiveSshPasswordPrompt({
const remainingLabel =
remainingSeconds === null ? null : formatRemainingSeconds(remainingSeconds);
const visibleResponseError = isExpired
? "This SSH password prompt expired. Try connecting again."
? "This SSH verification prompt expired. Try connecting again."
: responseError;

const respond = async (nextPassword: string | null) => {
Expand All @@ -111,7 +111,7 @@ function ActiveSshPasswordPrompt({

const requestId = request.requestId;
if (nextPassword !== null && isExpired) {
setResponseError("This SSH password prompt expired. Try connecting again.");
setResponseError("This SSH verification prompt expired. Try connecting again.");
return;
}

Expand Down Expand Up @@ -158,10 +158,11 @@ function ActiveSshPasswordPrompt({
>
<DialogPopup className="max-w-md" showCloseButton={false}>
<DialogHeader>
<DialogTitle>SSH Password Required</DialogTitle>
<DialogTitle>SSH verification required</DialogTitle>
<DialogDescription>
T3 needs your SSH password to connect to <code>{target}</code>. The password is passed
to the local SSH process for this connection attempt and is not saved by T3 Code.
Enter the password or verification code required by <code>{target}</code>. Your response
is passed to the local SSH process for this connection attempt and is not saved by T3
Code.
</DialogDescription>
</DialogHeader>
<DialogPanel className="space-y-3" scrollFade={false}>
Expand Down Expand Up @@ -202,7 +203,8 @@ function ActiveSshPasswordPrompt({
<p className="text-sm text-destructive">{visibleResponseError}</p>
) : (
<p className="text-sm text-muted-foreground">
Use SSH keys to avoid repeated password prompts on new SSH sessions.
SSH keys may replace a password, but your server can still require a verification
code.
</p>
)}
</form>
Expand Down
45 changes: 45 additions & 0 deletions packages/ssh/src/tunnel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,51 @@ describe("ssh tunnel scripts", () => {
}).pipe(Effect.provide(processLayer));
});

it.effect("prompts for a password or verification code after SSH authentication fails", () => {
const prompts: string[] = [];
const spawner = ChildProcessSpawner.make((command) =>
Effect.succeed(
commandArgs(command).includes("-G")
? makeSuccessfulProcess("")
: {
...makeSuccessfulProcess(""),
exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(255)),
stderr: Stream.make(
new TextEncoder().encode("Permission denied (publickey,keyboard-interactive).\n"),
),
},
),
);
const layer = Layer.mergeAll(
NodeServices.layer,
Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner),
Layer.succeed(HttpClient.HttpClient, testHttpClient),
Layer.succeed(NetService.NetService, testNetService),
Layer.succeed(SshPasswordPrompt, {
isAvailable: true,
request: (request) =>
Effect.sync(() => {
prompts.push(request.prompt);
return null;
}),
}),
SshEnvironmentManager.layer(),
);

return Effect.gen(function* () {
const manager = yield* SshEnvironmentManager;
yield* Effect.result(
manager.ensureEnvironment({
alias: "devbox",
hostname: "devbox.example.com",
username: "julius",
port: 2222,
}),
);
assert.deepEqual(prompts, ["Enter the SSH password or verification code for julius@devbox."]);
}).pipe(Effect.provide(layer), Effect.scoped);
});

it.effect.each(["successful stop", "failed stop"] as const)(
"closes the tunnel scope and starts fresh after a %s",
(mode) => {
Expand Down
2 changes: 1 addition & 1 deletion packages/ssh/src/tunnel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1239,7 +1239,7 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma
attempt,
destination: target.alias.trim() || target.hostname.trim(),
username: target.username,
prompt: `Enter the SSH password for ${hostSpec}.`,
prompt: `Enter the SSH password or verification code for ${hostSpec}.`,
});
if (password === null) {
yield* Effect.logWarning("ssh.auth.passwordPrompt.cancelled", {
Expand Down
Loading