diff --git a/apps/web/src/components/desktop/SshPasswordPromptDialog.test.tsx b/apps/web/src/components/desktop/SshPasswordPromptDialog.test.tsx new file mode 100644 index 000000000000..ef70ba34c367 --- /dev/null +++ b/apps/web/src/components/desktop/SshPasswordPromptDialog.test.tsx @@ -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(); + }); + }); + + 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.", + ); + }); +}); diff --git a/apps/web/src/components/desktop/SshPasswordPromptDialog.tsx b/apps/web/src/components/desktop/SshPasswordPromptDialog.tsx index a6ff9d8c4ec6..6f79e4e55378 100644 --- a/apps/web/src/components/desktop/SshPasswordPromptDialog.tsx +++ b/apps/web/src/components/desktop/SshPasswordPromptDialog.tsx @@ -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; } @@ -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) => { @@ -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; } @@ -158,10 +158,11 @@ function ActiveSshPasswordPrompt({ > - SSH Password Required + SSH verification required - T3 needs your SSH password to connect to {target}. 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 {target}. Your response + is passed to the local SSH process for this connection attempt and is not saved by T3 + Code. @@ -202,7 +203,8 @@ function ActiveSshPasswordPrompt({

{visibleResponseError}

) : (

- 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.

)} diff --git a/packages/ssh/src/tunnel.test.ts b/packages/ssh/src/tunnel.test.ts index e2536ba92017..25d0a7c16e1c 100644 --- a/packages/ssh/src/tunnel.test.ts +++ b/packages/ssh/src/tunnel.test.ts @@ -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) => { diff --git a/packages/ssh/src/tunnel.ts b/packages/ssh/src/tunnel.ts index 9cb6b25e4121..021cf78f8206 100644 --- a/packages/ssh/src/tunnel.ts +++ b/packages/ssh/src/tunnel.ts @@ -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", {