From ab4a7616d2dcbff38192c0d158abd6e990182964 Mon Sep 17 00:00:00 2001 From: Hotragn <103170876+Hotragn@users.noreply.github.com> Date: Tue, 18 Aug 2026 15:54:21 -0400 Subject: [PATCH] fix(core): resolve Windows shells installed as app-execution aliases A configured shell silently fell back to Windows PowerShell 5.1 when it came from a Store/MSIX install. Those expose the executable as a Windows app-execution alias, a zero-byte AppExecLink reparse point that stat and which cannot see, even though CreateProcess resolves it by name. So which() returned null for pwsh, resolve() returned undefined, and select() dropped through to win()[0]. where.exe does resolve app-execution aliases, so use it as the tiebreaker when which() finds nothing: trust a known shell family the OS can locate, and keep falling back when the shell genuinely is not installed. Closes #41426 --- packages/core/src/shell.ts | 15 +++++++++++++-- packages/core/test/shell.test.ts | 9 +++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/core/src/shell.ts b/packages/core/src/shell.ts index 29089106d904..33834fe6f616 100644 --- a/packages/core/src/shell.ts +++ b/packages/core/src/shell.ts @@ -1,7 +1,7 @@ export * as Shell from "./shell" import path from "path" -import { spawn, type ChildProcess } from "child_process" +import { spawn, spawnSync, type ChildProcess } from "child_process" import { readFile } from "fs/promises" import { statSync } from "fs" import { setTimeout as sleep } from "node:timers/promises" @@ -92,7 +92,18 @@ function resolve(file: string) { if (stat(shell)?.isFile()) return shell return } - return which(shell) ?? undefined + const found = which(shell) + if (found) return found + // Store/MSIX installs expose their executable as a Windows app-execution alias, an + // AppExecLink reparse point that stat and which cannot see even though CreateProcess + // resolves it by name. where.exe does see it, so it separates "installed as an alias" + // from "not installed" and keeps a configured shell like pwsh from silently falling back. + if (process.platform === "win32" && meta(shell) && aliased(shell)) return shell + return undefined +} + +function aliased(file: string) { + return spawnSync("where.exe", [file], { stdio: "ignore", windowsHide: true }).status === 0 } function win() { diff --git a/packages/core/test/shell.test.ts b/packages/core/test/shell.test.ts index 1cc47a79f6cd..4c8ea628e1de 100644 --- a/packages/core/test/shell.test.ts +++ b/packages/core/test/shell.test.ts @@ -49,6 +49,15 @@ describe("shell", () => { }) }) + test("falls back for known shell families that are not installed", async () => { + if (process.platform !== "win32") return + await withShell(undefined, () => { + // ksh is in the shell metadata table but is not present on Windows. Resolution must not + // trust a bare name just because the family is known, only when the OS can actually find it. + expect(Shell.acceptable("ksh")).toBe(Shell.acceptable()) + }) + }) + test("falls back for terminal-only acceptable shells", () => { expect(Shell.name(Shell.acceptable("fish"))).not.toBe("fish") expect(Shell.name(Shell.acceptable("nu"))).not.toBe("nu")