fix(desktop): stop Windows Terminal popup — real cause is worktrunk 'wt' name collision

The 1882/1883 terminal-auto-create guard fixed the wrong subsystem; the
embedded terminal was already guarded. The actual trigger: worktrunk's CLI is
named 'wt', colliding with Windows Terminal (wt.exe) on PATH, so probing it with
'wt --version' launched Windows Terminal and popped its native version dialog —
fired automatically by the Settings worktrunk-status fetch on mount.

- useWorktrunkInstallStatus: only auto-fetch /api/worktrunk/status when the
  integration is enabled (user opt-in), never on a plain Settings/dashboard mount.
- probeWorktrunk: refuse to exec a resolved 'wt' that is the Windows Terminal
  alias (WindowsApps / WindowsTerminal package dir), covering every resolution
  surface (cached/override/PATH/install/settings-route). Basename computed
  host-independently so the guard holds when the build host is POSIX.
- Tests for both guards; report updated with corrected root cause + Symptom
  Verification + Surface Enumeration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-03 22:49:56 -07:00
parent 20184acdfd
commit 89123997b9
7 changed files with 159 additions and 13 deletions

View File

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

View File

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

View File

@@ -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<typeof vi.fn>;
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");
});
});

View File

@@ -33,7 +33,12 @@ async function requestJson<T>(path: string, init?: RequestInit): Promise<T> {
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<WorktrunkInstallStatusResponse>({ 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), {

View File

@@ -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("/");

View File

@@ -210,7 +210,29 @@ async function lookupPath(binaryName: string): Promise<string | null> {
}
}
/*
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 <version>" 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,

View File

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