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:
7
.changeset/FN-7417-windows-terminal-popup.md
Normal file
7
.changeset/FN-7417-windows-terminal-popup.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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.",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user