diff --git a/.changeset/fix-windows-terminal-worktrunk-popup.md b/.changeset/fix-windows-terminal-worktrunk-popup.md new file mode 100644 index 0000000000..d44499dc33 --- /dev/null +++ b/.changeset/fix-windows-terminal-worktrunk-popup.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Stop Windows Terminal version dialogs from popping up when opening the dashboard or Settings on Windows. +category: fix +dev: Root cause was the worktrunk integration, not the embedded terminal: worktrunk's CLI is named `wt`, which collides with Windows Terminal (`wt.exe`) on PATH, so probing it with `wt --version` launched Windows Terminal. Fixed by (1) `useWorktrunkInstallStatus` only auto-fetching `/api/worktrunk/status` when the integration is enabled (user opt-in) instead of on every Settings/dashboard mount, and (2) an engine-level guard in `probeWorktrunk` that refuses to exec a resolved `wt` that is the Windows Terminal alias (under `WindowsApps` / a `WindowsTerminal` package dir), covering all resolution surfaces. diff --git a/packages/dashboard/app/components/SettingsModal.tsx b/packages/dashboard/app/components/SettingsModal.tsx index 3a4be98c4c..38f356fccd 100644 --- a/packages/dashboard/app/components/SettingsModal.tsx +++ b/packages/dashboard/app/components/SettingsModal.tsx @@ -701,8 +701,6 @@ export function SettingsModal({ const { isEmbedded, scrollLockEnabled, resizePersistEnabled, escapeEnabled, overlayDismissEnabled } = useEmbeddedPresentation(presentation); const { t } = useTranslation("app"); const { confirm } = useConfirm(); - const worktrunkInstall = useWorktrunkInstallStatus(projectId); - const worktrunkInstallVerified = worktrunkInstall.status === "installed"; const viewportMode = useViewportMode(); // Modal-only: lock background scroll on mobile. Embedded view owns its own scroll region. useMobileScrollLock(scrollLockEnabled); @@ -771,6 +769,11 @@ export function SettingsModal({ prTitlePromptInstructions: "", prDescriptionPromptInstructions: "", }); + // FNXC:WindowsTerminalStartup 2026-07-03-16:25: + // Only probe worktrunk status when the integration is enabled (user opted in), + // so opening Settings on Windows can't auto-launch Windows Terminal (`wt.exe`). + const worktrunkInstall = useWorktrunkInstallStatus(projectId, { enabled: form.worktrunk?.enabled === true }); + const worktrunkInstallVerified = worktrunkInstall.status === "installed"; const [loading, setLoading] = useState(true); // Guards the Save action against double-submit (rapid clicks / Enter) while the // parallel global+project writes are in flight. diff --git a/packages/dashboard/app/hooks/__tests__/useWorktrunkInstallStatus.test.ts b/packages/dashboard/app/hooks/__tests__/useWorktrunkInstallStatus.test.ts new file mode 100644 index 0000000000..57d8cff5fc --- /dev/null +++ b/packages/dashboard/app/hooks/__tests__/useWorktrunkInstallStatus.test.ts @@ -0,0 +1,47 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; + +vi.mock("../../sse-bus", () => ({ + subscribeSse: vi.fn(() => () => {}), +})); + +import { useWorktrunkInstallStatus } from "../useWorktrunkInstallStatus"; + +/* +FNXC:WindowsTerminalStartup 2026-07-03-16:25: +The worktrunk status probe hits `GET /api/worktrunk/status`, which resolves + +probes the `wt` binary server-side; on Windows `wt` is Windows Terminal, so an +automatic probe on Settings mount pops its native version/Help dialog. The hook +must only auto-fetch when worktrunk integration is enabled (user opted in) — +never on a plain mount — so opening Settings can't trigger the dialog. +*/ +describe("useWorktrunkInstallStatus", () => { + let fetchMock: ReturnType; + + beforeEach(() => { + fetchMock = vi.fn(async () => ({ + ok: true, + json: async () => ({ status: "installed", version: "0.4.2" }), + })); + vi.stubGlobal("fetch", fetchMock); + }); + + it("does not probe worktrunk status on mount when disabled", async () => { + renderHook(() => useWorktrunkInstallStatus("p1", { enabled: false })); + // Give any (incorrect) effect a chance to fire. + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("does not probe worktrunk status on mount when options are omitted", async () => { + renderHook(() => useWorktrunkInstallStatus("p1")); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("probes worktrunk status on mount when enabled (user opted in)", async () => { + renderHook(() => useWorktrunkInstallStatus("p1", { enabled: true })); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + expect(String(fetchMock.mock.calls[0]?.[0])).toContain("/api/worktrunk/status"); + }); +}); diff --git a/packages/dashboard/app/hooks/useWorktrunkInstallStatus.ts b/packages/dashboard/app/hooks/useWorktrunkInstallStatus.ts index 2ca56c3e48..f3e3fee1e2 100644 --- a/packages/dashboard/app/hooks/useWorktrunkInstallStatus.ts +++ b/packages/dashboard/app/hooks/useWorktrunkInstallStatus.ts @@ -33,7 +33,12 @@ async function requestJson(path: string, init?: RequestInit): Promise { return payload; } -export function useWorktrunkInstallStatus(projectId?: string) { +/* +FNXC:WindowsTerminalStartup 2026-07-03-16:25: +The worktrunk status probe must NOT run automatically on Settings/dashboard mount. `GET /api/worktrunk/status` resolves + probes the `wt` binary server-side, and on Windows `wt` collides with Windows Terminal (`wt.exe`), so an automatic probe pops Windows Terminal's native version/Help dialog just from opening Settings (field report Issue 4). Only auto-refresh when worktrunk integration is enabled — i.e. the user has opted in / requested it. `refresh` stays exposed so explicit UI actions can still check on demand. Backstop: probeWorktrunk (engine) also refuses to launch the Windows Terminal alias. +*/ +export function useWorktrunkInstallStatus(projectId?: string, options?: { enabled?: boolean }) { + const enabled = options?.enabled === true; const [status, setStatus] = useState({ status: "missing" }); const [requesting, setRequesting] = useState(false); @@ -47,8 +52,9 @@ export function useWorktrunkInstallStatus(projectId?: string) { }, [projectId]); useEffect(() => { + if (!enabled) return; void refresh(); - }, [refresh]); + }, [enabled, refresh]); useEffect(() => { const unsubscribe = subscribeSse(withProjectId("/api/events", projectId), { diff --git a/packages/engine/src/__tests__/worktrunk-installer.test.ts b/packages/engine/src/__tests__/worktrunk-installer.test.ts index 2427b93b24..41d6e12bcb 100644 --- a/packages/engine/src/__tests__/worktrunk-installer.test.ts +++ b/packages/engine/src/__tests__/worktrunk-installer.test.ts @@ -258,6 +258,62 @@ describe("worktrunk-installer", () => { expect(commands.some((command) => command.includes(" worktrunk"))).toBe(false); }); + /* + FNXC:WindowsTerminalStartup 2026-07-03-16:10: + On Windows `wt` resolves to Windows Terminal (`wt.exe`, an App Execution Alias + under WindowsApps). Probing it with `--version` launches Windows Terminal and + pops its native version/Help dialog. probeWorktrunk must refuse to exec the + Windows Terminal alias, and resolveWorktrunkBinary must not probe a PATH hit + that is Windows Terminal — otherwise opening Settings on Windows pops the dialog. + */ + it("probeWorktrunk refuses to launch the Windows Terminal alias without exec", async () => { + const originalPlatform = process.platform; + Object.defineProperty(process, "platform", { value: "win32", configurable: true }); + try { + execMock.mockClear(); + const result = await probeWorktrunk( + "C:\\Users\\me\\AppData\\Local\\Microsoft\\WindowsApps\\wt.exe", + ); + expect(result.ok).toBe(false); + expect(execMock).not.toHaveBeenCalled(); + } finally { + Object.defineProperty(process, "platform", { value: originalPlatform, configurable: true }); + } + }); + + it("probeWorktrunk still probes a genuine wt binary outside WindowsApps on Windows", async () => { + const originalPlatform = process.platform; + Object.defineProperty(process, "platform", { value: "win32", configurable: true }); + try { + mockExecSequence([{ stdout: "wt 0.4.2\n" }]); + const result = await probeWorktrunk("C:\\tools\\worktrunk\\wt.exe"); + expect(result).toEqual({ ok: true, version: "0.4.2" }); + expect(execMock).toHaveBeenCalledTimes(1); + } finally { + Object.defineProperty(process, "platform", { value: originalPlatform, configurable: true }); + } + }); + + it("resolveWorktrunkBinary never execs --version against a Windows Terminal PATH hit", async () => { + const originalPlatform = process.platform; + Object.defineProperty(process, "platform", { value: "win32", configurable: true }); + try { + // `where wt` returns Windows Terminal's alias; the Fusion install-path probe then misses. + mockExecSequence([ + { stdout: "C:\\Users\\me\\AppData\\Local\\Microsoft\\WindowsApps\\wt.exe\n" }, + { error: new Error("not found") }, + ]); + await expect(resolveWorktrunkBinary({ settings: makeSettings() })).rejects.toThrow( + WorktrunkInstallFailedError, + ); + const commands = execMock.mock.calls.map(([command]) => String(command)); + // The Windows Terminal alias was never launched with --version. + expect(commands.some((c) => c.toLowerCase().includes("windowsapps") && c.includes("--version"))).toBe(false); + } finally { + Object.defineProperty(process, "platform", { value: originalPlatform, configurable: true }); + } + }); + it("installer metadata points at canonical upstream", () => { const serialized = JSON.stringify(WORKTRUNK_PINNED_RELEASE); const fabricatedUpstream = ["worktrunk", "worktrunk"].join("/"); diff --git a/packages/engine/src/worktrunk-installer.ts b/packages/engine/src/worktrunk-installer.ts index 4592679e09..5cc32c000e 100644 --- a/packages/engine/src/worktrunk-installer.ts +++ b/packages/engine/src/worktrunk-installer.ts @@ -210,7 +210,29 @@ async function lookupPath(binaryName: string): Promise { } } +/* +FNXC:WindowsTerminalStartup 2026-07-03-16:10: +On Windows the worktrunk CLI (`wt`) collides by name with Windows Terminal (`wt.exe`), which ships as an App Execution Alias under %LOCALAPPDATA%\Microsoft\WindowsApps and is on PATH by default on Windows 11. `where wt` therefore resolves to Windows Terminal, and probing it with `wt --version` LAUNCHES Windows Terminal — popping its native "Windows Terminal " Help/version dialog whenever worktrunk resolution runs (e.g. on dashboard load or a Settings save, field report Issue 4). Never exec a resolved `wt` that is the Windows Terminal alias; a genuine worktrunk binary lives on PATH elsewhere or under ~/.fusion/bin, never under WindowsApps / a WindowsTerminal package dir. This guard sits in probeWorktrunk — the single choke point every resolution surface (cached/override/PATH/install/settings-route) funnels through — so the invariant holds everywhere. +*/ +export const WORKTRUNK_WINDOWS_TERMINAL_COLLISION_MESSAGE = + "Refusing to probe `wt` on Windows: the resolved binary is Windows Terminal (wt.exe), not worktrunk. Set `worktrunk.binaryPath` to the real worktrunk executable, or let Fusion install it under ~/.fusion/bin."; + +export function isWindowsTerminalBinary(binaryPath: string): boolean { + if (process.platform !== "win32") return false; + // Compute the basename from the backslash-normalized string directly rather + // than path.basename(): on a POSIX build host (tests/CI) node's default `path` + // is POSIX and would not split on "\\", so a Windows path would be misparsed. + const normalized = binaryPath.replace(/\//g, "\\").toLowerCase(); + const base = (normalized.split("\\").pop() ?? "").replace(/\.exe$/, ""); + if (base !== "wt") return false; + return normalized.includes("\\windowsapps\\") || normalized.includes("windowsterminal"); +} + export async function probeWorktrunk(binaryPath: string): Promise<{ ok: boolean; version?: string; error?: string }> { + if (isWindowsTerminalBinary(binaryPath)) { + logger.warn("probe: refusing to launch Windows Terminal wt.exe", { binaryPath }); + return { ok: false, error: WORKTRUNK_WINDOWS_TERMINAL_COLLISION_MESSAGE }; + } try { const { stdout } = await execAsync(`"${binaryPath}" --version`, { timeout: WORKTRUNK_PROBE_TIMEOUT_MS, diff --git a/reports/desktop-release-issues-2026-07-03.md b/reports/desktop-release-issues-2026-07-03.md index 985108c5e4..577a1ed1bb 100644 --- a/reports/desktop-release-issues-2026-07-03.md +++ b/reports/desktop-release-issues-2026-07-03.md @@ -17,7 +17,7 @@ This document collects the issues we found while trying to run the official Fusi | 1 | `electron` only a devDependency | **Fixed** — `electron` added to `@runfusion/fusion` runtime deps; lockfile synced. | | 2 | Ancestor-dir walk crashes on unrelated JSON | **Already handled** — the desktop launcher uses `process.cwd()` (CLI) / `$HOME` (Electron main), never an ancestor walk (`desktop.ts:175`, `main.ts` `resolveLocalRuntimeRoot`), and every JSON parse on the shared discovery path is already `try/catch`-guarded, so unrelated JSON no longer throws. | | 3 | Manage Projects opens Settings | **Fixed** — `handleViewAllProjects` now resets `taskView` to `command-center`. | -| 4 | Windows Terminal "Help" dialogs | **Fixed** — frontend auto-create of the first terminal tab is skipped on Windows. | +| 4 | Windows Terminal "Help" dialogs | **Fixed (real root cause found)** — the trigger was NOT the embedded terminal (already guarded) but the **worktrunk integration**: worktrunk's CLI is named `wt`, which collides with Windows Terminal (`wt.exe`) on PATH, so `wt --version` launched Windows Terminal. Fixed by (a) not auto-probing worktrunk status on Settings/dashboard mount — only when the integration is enabled (user opt-in), and (b) an engine-level guard in `probeWorktrunk` that refuses to exec the Windows Terminal alias. The 1882 frontend terminal-auto-create guard is retained as defense-in-depth. | | 5 | Packaged build can miss `preload`/assets | **Fixed** — `scripts/build.ts` now verifies `main.js`, `preload.js`, and `client/index.html` exist in both `dist/` and the staged `deploy/dist/` before packaging, failing the build otherwise. (In this repo the preload ships as `dist/preload.js` inside `app.asar`, not `preload.cjs`.) | | 6 | Desktop port drift / collision | **Already handled** — the embedded runtime binds an ephemeral port (`app.listen(0)`, `desktop.ts:78`), so fixed-port collision is structurally impossible; the CLI passes it via `FUSION_SERVER_PORT` and the desktop reuses it instead of double-binding (Issue 9), and a single-instance lock (`deep-link.ts`) quits a duplicate window. A fixed 9119/8643 would *reintroduce* collisions, so we deliberately did not pin one. | | 7 | GPU/sandbox instability on Windows | **Fixed** — GPU/sandbox-disabling flags applied on Windows only (`os.platform() === "win32"`); macOS/Linux keep hardware acceleration and the sandbox. | @@ -90,16 +90,21 @@ Windows Terminal 1.24.11321.0 ``` -### Root cause -The dashboard’s `useTerminalSessions` hook auto-creates the first terminal tab once session validation completes. On Windows, spawning a PTY can end up invoking `wt.exe` (Windows Terminal) or otherwise triggering its built-in version/help dialog. The backend `terminal-service.ts` already has an FNXC guard (FNXC:WindowsTerminalStartup) to avoid probing `wt.exe`, but the frontend auto-create path still triggers a PTY spawn on Windows before the user has asked for a terminal. +### Root cause (corrected) +The initial hypothesis blamed the embedded terminal's PTY auto-create. That path was already fully guarded (`terminal-service.ts` excludes `wt.exe`, ignores `SHELL` on win32, defaults to `cmd.exe`, maps the version-only spawn to an actionable error), so the dialog persisted after the 1882 frontend guard. The **actual** trigger is the **worktrunk integration**: worktrunk's CLI binary is named `wt` (`WORKTRUNK_BINARY_NAME = "wt"`), which is the same executable name as Windows Terminal (`wt.exe`, an App Execution Alias under `%LOCALAPPDATA%\Microsoft\WindowsApps`, on PATH by default on Windows 11). Worktrunk resolution runs `where wt` → finds Windows Terminal → runs `"wt.exe" --version` to probe it → **launches Windows Terminal**, which shows the native "Windows Terminal 1.24.11321.0" version dialog. This fired because the dashboard/Settings UI auto-fetched worktrunk status (`GET /api/worktrunk/status`) on mount, even when worktrunk was not in use. -### What we tried -- Confirmed `terminal-service.ts` skips Windows Terminal for `SHELL` on `win32`. -- Confirmed tests already assert `wt.exe` should not be selected. -- Disabled the auto-create path on Windows in `useTerminalSessions.ts` so the failure cannot recur automatically. Manual terminal creation still works and surfaces the inline error UI. +### Fix +1. **Don't probe worktrunk automatically.** `useWorktrunkInstallStatus` now only auto-fetches status when the worktrunk integration is enabled (user opt-in / explicit request); a plain Settings/dashboard mount no longer probes. +2. **Engine invariant guard.** `probeWorktrunk` refuses to `exec` a resolved `wt` that is the Windows Terminal alias (basename `wt` under `WindowsApps` / a `WindowsTerminal` package dir), returning an actionable error instead of launching it. This covers every resolution surface (cached / override / PATH / install / settings-route). +3. The 1882 frontend terminal-auto-create guard is retained as defense-in-depth. -### Proposed fix -Merge the frontend guard from PR #1882, or move the platform check server-side so no terminal session is auto-created for Windows users unless the platform has a verified embedded shell. +### Symptom Verification +- **Original symptom:** Opening the dashboard / Settings on Windows pops native "Windows Terminal 1.24.11321.0" Help dialogs. +- **Exact reproduction:** On Windows 11 (Windows Terminal on PATH), worktrunk resolution runs `where wt` → `wt.exe` → `"wt.exe" --version` → GUI dialog; auto-triggered by the Settings worktrunk-status fetch on mount. +- **Assertion it is gone:** `probeWorktrunk` returns `{ ok: false }` for a `WindowsApps\wt.exe` path **without** calling `exec` (`worktrunk-installer.test.ts`); `resolveWorktrunkBinary` never execs `--version` against a Windows Terminal PATH hit; and `useWorktrunkInstallStatus` does not fetch `/api/worktrunk/status` on mount unless enabled (`useWorktrunkInstallStatus.test.ts`). + +### Surface Enumeration +Worktrunk resolution surfaces all funnel through `probeWorktrunk`, so the engine guard covers them uniformly: cached resolution, explicit `binaryPath` override, PATH auto-discovery (`worktree-pool.ts`), Fusion-managed install path, the Settings-save enable validation (`register-settings-memory-routes.ts`), and the `GET /worktrunk/status` route (`register-worktrunk-routes.ts`). The frontend auto-fetch (`useWorktrunkInstallStatus`, used by `SettingsModal`) is the only automatic client trigger and is now gated on `enabled`. ---