From 77eaeaf1beb77a47f93ba2a37e8411eca1a55721 Mon Sep 17 00:00:00 2001 From: Hotragn <103170876+Hotragn@users.noreply.github.com> Date: Mon, 14 Sep 2026 03:05:14 -0400 Subject: [PATCH 1/2] fix(core): resolve Windows shells installed as app-execution aliases --- packages/core/src/shell/select.ts | 14 +++++++++++++- packages/core/test/shell.test.ts | 11 +++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/packages/core/src/shell/select.ts b/packages/core/src/shell/select.ts index 6645454c0a45..918366a639da 100644 --- a/packages/core/src/shell/select.ts +++ b/packages/core/src/shell/select.ts @@ -3,6 +3,7 @@ export * as ShellSelect from "./select.js" import path from "path" import { readFile } from "fs/promises" import { statSync } from "fs" +import { spawnSync } from "child_process" import { Context, Effect, Layer, Schema } from "effect" import { makeLocationNode } from "@opencode/util/effect/app-node" import { FSUtil } from "@opencode/util/fs-util" @@ -89,7 +90,18 @@ function executable(file: string, options?: Options, bin?: string) { if (stat(shell)?.isFile()) return shell return } - return findExecutable(shell, bin) ?? undefined + const found = findExecutable(shell, bin) + 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(options?: Options, bin?: string) { diff --git a/packages/core/test/shell.test.ts b/packages/core/test/shell.test.ts index 204d6e146d72..0a00c73759ba 100644 --- a/packages/core/test/shell.test.ts +++ b/packages/core/test/shell.test.ts @@ -4,6 +4,7 @@ import { ShellSelect } from "@opencode/core/shell/select" import { FSUtil } from "@opencode/util/fs-util" import { which } from "@opencode/core/util/which" import fs from "node:fs/promises" +import { spawnSync } from "node:child_process" import { tmpdir } from "./fixture/tmpdir" const withShell = async (shell: string | undefined, fn: () => void | Promise) => { @@ -119,5 +120,15 @@ describe("shell", () => { expect(ShellSelect.resolve({ priority: "config" })).toBe(shell) }) }) + + test("does not resolve known shells that are not installed", async () => { + // The app-execution-alias fallback may only return a bare name for a shell where.exe + // can actually find; a known-but-absent shell must still fall back. + for (const name of ["zsh", "ksh"]) { + if (which(name)) continue + if (spawnSync("where.exe", [name], { stdio: "ignore", windowsHide: true }).status === 0) continue + expect(ShellSelect.resolve({ priority: "config" }, name)).not.toBe(name) + } + }) } }) From 256f54617ab371910606f8e02c2c430387a00e65 Mon Sep 17 00:00:00 2001 From: Hotragn <103170876+Hotragn@users.noreply.github.com> Date: Wed, 16 Sep 2026 15:36:02 -0400 Subject: [PATCH 2/2] fix(core): memoize alias lookups and return the resolved path --- packages/core/src/shell/select.ts | 24 +++++++++++++++++++++--- packages/core/test/shell.test.ts | 31 +++++++++++++++++++++++++++++-- 2 files changed, 50 insertions(+), 5 deletions(-) diff --git a/packages/core/src/shell/select.ts b/packages/core/src/shell/select.ts index 918366a639da..7377370ff481 100644 --- a/packages/core/src/shell/select.ts +++ b/packages/core/src/shell/select.ts @@ -96,13 +96,31 @@ function executable(file: string, options?: Options, bin?: string) { // 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 + // Restricted to the known shell families so an arbitrary configured name cannot resolve + // to some unrelated Store app that happens to share it. + if (process.platform === "win32" && meta(shell)) return aliased(shell) return undefined } -function aliased(file: string) { - return spawnSync("where.exe", [file], { stdio: "ignore", windowsHide: true }).status === 0 +const aliases = new Map() + +function line(out?: string) { + // trim() drops the trailing CR, so splitting on the LF alone is enough + return out?.split("\n")[0]?.trim() || undefined +} + +// Spawning where.exe costs ~300ms, and resolve() skips its own cache whenever a shell is +// configured, so memoize both hits and misses per name. +export function aliased(file: string) { + if (aliases.has(file)) return aliases.get(file) + const result = spawnSync("where.exe", [file], { encoding: "utf8", windowsHide: true }) + // spawnSync reports a failed launch on .error rather than throwing, so a missing or + // blocked where.exe lands here as a miss. + const path = result.status === 0 ? line(result.stdout) : undefined + aliases.set(file, path) + return path } +aliased.reset = () => aliases.clear() function win(options?: Options, bin?: string) { return Array.from( diff --git a/packages/core/test/shell.test.ts b/packages/core/test/shell.test.ts index 0a00c73759ba..3a7b8720e5f7 100644 --- a/packages/core/test/shell.test.ts +++ b/packages/core/test/shell.test.ts @@ -122,13 +122,40 @@ describe("shell", () => { }) test("does not resolve known shells that are not installed", async () => { - // The app-execution-alias fallback may only return a bare name for a shell where.exe - // can actually find; a known-but-absent shell must still fall back. + // The app-execution-alias fallback may only resolve a shell where.exe can actually + // find; a known-but-absent shell must still fall back. for (const name of ["zsh", "ksh"]) { if (which(name)) continue if (spawnSync("where.exe", [name], { stdio: "ignore", windowsHide: true }).status === 0) continue expect(ShellSelect.resolve({ priority: "config" }, name)).not.toBe(name) } }) + + test("finds app-execution aliases that which cannot see", async () => { + // winget ships as an alias on stock Windows 11 and is not a shell, so it exercises + // the detection without depending on which shells happen to be installed. + if (which("winget")) return + const found = ShellSelect.aliased("winget") + if (!found) return + expect(path.win32.isAbsolute(found)).toBe(true) + expect(path.win32.basename(found).toLowerCase()).toBe("winget.exe") + }) + + test("returns undefined for names where.exe cannot find", async () => { + expect(ShellSelect.aliased("opencode-not-a-real-binary")).toBeUndefined() + }) + + test("memoizes alias lookups including misses", async () => { + ShellSelect.aliased.reset() + const name = "opencode-not-a-real-binary" + const cold = Date.now() + ShellSelect.aliased(name) + const coldMs = Date.now() - cold + const warm = Date.now() + ShellSelect.aliased(name) + const warmMs = Date.now() - warm + // A where.exe launch costs ~300ms here; a cached miss must not spawn again. + expect(warmMs).toBeLessThan(Math.max(coldMs, 10)) + }) } })