From 306e516e3b380b47f5eab66268e3529a1f5e9882 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 2 Jul 2026 07:55:15 -0700 Subject: [PATCH] FN-7417: prevent Windows Terminal startup popups Prevent embedded terminal startup from surfacing recurring native Windows Terminal popups. - Keep Windows embedded terminal detection on supported shell processes instead of Windows Terminal hosts. - Convert Windows PTY spawn failures into actionable inline guidance with explicit Retry behavior. - Cover desktop/mobile terminal guidance, bootstrap retry loops, API error payloads, and Windows shell selection with regression tests. - Document Windows embedded terminal behavior and add a patch changeset. Files changed: .changeset/FN-7417-windows-terminal-popup.md | 7 ++ docs/dashboard-guide.md | 2 + .../components/__tests__/TerminalModal.test.tsx | 31 ++++++++ .../hooks/__tests__/useTerminalSessions.test.ts | 26 ++++++- .../dashboard/app/hooks/useTerminalSessions.ts | 18 ++++- .../src/__tests__/routes-automation.test.ts | 31 +++++++- .../src/__tests__/terminal-service.test.ts | 86 ++++++++++++++++++++++ packages/dashboard/src/terminal-service.ts | 32 ++++++-- 8 files changed, 222 insertions(+), 11 deletions(-) Fusion-Task-Id: FN-7417 Fusion-Task-Lineage: edeb7743-c106-47e8-9506-6edaad821cfb Co-authored-by: Fusion (runfusion.ai) --- .changeset/FN-7417-windows-terminal-popup.md | 7 ++ docs/dashboard-guide.md | 2 + .../__tests__/TerminalModal.test.tsx | 31 +++++++ .../__tests__/useTerminalSessions.test.ts | 26 +++++- .../app/hooks/useTerminalSessions.ts | 18 +++- .../src/__tests__/routes-automation.test.ts | 31 ++++++- .../src/__tests__/terminal-service.test.ts | 86 +++++++++++++++++++ packages/dashboard/src/terminal-service.ts | 32 +++++-- 8 files changed, 222 insertions(+), 11 deletions(-) create mode 100644 .changeset/FN-7417-windows-terminal-popup.md diff --git a/.changeset/FN-7417-windows-terminal-popup.md b/.changeset/FN-7417-windows-terminal-popup.md new file mode 100644 index 0000000000..3b9a7fea24 --- /dev/null +++ b/.changeset/FN-7417-windows-terminal-popup.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Stop recurring Windows Terminal warning popups during terminal startup. +category: fix +dev: Keeps embedded terminal bootstrap on supported shells and surfaces actionable inline errors. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index c14c313021..32a7db3446 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -508,6 +508,8 @@ Mailbox view shows inbox/outbox communication threads and unread state. Fusion embeds a terminal using xterm.js. Desktop and tablet use the footer status bar as the terminal launcher; mobile keeps the full-screen terminal path. +On Windows, the embedded terminal starts a supported shell inside Fusion, such as Command Prompt (`cmd.exe`) or Windows PowerShell. Windows Terminal (`wt.exe`) is an external terminal host and is not required or launched for the embedded panel, so Fusion should not show native Windows Terminal help/version popups while starting a terminal. If embedded terminal startup fails, Fusion shows an inline error with **Retry** instead of a blocking native dialog; install or repair Windows Terminal separately with `winget install Microsoft.WindowsTerminal` only if you want to use Windows Terminal outside Fusion. + Use the terminal on desktop/tablet: 1. Select the **Terminal** button in the footer executor status bar. diff --git a/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx b/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx index 4172628a3d..c192558eac 100644 --- a/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx @@ -664,6 +664,37 @@ describe("TerminalModal", () => { expect(refreshBtn.textContent).toContain("Refresh page"); }); + it("shows Windows Terminal startup guidance inline on desktop and mobile without native dialog hooks", async () => { + const mockRetryBootstrap = vi.fn(); + const windowsTerminalMessage = + "Fusion could not start an embedded terminal shell on Windows. Use Command Prompt or PowerShell for the embedded terminal, or install/repair Windows Terminal separately with `winget install Microsoft.WindowsTerminal` if you want Windows Terminal outside Fusion."; + mockUseTerminalSessions.mockReturnValue({ + ...defaultSessionState, + tabs: [], + activeTab: null, + bootstrapError: windowsTerminalMessage, + retryBootstrap: mockRetryBootstrap, + }); + + const { rerender } = render(); + + expect(screen.getByTestId("terminal-bootstrap-error")).toHaveTextContent("Command Prompt or PowerShell"); + expect(screen.getByTestId("terminal-bootstrap-error")).toHaveTextContent("winget install Microsoft.WindowsTerminal"); + expect(screen.getByTestId("terminal-bootstrap-error")).not.toHaveTextContent("1.24.11321.0"); + expect(screen.getByTestId("terminal-retry-btn")).toBeTruthy(); + + const previousInnerWidth = window.innerWidth; + Object.defineProperty(window, "innerWidth", { value: 390, configurable: true }); + try { + rerender(); + expect(screen.getByTestId("terminal-bootstrap-error")).toHaveTextContent("Command Prompt or PowerShell"); + fireEvent.click(screen.getByTestId("terminal-retry-btn")); + expect(mockRetryBootstrap).toHaveBeenCalledTimes(1); + } finally { + Object.defineProperty(window, "innerWidth", { value: previousInnerWidth, configurable: true }); + } + }); + it("retry button calls retryBootstrap from the hook", async () => { const mockRetryBootstrap = vi.fn(); mockUseTerminalSessions.mockReturnValue({ diff --git a/packages/dashboard/app/hooks/__tests__/useTerminalSessions.test.ts b/packages/dashboard/app/hooks/__tests__/useTerminalSessions.test.ts index fafcedefcd..1c8b809d93 100644 --- a/packages/dashboard/app/hooks/__tests__/useTerminalSessions.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useTerminalSessions.test.ts @@ -882,9 +882,33 @@ describe("useTerminalSessions", () => { expect(result.current.bootstrapError).toBe("Server unreachable"); }); - // No tabs should be created + // No tabs should be created, and the failed generation must not auto-loop. expect(result.current.tabs.length).toBe(0); expect(result.current.activeTab).toBeNull(); + expect(mockCreateTerminalSession).toHaveBeenCalledTimes(1); + }); + + it("shows the Windows Terminal version-only failure once until explicit retry", async () => { + localStorageMock.getItem.mockReturnValue(null); + mockListTerminalSessions.mockResolvedValue([]); + const windowsTerminalMessage = + "Fusion could not start an embedded terminal shell on Windows. Use Command Prompt or PowerShell for the embedded terminal, or install/repair Windows Terminal separately with `winget install Microsoft.WindowsTerminal` if you want Windows Terminal outside Fusion."; + mockCreateTerminalSession.mockRejectedValue(new Error(windowsTerminalMessage)); + + const { result, rerender } = renderHook(() => useTerminalSessions(TEST_PROJECT_ID)); + + await waitFor(() => { + expect(result.current.bootstrapError).toBe(windowsTerminalMessage); + }); + + rerender(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(result.current.tabs).toHaveLength(0); + expect(result.current.activeTab).toBeNull(); + expect(result.current.bootstrapError).toBe(windowsTerminalMessage); + expect(result.current.bootstrapError).not.toContain("1.24.11321.0"); + expect(mockCreateTerminalSession).toHaveBeenCalledTimes(1); }); it("sets bootstrapError with fallback message for non-Error throws", async () => { diff --git a/packages/dashboard/app/hooks/useTerminalSessions.ts b/packages/dashboard/app/hooks/useTerminalSessions.ts index c1dc6ee12b..f5565cef1c 100644 --- a/packages/dashboard/app/hooks/useTerminalSessions.ts +++ b/packages/dashboard/app/hooks/useTerminalSessions.ts @@ -146,6 +146,7 @@ export function useTerminalSessions(projectId?: string): UseTerminalSessionsRetu // Ref-based generation token to protect against stale completions from prior // bootstrap attempts. Only the current generation may mutate state. const generationRef = useRef(0); + const bootstrapCreateInFlightGenerationRef = useRef(null); useEffect(() => { generationRef.current += 1; @@ -241,12 +242,19 @@ export function useTerminalSessions(projectId?: string): UseTerminalSessionsRetu // Auto-create first tab if no tabs exist after validation useEffect(() => { - if (tabs.length === 0 && isReady && serverAvailable) { + if (tabs.length === 0 && isReady && serverAvailable && !bootstrapError) { // Capture current generation so only this attempt's result is accepted const gen = generationRef.current; + if (bootstrapCreateInFlightGenerationRef.current === gen) return; // Small delay to avoid race condition with the validation effect const timeout = setTimeout(() => { + if (bootstrapCreateInFlightGenerationRef.current === gen) return; + bootstrapCreateInFlightGenerationRef.current = gen; + /* + FNXC:WindowsTerminalStartup 2026-07-02-07:45: + Terminal bootstrap failures must render once inside Fusion and then wait for an explicit Retry, so Windows Terminal help/version output cannot recur through an automatic create-session loop. + */ withTimeout( createTerminalSession(undefined, undefined, undefined, projectId), BOOTSTRAP_CREATE_TIMEOUT_MS, @@ -284,11 +292,16 @@ export function useTerminalSessions(projectId?: string): UseTerminalSessionsRetu const message = err instanceof Error ? err.message : typeof err === "string" ? err : "Failed to create terminal session"; setBootstrapError(message); + }) + .finally(() => { + if (bootstrapCreateInFlightGenerationRef.current === gen) { + bootstrapCreateInFlightGenerationRef.current = null; + } }); }, 0); return () => clearTimeout(timeout); } - }, [isReady, serverAvailable, tabs.length, retryGeneration]); // Run when ready or when tabs become empty + }, [bootstrapError, isReady, serverAvailable, tabs.length, retryGeneration]); // Run when ready or when tabs become empty /** * Internal create tab function (used for auto-creation and user-initiated creation). @@ -491,6 +504,7 @@ export function useTerminalSessions(projectId?: string): UseTerminalSessionsRetu const retryBootstrap = useCallback((): void => { setBootstrapError(null); generationRef.current += 1; + bootstrapCreateInFlightGenerationRef.current = null; setRetryGeneration((g) => g + 1); }, []); diff --git a/packages/dashboard/src/__tests__/routes-automation.test.ts b/packages/dashboard/src/__tests__/routes-automation.test.ts index 1dbb8dd8e7..006aa2a18b 100644 --- a/packages/dashboard/src/__tests__/routes-automation.test.ts +++ b/packages/dashboard/src/__tests__/routes-automation.test.ts @@ -369,7 +369,7 @@ describe("Terminal session routes", () => { ["invalid_shell", 400, "Shell not allowed. Please use a supported shell (bash, zsh, sh, cmd, powershell)."], ["invalid_cwd", 400, "Terminal working directory is not an authorized project or task worktree."], ["pty_load_failed", 503, "Terminal service unavailable. The PTY module could not be loaded."], - ["pty_spawn_failed", 500, "Failed to start terminal shell process."], + ["pty_spawn_failed", 500, terminalServiceModule.WINDOWS_TERMINAL_EMBEDDED_STARTUP_ERROR], ] as const)("returns %s errors with the correct status and body", async (code, status, error) => { const mockService = { createSession: vi.fn().mockResolvedValue({ @@ -394,6 +394,35 @@ describe("Terminal session routes", () => { terminalServiceSpy.mockRestore(); }); + it("returns an actionable structured error instead of raw Windows Terminal version text", async () => { + const mockService = { + createSession: vi.fn().mockResolvedValue({ + success: false, + code: "pty_spawn_failed", + error: terminalServiceModule.WINDOWS_TERMINAL_EMBEDDED_STARTUP_ERROR, + }), + }; + const terminalServiceSpy = vi.spyOn(terminalServiceModule, "getTerminalService").mockReturnValue(mockService as any); + + const res = await REQUEST( + buildApp(), + "POST", + "/api/terminal/sessions", + JSON.stringify({}), + { "Content-Type": "application/json" }, + ); + + expect(res.status).toBe(500); + expect(res.body).toEqual({ + error: terminalServiceModule.WINDOWS_TERMINAL_EMBEDDED_STARTUP_ERROR, + details: { code: "pty_spawn_failed" }, + }); + expect(JSON.stringify(res.body)).not.toContain("Windows Terminal\\n1.24.11321.0"); + expect(mockService.createSession).toHaveBeenCalledTimes(1); + + terminalServiceSpy.mockRestore(); + }); + it("returns 201 for a successful session creation", async () => { const mockService = { createSession: vi.fn().mockResolvedValue({ diff --git a/packages/dashboard/src/__tests__/terminal-service.test.ts b/packages/dashboard/src/__tests__/terminal-service.test.ts index d4fbaebf6a..7b51aa792e 100644 --- a/packages/dashboard/src/__tests__/terminal-service.test.ts +++ b/packages/dashboard/src/__tests__/terminal-service.test.ts @@ -1,9 +1,13 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import * as fs from "node:fs"; +import * as nodePty from "node-pty"; import { READY_QUIET_WINDOW_MS, READY_TIMEOUT_MS, TerminalService, STALE_SESSION_THRESHOLD_MS, + WINDOWS_TERMINAL_EMBEDDED_STARTUP_ERROR, + __setTerminalPlatformForTests, } from "../terminal-service.js"; import { runGitCommand } from "../routes/resolve-diff-base.js"; @@ -53,6 +57,8 @@ vi.mock("../routes/resolve-diff-base.js", () => ({ runGitCommand: vi.fn(), })); +const ORIGINAL_SHELL = process.env.SHELL; + describe("TerminalService", () => { let service: TerminalService; const projectRoot = "/test/project"; @@ -60,6 +66,7 @@ describe("TerminalService", () => { beforeEach(() => { vi.clearAllMocks(); service = new TerminalService(projectRoot, 10); + vi.mocked(nodePty.spawn).mockImplementation(() => mockPtyProcess as never); mockPtyProcess._onDataCallback = null; mockPtyProcess._onExitCallback = null; mockStat.mockResolvedValue({ isDirectory: () => true }); @@ -68,6 +75,13 @@ describe("TerminalService", () => { afterEach(() => { service.cleanup(); + __setTerminalPlatformForTests(null); + vi.restoreAllMocks(); + if (ORIGINAL_SHELL === undefined) { + delete process.env.SHELL; + } else { + process.env.SHELL = ORIGINAL_SHELL; + } }); describe("createSession", () => { @@ -107,6 +121,78 @@ describe("TerminalService", () => { }); }); + it("does not select or probe Windows Terminal when wt.exe is present on Windows", async () => { + __setTerminalPlatformForTests("win32"); + vi.mocked(fs.existsSync).mockImplementation((candidate) => { + const value = String(candidate).toLowerCase(); + return value === "wt.exe" || value.endsWith("\\cmd.exe") || value === "cmd.exe"; + }); + process.env.SHELL = "wt.exe"; + const windowsService = new TerminalService(projectRoot, 10); + + const result = await windowsService.createSession(); + + expect(result.success).toBe(true); + expect(nodePty.spawn).toHaveBeenCalledWith( + "C:\\Windows\\System32\\cmd.exe", + [], + expect.objectContaining({ cwd: projectRoot }), + ); + expect(nodePty.spawn).not.toHaveBeenCalledWith("wt.exe", expect.anything(), expect.anything()); + windowsService.cleanup(); + }); + + it("rejects explicit Windows Terminal shells before spawning an embedded PTY", async () => { + __setTerminalPlatformForTests("win32"); + const windowsService = new TerminalService(projectRoot, 10); + + const result = await windowsService.createSession({ shell: "wt.exe" }); + + expect(result).toEqual({ + success: false, + code: "invalid_shell", + error: "Shell not allowed. Please use a supported shell (bash, zsh, sh, cmd, powershell).", + }); + expect(nodePty.spawn).not.toHaveBeenCalled(); + windowsService.cleanup(); + }); + + it("maps Windows Terminal version-only spawn output to an actionable inline startup error", async () => { + __setTerminalPlatformForTests("win32"); + vi.mocked(fs.existsSync).mockImplementation((candidate) => String(candidate).toLowerCase().endsWith("cmd.exe")); + vi.mocked(nodePty.spawn).mockImplementation(() => { + throw new Error("Windows Terminal\n1.24.11321.0"); + }); + const windowsService = new TerminalService(projectRoot, 10); + + const result = await windowsService.createSession(); + + expect(result).toEqual({ + success: false, + code: "pty_spawn_failed", + error: WINDOWS_TERMINAL_EMBEDDED_STARTUP_ERROR, + }); + expect(result.success ? result.session.shell : result.error).not.toContain("1.24.11321.0"); + windowsService.cleanup(); + }); + + it("preserves non-Windows shell detection when wt.exe is present in the environment", async () => { + __setTerminalPlatformForTests("linux"); + process.env.SHELL = "/bin/bash"; + vi.mocked(fs.existsSync).mockImplementation((candidate) => ["/bin/bash", "/bin/sh"].includes(String(candidate))); + const linuxService = new TerminalService(projectRoot, 10); + + const result = await linuxService.createSession(); + + expect(result.success).toBe(true); + expect(nodePty.spawn).toHaveBeenCalledWith( + "/bin/bash", + ["--login"], + expect.objectContaining({ cwd: projectRoot }), + ); + linuxService.cleanup(); + }); + it("allows an explicit project-root cwd", async () => { const result = await service.createSession({ cwd: projectRoot }); diff --git a/packages/dashboard/src/terminal-service.ts b/packages/dashboard/src/terminal-service.ts index a7e97c7c52..60aaddeb96 100644 --- a/packages/dashboard/src/terminal-service.ts +++ b/packages/dashboard/src/terminal-service.ts @@ -69,6 +69,19 @@ const STRIP_ENV_VARS = [ "FUSION_API_KEY", ]; +let platformOverrideForTests: NodeJS.Platform | null = null; + +function getTerminalPlatform(): NodeJS.Platform { + return platformOverrideForTests ?? os.platform(); +} + +export function __setTerminalPlatformForTests(platform: NodeJS.Platform | null): void { + platformOverrideForTests = platform; +} + +export const WINDOWS_TERMINAL_EMBEDDED_STARTUP_ERROR = + "Fusion could not start an embedded terminal shell on Windows. Use Command Prompt or PowerShell for the embedded terminal, or install/repair Windows Terminal separately with `winget install Microsoft.WindowsTerminal` if you want Windows Terminal outside Fusion."; + export interface TerminalSession { id: string; pty: IPty; @@ -139,8 +152,7 @@ export class TerminalService extends EventEmitter { private sessions: Map = new Map(); private dataCallbacks: Set = new Set(); private exitCallbacks: Set = new Set(); - private isWindows = os.platform() === "win32"; - private projectRoot: string; + private isWindows = getTerminalPlatform() === "win32"; private projectRoot: string; private maxSessions: number; private registeredWorktreeCache: Map = new Map(); @@ -166,7 +178,7 @@ export class TerminalService extends EventEmitter { * Get the default allowed shells for the current platform */ private getAllowedShells(): string[] { - const platform = os.platform(); + const platform = getTerminalPlatform(); return ALLOWED_SHELL_PATHS[platform] || ALLOWED_SHELL_PATHS.linux; } @@ -183,7 +195,7 @@ export class TerminalService extends EventEmitter { * Detect the best shell for the current platform */ detectShell(): { shell: string; args: string[] } { - const platform = os.platform(); + const platform = getTerminalPlatform(); const allowedShells = this.getAllowedShells(); // Helper to get basename handling both path separators @@ -207,7 +219,11 @@ export class TerminalService extends EventEmitter { return ["--login"]; }; - // First try user's shell from env if it's allowed + /* + FNXC:WindowsTerminalStartup 2026-07-02-07:45: + Fusion's embedded terminal must not invoke or probe Windows Terminal (`wt.exe`) because it is an external terminal host, not a PTY shell; the Windows startup path stays on supported shells and reports actionable Fusion-owned errors instead of recurring native Windows Terminal help/version dialogs. + */ + // First try user's shell from env if it's allowed. Windows intentionally ignores SHELL because values such as wt.exe are terminal hosts, not embedded shells. const userShell = process.env.SHELL; if (userShell && platform !== "win32") { const normalizedUserShell = this.isWindows ? userShell.toLowerCase() : userShell; @@ -243,7 +259,7 @@ export class TerminalService extends EventEmitter { cwd: string, ): Record { return { - platform: os.platform(), + platform: getTerminalPlatform(), projectRoot: this.projectRoot, cwd, requestedShell: requestedShell ?? null, @@ -580,7 +596,9 @@ export class TerminalService extends EventEmitter { return { success: false, code: "pty_spawn_failed", - error: "Failed to start terminal shell process.", + error: this.isWindows + ? WINDOWS_TERMINAL_EMBEDDED_STARTUP_ERROR + : "Failed to start terminal shell process.", }; }