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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-02 07:55:15 -07:00
parent 3453d164ea
commit 306e516e3b
8 changed files with 222 additions and 11 deletions

View File

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

View File

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

View File

@@ -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(<TerminalModal isOpen={true} onClose={mockOnClose} />);
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(<TerminalModal isOpen={true} onClose={mockOnClose} />);
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({

View File

@@ -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 () => {

View File

@@ -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<number | null>(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);
}, []);

View File

@@ -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({

View File

@@ -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 });

View File

@@ -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<string, TerminalSession> = new Map();
private dataCallbacks: Set<DataCallback> = new Set();
private exitCallbacks: Set<ExitCallback> = new Set();
private isWindows = os.platform() === "win32";
private projectRoot: string;
private isWindows = getTerminalPlatform() === "win32"; private projectRoot: string;
private maxSessions: number;
private registeredWorktreeCache: Map<string, string[]> = 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<string, unknown> {
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.",
};
}