Resolve PowerShell to a real executable so terminals spawn (Windows error code 5)

On Windows, launching a PowerShell terminal could fail with:
  Failed to spawn shell "pwsh.exe": Cannot create process, error code: 5

error code 5 = ERROR_ACCESS_DENIED from CreateProcessW inside node-pty's
ConPTY. Orca handed ConPTY a bare family name ("pwsh.exe" /
"powershell.exe"). When pwsh resolved to the Microsoft Store App Execution
Alias stub under WindowsApps (or was blocked by AV/AppLocker/SAC),
CreateProcessW rejected it with access-denied. isPwshAvailable probes via
execFileSync (which DOES follow the alias), so detection and launch disagreed.

Fix: resolve the PowerShell executable to a real absolute path before
spawning, skipping the WindowsApps alias stub, and add a Windows fallback
chain: pwsh (resolved abs) -> Windows PowerShell (System32 abs) -> cmd.exe.
SSH/remote and WSL/Git Bash/cmd paths are unaffected.

Fixes #5161
This commit is contained in:
brennanb2025
2026-06-27 21:58:15 -07:00
parent e40ad6a93a
commit 43daa2e34c
10 changed files with 864 additions and 42 deletions
+21 -5
View File
@@ -31,6 +31,22 @@ vi.mock('../pwsh', () => ({
isPwshAvailable: isPwshAvailableMock
}))
// Resolve PowerShell family names to deterministic absolute paths so these
// tests run on non-Windows CI. The real resolver (which skips the Store App
// Execution Alias stub) is exercised in windows-powershell-executable.test.ts.
const PWSH7_ABS = 'C:\\Program Files\\PowerShell\\7\\pwsh.exe'
const WINDOWS_POWERSHELL_ABS = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'
const CMD_ABS = 'C:\\Windows\\System32\\cmd.exe'
vi.mock('../providers/windows-powershell-executable', () => ({
resolveWindowsPowerShellExecutablePath: (family: 'pwsh.exe' | 'powershell.exe') =>
family === 'pwsh.exe' ? PWSH7_ABS : WINDOWS_POWERSHELL_ABS,
resolveWindowsPowerShellSpawnChain: (family: 'pwsh.exe' | 'powershell.exe') =>
family === 'pwsh.exe'
? [PWSH7_ABS, WINDOWS_POWERSHELL_ABS, CMD_ABS]
: [WINDOWS_POWERSHELL_ABS, CMD_ABS],
getWindowsCmdPath: () => CMD_ABS
}))
vi.mock('../providers/local-pty-utils', async (importOriginal) => {
const actual = await importOriginal<typeof LocalPtyUtils>()
return {
@@ -1287,7 +1303,7 @@ describe('createPtySubprocess', () => {
}
expect(spawnMock).toHaveBeenCalledWith(
'powershell.exe',
WINDOWS_POWERSHELL_ABS,
POWERSHELL_OSC133_COMMAND_ARGS,
expect.any(Object)
)
@@ -1316,7 +1332,7 @@ describe('createPtySubprocess', () => {
}
expect(spawnMock).toHaveBeenCalledWith(
'pwsh.exe',
PWSH7_ABS,
POWERSHELL_OSC133_COMMAND_ARGS,
expect.any(Object)
)
@@ -1345,7 +1361,7 @@ describe('createPtySubprocess', () => {
}
expect(spawnMock).toHaveBeenCalledWith(
'powershell.exe',
WINDOWS_POWERSHELL_ABS,
POWERSHELL_OSC133_COMMAND_ARGS,
expect.any(Object)
)
@@ -1374,7 +1390,7 @@ describe('createPtySubprocess', () => {
}
expect(spawnMock).toHaveBeenCalledWith(
'powershell.exe',
WINDOWS_POWERSHELL_ABS,
POWERSHELL_OSC133_COMMAND_ARGS,
expect.any(Object)
)
@@ -1548,7 +1564,7 @@ describe('createPtySubprocess', () => {
}
expect(spawnMock).toHaveBeenCalledWith(
'powershell.exe',
WINDOWS_POWERSHELL_ABS,
POWERSHELL_OSC133_COMMAND_ARGS,
expect.objectContaining({ cwd: 'C:\\Users\\alice\\project' })
)
+114 -19
View File
@@ -18,6 +18,10 @@ import {
} from '../providers/local-pty-utils'
import { resolveWindowsShellLaunchArgs } from '../providers/windows-shell-args'
import { resolveEffectiveWindowsPowerShell } from '../providers/windows-powershell'
import {
buildWindowsPowerShellSpawnAttempts,
type WindowsShellSpawnAttempt
} from '../providers/windows-shell-fallback-chain'
import { isPwshAvailable } from '../pwsh'
import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../pty/codex-home-wsl-env'
import { removeInheritedNoColor } from '../pty/terminal-color-env'
@@ -400,6 +404,70 @@ function resolveFallbackForegroundProcess(
return normalizeForegroundProcessName(pathWin32.basename(shellPath))
}
/**
* Spawns the daemon PTY, walking the Windows PowerShell -> cmd.exe fallback
* chain when ConPTY rejects the primary shell with ERROR_ACCESS_DENIED.
*
* Why: the daemon spawns node-pty directly (no LocalPtyProvider), so it needs
* its own chain walk. The first attempt must match the already-resolved
* shellPath/shellArgs/spawnCwd; later attempts carry their own recomputed args
* so the cmd.exe fallback still gets `chcp 65001`.
*/
function spawnDaemonPtyWithWindowsFallback(args: {
shellPath: string
shellArgs: string[]
spawnCwd: string
env: Record<string, string>
cols: number
rows: number
windowsFallbackAttempts: WindowsShellSpawnAttempt[]
}): {
process: pty.IPty
shellPath: string
spawnCwd: string
startupCommandDeliveredInShellArgs?: boolean
} {
const spawnAt = (shellPath: string, shellArgs: string[], cwd: string): pty.IPty =>
pty.spawn(shellPath, shellArgs, {
name: args.env.TERM ?? 'xterm-256color',
cols: args.cols,
rows: args.rows,
cwd,
env: args.env
})
try {
return {
process: spawnAt(args.shellPath, args.shellArgs, args.spawnCwd),
shellPath: args.shellPath,
spawnCwd: args.spawnCwd
}
} catch (primaryErr) {
if (process.platform !== 'win32') {
throw primaryErr
}
// Skip the first entry: it is the primary that already failed above.
for (const attempt of args.windowsFallbackAttempts.slice(1)) {
try {
const process = spawnAt(attempt.shellPath, attempt.shellArgs, attempt.effectiveCwd)
const message = primaryErr instanceof Error ? primaryErr.message : String(primaryErr)
console.warn(
`[daemon/pty] Primary shell "${args.shellPath}" failed (${message}), fell back to "${attempt.shellPath}"`
)
return {
process,
shellPath: attempt.shellPath,
spawnCwd: attempt.effectiveCwd,
startupCommandDeliveredInShellArgs: attempt.startupCommandDeliveredInShellArgs
}
} catch {
// This fallback shell also failed -- try the next link in the chain.
}
}
throw primaryErr
}
}
/**
* Spawns the daemon-owned PTY subprocess for a terminal session.
*
@@ -460,6 +528,7 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
cwdWslInfo || sessionWslContext ? 'wsl.exe' : opts.shellOverride || resolvePtyShellPath(env)
let shellArgs: string[]
let startupCommandDeliveredInShellArgs = false
let windowsFallbackAttempts: WindowsShellSpawnAttempt[] = []
const startupAgentRecognition = recognizeAgentProcessFromCommandLine(opts.command)
const isCodexStartupCommand = startupAgentRecognition?.agent === 'codex'
const requestedCwd = opts.cwd || getDefaultCwd()
@@ -495,22 +564,38 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
}) ?? shellPath)
: shellPath
}
// Why: matches LocalPtyProvider — CMD needs chcp 65001, PowerShell needs
// $PROFILE dot-sourcing, WSL needs a --bash entry with a translated cwd.
// Reuse the same shared launch-args helper after resolving the effective
// PowerShell executable so daemon-backed terminals preserve parity with the
// in-process PTY path.
const resolved = resolveWindowsShellLaunchArgs(
// Why: when the selected shell is a PowerShell family, resolve it to a real
// absolute executable and build a PowerShell -> cmd.exe fallback chain. A
// bare `pwsh.exe` lets ConPTY resolve the Store App Execution Alias stub,
// whose CreateProcessW launch fails with ERROR_ACCESS_DENIED (error code 5).
// Mirrors LocalPtyProvider so daemon-backed terminals keep arg parity.
windowsFallbackAttempts = buildWindowsPowerShellSpawnAttempts({
shellPath,
spawnCwd,
getDefaultCwd(),
sessionWslContext ?? preferredWslContext,
opts.command
)
shellArgs = resolved.shellArgs
spawnCwd = resolved.effectiveCwd
validationCwd = resolved.validationCwd
startupCommandDeliveredInShellArgs = resolved.startupCommandDeliveredInShellArgs === true
cwd: spawnCwd,
defaultCwd: getDefaultCwd(),
wslContext: sessionWslContext ?? preferredWslContext,
startupCommand: opts.command
})
const primaryAttempt = windowsFallbackAttempts[0]
if (primaryAttempt) {
shellPath = primaryAttempt.shellPath
shellArgs = primaryAttempt.shellArgs
spawnCwd = primaryAttempt.effectiveCwd
validationCwd = primaryAttempt.validationCwd
startupCommandDeliveredInShellArgs = primaryAttempt.startupCommandDeliveredInShellArgs
} else {
const resolved = resolveWindowsShellLaunchArgs(
shellPath,
spawnCwd,
getDefaultCwd(),
sessionWslContext ?? preferredWslContext,
opts.command
)
shellArgs = resolved.shellArgs
spawnCwd = resolved.effectiveCwd
validationCwd = resolved.validationCwd
startupCommandDeliveredInShellArgs = resolved.startupCommandDeliveredInShellArgs === true
}
if (isWindowsGitBashShellPath(shellPath)) {
// Why: Git for Windows login startup files otherwise cd to $HOME,
// ignoring node-pty's cwd for repo-scoped terminals.
@@ -631,13 +716,23 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
let proc: pty.IPty
try {
proc = pty.spawn(shellPath, shellArgs, {
name: env.TERM ?? 'xterm-256color',
const spawned = spawnDaemonPtyWithWindowsFallback({
shellPath,
shellArgs,
spawnCwd,
env,
cols: size.cols,
rows: size.rows,
cwd: spawnCwd,
env
windowsFallbackAttempts
})
proc = spawned.process
// Why: a Windows fallback (e.g. cmd.exe) carries its own argv-embedded
// startup command, so adopt the winning shell's identity + delivery flag.
shellPath = spawned.shellPath
spawnCwd = spawned.spawnCwd
if (spawned.startupCommandDeliveredInShellArgs !== undefined) {
startupCommandDeliveredInShellArgs = spawned.startupCommandDeliveredInShellArgs
}
} catch (err) {
if (process.platform === 'win32') {
throw formatPtySpawnError(err, shellPath, spawnCwd)
@@ -39,6 +39,23 @@ vi.mock('node-pty', () => ({
spawn: spawnMock
}))
// Resolve PowerShell family names to deterministic absolute paths (the fs mock
// above otherwise makes every probe miss). The real resolver — which skips the
// Store App Execution Alias stub — is covered in
// windows-powershell-executable.test.ts.
const WINDOWS_POWERSHELL_ABS = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'
const PWSH7_ABS = 'C:\\Program Files\\PowerShell\\7\\pwsh.exe'
const CMD_ABS = 'C:\\Windows\\System32\\cmd.exe'
vi.mock('./windows-powershell-executable', () => ({
resolveWindowsPowerShellExecutablePath: (family: 'pwsh.exe' | 'powershell.exe') =>
family === 'pwsh.exe' ? PWSH7_ABS : WINDOWS_POWERSHELL_ABS,
resolveWindowsPowerShellSpawnChain: (family: 'pwsh.exe' | 'powershell.exe') =>
family === 'pwsh.exe'
? [PWSH7_ABS, WINDOWS_POWERSHELL_ABS, CMD_ABS]
: [WINDOWS_POWERSHELL_ABS, CMD_ABS],
getWindowsCmdPath: () => CMD_ABS
}))
vi.mock('./agent-foreground-process', () => ({
resolveAgentForegroundProcess: resolveAgentForegroundProcessMock
}))
+39 -16
View File
@@ -6,6 +6,7 @@ import { basename, delimiter } from 'path'
import { win32 as pathWin32 } from 'path'
import { resolveWindowsShellLaunchArgs } from './windows-shell-args'
import { resolveEffectiveWindowsPowerShell } from './windows-powershell'
import { buildWindowsPowerShellSpawnAttempts } from './windows-shell-fallback-chain'
import { resolveProcessCwd } from './process-cwd'
import { existsSync } from 'fs'
import * as pty from 'node-pty'
@@ -350,6 +351,7 @@ export class LocalPtyProvider implements IPtyProvider {
let effectiveCwd: string
let validationCwd: string
let startupCommandDeliveredInShellArgs = false
let windowsFallbackAttempts: ReturnType<typeof buildWindowsPowerShellSpawnAttempts> = []
let shellReadyLaunch: ReturnType<typeof getShellReadyLaunchConfig> | null = null
let getFallbackShellReadyConfig:
| ((shell: string) => ReturnType<typeof getShellReadyLaunchConfig>)
@@ -398,24 +400,39 @@ export class LocalPtyProvider implements IPtyProvider {
}) ?? shellFamily)
: shellFamily
}
// Why: one-off overrides and persisted shell-family selection keep the
// same priority, while the shared resolver chooses which PowerShell
// executable is safe to run right now if that family is selected.
// Why: both this path and the daemon-subprocess path must derive the
// same shellArgs for the same (shell, cwd) pair. The helper keeps CJK
// UTF-8 setup (chcp 65001), PowerShell $PROFILE dot-sourcing, and the
// wsl.exe /mnt/<drive> cwd translation in one place.
const resolved = resolveWindowsShellLaunchArgs(
// Why: when the selected shell is a PowerShell family, resolve it to a
// real absolute executable and build a PowerShell -> cmd.exe fallback
// chain. Handing ConPTY a bare `pwsh.exe` lets Windows resolve it to the
// Store App Execution Alias stub, whose spawn fails with error code 5.
// The shared launch-args helper inside keeps both this path and the
// daemon path producing identical args (chcp 65001 / $PROFILE / wsl cwd).
windowsFallbackAttempts = buildWindowsPowerShellSpawnAttempts({
shellPath,
cwd,
defaultCwd,
worktreeWslContext ?? preferredWslContext,
args.command
)
shellArgs = resolved.shellArgs
effectiveCwd = resolved.effectiveCwd
validationCwd = resolved.validationCwd
startupCommandDeliveredInShellArgs = resolved.startupCommandDeliveredInShellArgs === true
wslContext: worktreeWslContext ?? preferredWslContext,
startupCommand: args.command
})
const primaryAttempt = windowsFallbackAttempts[0]
if (primaryAttempt) {
shellPath = primaryAttempt.shellPath
shellArgs = primaryAttempt.shellArgs
effectiveCwd = primaryAttempt.effectiveCwd
validationCwd = primaryAttempt.validationCwd
startupCommandDeliveredInShellArgs = primaryAttempt.startupCommandDeliveredInShellArgs
} else {
const resolved = resolveWindowsShellLaunchArgs(
shellPath,
cwd,
defaultCwd,
worktreeWslContext ?? preferredWslContext,
args.command
)
shellArgs = resolved.shellArgs
effectiveCwd = resolved.effectiveCwd
validationCwd = resolved.validationCwd
startupCommandDeliveredInShellArgs = resolved.startupCommandDeliveredInShellArgs === true
}
} else {
shellPath = args.env?.SHELL || process.env.SHELL || '/bin/zsh'
shellArgs = ['-l']
@@ -623,9 +640,15 @@ export class LocalPtyProvider implements IPtyProvider {
// correct filename (see design doc §8).
onBeforeFallbackSpawn: historyResult?.histFile
? (env, fallbackShell) => updateHistFileForFallback(env, fallbackShell)
: undefined
: undefined,
windowsFallbackAttempts
})
shellPath = spawnResult.shellPath
// Why: a Windows fallback (e.g. cmd.exe) embeds its own startup command in
// argv, so honor the winning shell's delivery flag to avoid a double write.
if (spawnResult.startupCommandDeliveredInShellArgs !== undefined) {
startupCommandDeliveredInShellArgs = spawnResult.startupCommandDeliveredInShellArgs
}
if (args.command && getFallbackShellReadyConfig) {
shellReadyLaunch = getFallbackShellReadyConfig(shellPath)
}
@@ -0,0 +1,151 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type * as pty from 'node-pty'
import { spawnShellWithFallback, type WindowsShellSpawnAttempt } from './local-pty-utils'
function setPlatform(platform: NodeJS.Platform): () => void {
const original = process.platform
Object.defineProperty(process, 'platform', { configurable: true, value: platform })
return () => Object.defineProperty(process, 'platform', { configurable: true, value: original })
}
let restorePlatform: (() => void) | null = null
afterEach(() => {
restorePlatform?.()
restorePlatform = null
vi.restoreAllMocks()
})
const PWSH7 = 'C:\\Program Files\\PowerShell\\7\\pwsh.exe'
const WINDOWS_POWERSHELL = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'
const CMD = 'C:\\Windows\\System32\\cmd.exe'
function makeFakePty(): pty.IPty {
return { pid: 1234 } as unknown as pty.IPty
}
function makeAttempt(
shellPath: string,
overrides: Partial<WindowsShellSpawnAttempt> = {}
): WindowsShellSpawnAttempt {
return {
shellPath,
shellArgs: ['-NoLogo'],
effectiveCwd: 'C:\\repo',
validationCwd: 'C:\\repo',
startupCommandDeliveredInShellArgs: false,
...overrides
}
}
// error code 5 == ERROR_ACCESS_DENIED from CreateProcessW inside ConPTY when a
// bare/alias pwsh.exe is handed to node-pty.
const ACCESS_DENIED_5 =
'Cannot create process, error code: 5'
describe('spawnShellWithFallback on Windows', () => {
it('repro: recovers when the primary PowerShell spawn fails with error code 5', () => {
restorePlatform = setPlatform('win32')
vi.spyOn(console, 'warn').mockImplementation(() => {})
const attempts: WindowsShellSpawnAttempt[] = [
makeAttempt(PWSH7),
makeAttempt(WINDOWS_POWERSHELL),
makeAttempt(CMD, { shellArgs: ['/K', 'chcp 65001 > nul'] })
]
const ptySpawn = vi.fn((shellPath: string) => {
if (shellPath === PWSH7) {
throw new Error(ACCESS_DENIED_5)
}
return makeFakePty()
}) as unknown as typeof pty.spawn
const result = spawnShellWithFallback({
shellPath: PWSH7,
shellArgs: attempts[0].shellArgs,
cols: 80,
rows: 24,
cwd: 'C:\\repo',
env: {},
ptySpawn,
windowsFallbackAttempts: attempts
})
// Falls back to the next real absolute executable instead of throwing.
expect(result.shellPath).toBe(WINDOWS_POWERSHELL)
expect(ptySpawn).toHaveBeenNthCalledWith(
1,
PWSH7,
attempts[0].shellArgs,
expect.objectContaining({ cwd: 'C:\\repo' })
)
expect(ptySpawn).toHaveBeenNthCalledWith(
2,
WINDOWS_POWERSHELL,
attempts[1].shellArgs,
expect.objectContaining({ cwd: 'C:\\repo' })
)
})
it('falls all the way through to cmd.exe and surfaces its argv-delivery flag', () => {
restorePlatform = setPlatform('win32')
vi.spyOn(console, 'warn').mockImplementation(() => {})
const attempts: WindowsShellSpawnAttempt[] = [
makeAttempt(PWSH7),
makeAttempt(WINDOWS_POWERSHELL),
makeAttempt(CMD, {
shellArgs: ['/K', 'chcp 65001 > nul & npm start'],
startupCommandDeliveredInShellArgs: true
})
]
const ptySpawn = vi.fn((shellPath: string) => {
if (shellPath === CMD) {
return makeFakePty()
}
throw new Error(ACCESS_DENIED_5)
}) as unknown as typeof pty.spawn
const result = spawnShellWithFallback({
shellPath: PWSH7,
shellArgs: attempts[0].shellArgs,
cols: 80,
rows: 24,
cwd: 'C:\\repo',
env: {},
ptySpawn,
windowsFallbackAttempts: attempts
})
expect(result.shellPath).toBe(CMD)
expect(result.startupCommandDeliveredInShellArgs).toBe(true)
})
it('throws a descriptive error when every Windows fallback fails', () => {
restorePlatform = setPlatform('win32')
vi.spyOn(console, 'warn').mockImplementation(() => {})
const attempts: WindowsShellSpawnAttempt[] = [
makeAttempt(PWSH7),
makeAttempt(WINDOWS_POWERSHELL),
makeAttempt(CMD)
]
const ptySpawn = vi.fn(() => {
throw new Error(ACCESS_DENIED_5)
}) as unknown as typeof pty.spawn
expect(() =>
spawnShellWithFallback({
shellPath: PWSH7,
shellArgs: attempts[0].shellArgs,
cols: 80,
rows: 24,
cwd: 'C:\\repo',
env: {},
ptySpawn,
windowsFallbackAttempts: attempts
})
).toThrow(/Failed to spawn shell/)
})
})
+68 -2
View File
@@ -91,6 +91,17 @@ export function validateWorkingDirectory(cwd: string): void {
}
}
/** A pre-resolved Windows shell attempt: an absolute executable plus the launch
* args + cwd computed for it. Used to walk the PowerShell -> Windows PowerShell
* -> cmd.exe fallback chain when ConPTY rejects the primary shell. */
export type WindowsShellSpawnAttempt = {
shellPath: string
shellArgs: string[]
effectiveCwd: string
validationCwd: string
startupCommandDeliveredInShellArgs: boolean
}
export type ShellSpawnParams = {
shellPath: string
shellArgs: string[]
@@ -106,16 +117,64 @@ export type ShellSpawnParams = {
/** Called before each fallback shell spawn so callers can update env vars
* (e.g. HISTFILE) that depend on which shell is about to run. */
onBeforeFallbackSpawn?: (env: Record<string, string>, fallbackShell: string) => void
/** Windows-only ordered fallback chain (PowerShell -> Windows PowerShell ->
* cmd.exe). When the first attempt (which must match shellPath/shellArgs)
* fails to spawn, the next real absolute executable is tried with its own
* recomputed args/cwd. */
windowsFallbackAttempts?: WindowsShellSpawnAttempt[]
}
export type ShellSpawnResult = {
process: pty.IPty
shellPath: string
/** True when the winning shell's startup command was already embedded in its
* argv, so callers must not re-deliver it through stdin. Only set when a
* Windows fallback attempt other than the primary was used. */
startupCommandDeliveredInShellArgs?: boolean
}
/**
* Attempt to spawn a PTY shell. If the primary shell fails on Unix,
* try common fallback shells before giving up.
* Walk the Windows PowerShell -> Windows PowerShell -> cmd.exe fallback chain.
*
* Why: ConPTY's CreateProcessW rejects a Store App Execution Alias stub with
* ERROR_ACCESS_DENIED (error code 5). The chain entries are real absolute
* executables with per-shell args, so when the primary fails we retry with the
* next safe shell instead of leaving the user with no terminal.
*/
function spawnWindowsFallbackChain(
params: ShellSpawnParams,
primaryError: string
): ShellSpawnResult | null {
const { termName = 'xterm-256color', cols, rows, env, ptySpawn } = params
const attempts = params.windowsFallbackAttempts ?? []
// Skip the first entry: it is the primary that already failed above.
for (const attempt of attempts.slice(1)) {
try {
const proc = ptySpawn(attempt.shellPath, attempt.shellArgs, {
name: termName,
cols,
rows,
cwd: attempt.effectiveCwd,
env
})
console.warn(
`[pty] Primary shell "${params.shellPath}" failed (${primaryError}), fell back to "${attempt.shellPath}"`
)
return {
process: proc,
shellPath: attempt.shellPath,
startupCommandDeliveredInShellArgs: attempt.startupCommandDeliveredInShellArgs
}
} catch {
// This fallback shell also failed -- try the next link in the chain.
}
}
return null
}
/**
* Attempt to spawn a PTY shell. If the primary shell fails, try fallback shells
* (Unix: zsh/bash/sh; Windows: the PowerShell -> cmd.exe chain) before giving up.
*/
export function spawnShellWithFallback(params: ShellSpawnParams): ShellSpawnResult {
const {
@@ -147,6 +206,13 @@ export function spawnShellWithFallback(params: ShellSpawnParams): ShellSpawnResu
}
}
if (process.platform === 'win32') {
const fallback = spawnWindowsFallbackChain(params, primaryError ?? 'unknown error')
if (fallback) {
return fallback
}
}
// Try fallback shells on Unix
if (process.platform !== 'win32') {
const fallbackShells = ['/bin/zsh', '/bin/bash', '/bin/sh'].filter((s) => s !== shellPath)
@@ -0,0 +1,126 @@
import { describe, expect, it } from 'vitest'
import {
getWindowsCmdPath,
resolveWindowsPowerShellExecutablePath,
resolveWindowsPowerShellSpawnChain
} from './windows-powershell-executable'
const WIN_ENV: NodeJS.ProcessEnv = {
ProgramW6432: 'C:\\Program Files',
'ProgramFiles(x86)': 'C:\\Program Files (x86)',
LOCALAPPDATA: 'C:\\Users\\dev\\AppData\\Local',
SystemRoot: 'C:\\Windows',
ComSpec: 'C:\\Windows\\System32\\cmd.exe'
}
const PWSH7 = 'C:\\Program Files\\PowerShell\\7\\pwsh.exe'
const WINDOWS_POWERSHELL =
'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'
// The Microsoft Store App Execution Alias stub for pwsh — a zero-byte reparse
// point under WindowsApps that ConPTY's CreateProcessW rejects with error 5.
const PWSH_STORE_ALIAS =
'C:\\Users\\dev\\AppData\\Local\\Microsoft\\WindowsApps\\pwsh.exe'
describe('resolveWindowsPowerShellExecutablePath', () => {
it('returns null on non-Windows platforms', () => {
expect(
resolveWindowsPowerShellExecutablePath('pwsh.exe', {
platform: 'linux',
env: WIN_ENV,
isRealExecutable: () => true
})
).toBeNull()
})
it('resolves pwsh.exe to a real absolute path under Program Files', () => {
expect(
resolveWindowsPowerShellExecutablePath('pwsh.exe', {
platform: 'win32',
env: WIN_ENV,
isRealExecutable: (p) => p === PWSH7
})
).toBe(PWSH7)
})
it('resolves powershell.exe to inbox System32 WindowsPowerShell', () => {
expect(
resolveWindowsPowerShellExecutablePath('powershell.exe', {
platform: 'win32',
env: WIN_ENV,
isRealExecutable: (p) => p === WINDOWS_POWERSHELL
})
).toBe(WINDOWS_POWERSHELL)
})
it('repro: never resolves pwsh.exe to the Store App Execution Alias stub', () => {
// Only the WindowsApps alias "exists"; a naive resolver would return it and
// ConPTY would fail with error code 5. The resolver must reject it.
const resolved = resolveWindowsPowerShellExecutablePath('pwsh.exe', {
platform: 'win32',
env: WIN_ENV,
isRealExecutable: (p) => p === PWSH_STORE_ALIAS
})
expect(resolved).toBeNull()
expect(resolved).not.toBe(PWSH_STORE_ALIAS)
})
it('returns null for pwsh when no real executable is found', () => {
expect(
resolveWindowsPowerShellExecutablePath('pwsh.exe', {
platform: 'win32',
env: WIN_ENV,
isRealExecutable: () => false
})
).toBeNull()
})
})
describe('resolveWindowsPowerShellSpawnChain', () => {
it('orders pwsh -> Windows PowerShell -> cmd.exe when all resolve', () => {
const chain = resolveWindowsPowerShellSpawnChain('pwsh.exe', {
platform: 'win32',
env: WIN_ENV,
isRealExecutable: (p) => p === PWSH7 || p === WINDOWS_POWERSHELL
})
expect(chain).toEqual([PWSH7, WINDOWS_POWERSHELL, WIN_ENV.ComSpec])
})
it('falls back to Windows PowerShell + cmd.exe when pwsh is only a Store alias', () => {
const chain = resolveWindowsPowerShellSpawnChain('pwsh.exe', {
platform: 'win32',
env: WIN_ENV,
// pwsh exists only as the alias stub (rejected); Windows PowerShell is real.
isRealExecutable: (p) => p === PWSH_STORE_ALIAS || p === WINDOWS_POWERSHELL
})
expect(chain).toEqual([WINDOWS_POWERSHELL, WIN_ENV.ComSpec])
expect(chain).not.toContain(PWSH_STORE_ALIAS)
})
it('always ends with cmd.exe even when no PowerShell resolves', () => {
const chain = resolveWindowsPowerShellSpawnChain('powershell.exe', {
platform: 'win32',
env: WIN_ENV,
isRealExecutable: () => false
})
expect(chain).toEqual([WIN_ENV.ComSpec])
})
it('does not duplicate cmd.exe when ComSpec is missing', () => {
const chain = resolveWindowsPowerShellSpawnChain('powershell.exe', {
platform: 'win32',
env: { SystemRoot: 'C:\\Windows' },
isRealExecutable: () => false
})
expect(chain).toEqual(['C:\\Windows\\System32\\cmd.exe'])
})
})
describe('getWindowsCmdPath', () => {
it('prefers ComSpec when set', () => {
expect(getWindowsCmdPath(WIN_ENV)).toBe('C:\\Windows\\System32\\cmd.exe')
})
it('derives cmd.exe from SystemRoot when ComSpec is absent', () => {
expect(getWindowsCmdPath({ SystemRoot: 'D:\\Win' })).toBe('D:\\Win\\System32\\cmd.exe')
})
})
@@ -0,0 +1,177 @@
import { existsSync, statSync } from 'fs'
import { win32 as pathWin32 } from 'path'
/** Dependency seams so the resolver can be unit-tested without a real Windows
* filesystem. Production callers omit these and get process.env / fs. */
export type WindowsPowerShellResolveOptions = {
env?: NodeJS.ProcessEnv
/** Returns true when the candidate path is a real, non-empty executable. */
isRealExecutable?: (path: string) => boolean
platform?: NodeJS.Platform
}
/** Why: the Microsoft Store ships `pwsh.exe`/`powershell.exe` App Execution
* Alias stubs under WindowsApps. They are zero-byte reparse points that
* execFileSync can launch (PATH lookup follows the alias) but ConPTY's
* CreateProcessW(lpApplicationName=<abs stub>) rejects with ERROR_ACCESS_DENIED
* (error code 5). Never resolve a shell to a path inside this directory. */
function isWindowsAppExecutionAliasPath(candidate: string): boolean {
return /[\\/]Microsoft[\\/]WindowsApps[\\/]/i.test(pathWin32.normalize(candidate))
}
/** A real executable is a regular file with non-zero size. The App Execution
* Alias stubs report size 0, so the size check rejects them even if a future
* Windows build relocates them outside WindowsApps. */
function defaultIsRealExecutable(candidate: string): boolean {
if (isWindowsAppExecutionAliasPath(candidate)) {
return false
}
try {
if (!existsSync(candidate)) {
return false
}
const stat = statSync(candidate)
return stat.isFile() && stat.size > 0
} catch {
return false
}
}
function readEnv(env: NodeJS.ProcessEnv, names: string[]): string | undefined {
for (const name of names) {
const value = env[name]
if (value) {
return value
}
}
return undefined
}
function pushUniqueCandidate(
candidates: string[],
seen: Set<string>,
candidate: string | undefined
): void {
if (!candidate) {
return
}
const normalized = pathWin32.normalize(candidate)
const key = normalized.toLowerCase()
if (!seen.has(key)) {
seen.add(key)
candidates.push(normalized)
}
}
/** Known absolute install locations for PowerShell 7 (`pwsh.exe`), ordered by
* preference: per-machine MSI installs first, then per-user. */
function getPwshCandidatePaths(env: NodeJS.ProcessEnv): string[] {
const candidates: string[] = []
const seen = new Set<string>()
const programFilesRoots = [
readEnv(env, ['ProgramW6432', 'PROGRAMW6432']),
readEnv(env, ['ProgramFiles', 'PROGRAMFILES']),
readEnv(env, ['ProgramFiles(x86)', 'PROGRAMFILES(X86)'])
]
for (const root of programFilesRoots) {
if (!root) {
continue
}
// PowerShell 7 MSI installs land in `PowerShell\7\pwsh.exe`. Glob the major
// version conservatively (6/7/8) so future majors keep resolving.
for (const major of ['7', '8', '6']) {
pushUniqueCandidate(candidates, seen, pathWin32.join(root, 'PowerShell', major, 'pwsh.exe'))
}
}
// Per-user dotnet-tool / winget install location.
const localAppData = readEnv(env, ['LOCALAPPDATA', 'LocalAppData'])
if (localAppData) {
for (const major of ['7', '8', '6']) {
pushUniqueCandidate(
candidates,
seen,
pathWin32.join(localAppData, 'Microsoft', 'PowerShell', major, 'pwsh.exe')
)
}
}
return candidates
}
/** Absolute path to inbox Windows PowerShell (`powershell.exe`), which always
* ships at System32\WindowsPowerShell\v1.0 on a stock Windows install. */
function getWindowsPowerShellPath(env: NodeJS.ProcessEnv): string {
const systemRoot = readEnv(env, ['SystemRoot', 'WINDIR', 'windir']) || 'C:\\Windows'
return pathWin32.join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe')
}
/** Absolute path to cmd.exe, the last-resort fallback shell. */
export function getWindowsCmdPath(env: NodeJS.ProcessEnv = process.env): string {
const comspec = readEnv(env, ['ComSpec', 'COMSPEC'])
if (comspec) {
return comspec
}
const systemRoot = readEnv(env, ['SystemRoot', 'WINDIR', 'windir']) || 'C:\\Windows'
return pathWin32.join(systemRoot, 'System32', 'cmd.exe')
}
/**
* Resolve a PowerShell family name to a real absolute executable path.
*
* Returns null when no real executable can be found for the family (e.g. pwsh.exe
* is only present as a Store App Execution Alias stub). Callers fall back to the
* next link in the Windows shell chain when this returns null.
*/
export function resolveWindowsPowerShellExecutablePath(
family: 'pwsh.exe' | 'powershell.exe',
options: WindowsPowerShellResolveOptions = {}
): string | null {
const platform = options.platform ?? process.platform
if (platform !== 'win32') {
return null
}
const env = options.env ?? process.env
const isRealExecutable = options.isRealExecutable ?? defaultIsRealExecutable
if (family === 'powershell.exe') {
const windowsPowerShell = getWindowsPowerShellPath(env)
return isRealExecutable(windowsPowerShell) ? windowsPowerShell : null
}
for (const candidate of getPwshCandidatePaths(env)) {
if (isRealExecutable(candidate)) {
return candidate
}
}
return null
}
/**
* Build the ordered Windows shell-spawn fallback chain for a requested
* PowerShell family. Each entry is a real absolute path that ConPTY can launch.
*
* Order: requested PowerShell (resolved abs) -> inbox Windows PowerShell (abs)
* -> cmd.exe. Duplicates and unresolved links are dropped. The chain is never
* empty because cmd.exe always closes it.
*/
export function resolveWindowsPowerShellSpawnChain(
requestedFamily: 'pwsh.exe' | 'powershell.exe',
options: WindowsPowerShellResolveOptions = {}
): string[] {
const env = options.env ?? process.env
const chain: string[] = []
const seen = new Set<string>()
const add = (candidate: string | null): void => {
if (candidate) {
pushUniqueCandidate(chain, seen, candidate)
}
}
add(resolveWindowsPowerShellExecutablePath(requestedFamily, options))
// Always offer inbox Windows PowerShell as the next link: it is present on
// every supported Windows and is not subject to the Store alias hazard.
add(resolveWindowsPowerShellExecutablePath('powershell.exe', options))
// cmd.exe is the guaranteed last resort so a terminal still opens even when
// both PowerShell flavors are blocked by AV/AppLocker/SAC.
pushUniqueCandidate(chain, seen, getWindowsCmdPath(env))
return chain
}
@@ -0,0 +1,80 @@
import { afterEach, describe, expect, it } from 'vitest'
import { win32 as pathWin32 } from 'path'
import { buildWindowsPowerShellSpawnAttempts } from './windows-shell-fallback-chain'
const WIN_ENV: NodeJS.ProcessEnv = {
ProgramW6432: 'C:\\Program Files',
SystemRoot: 'C:\\Windows',
ComSpec: 'C:\\Windows\\System32\\cmd.exe'
}
const PWSH7 = 'C:\\Program Files\\PowerShell\\7\\pwsh.exe'
const WINDOWS_POWERSHELL = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'
const CMD = 'C:\\Windows\\System32\\cmd.exe'
function setPlatform(platform: NodeJS.Platform): () => void {
const original = process.platform
Object.defineProperty(process, 'platform', { configurable: true, value: platform })
return () => Object.defineProperty(process, 'platform', { configurable: true, value: original })
}
let restorePlatform: (() => void) | null = null
afterEach(() => {
restorePlatform?.()
restorePlatform = null
})
describe('buildWindowsPowerShellSpawnAttempts', () => {
it('returns no attempts for non-PowerShell shells (cmd.exe keeps single-shell behavior)', () => {
restorePlatform = setPlatform('win32')
expect(
buildWindowsPowerShellSpawnAttempts({
shellPath: 'cmd.exe',
cwd: 'C:\\repo',
defaultCwd: 'C:\\Users\\dev'
})
).toEqual([])
})
it('builds pwsh -> Windows PowerShell -> cmd.exe attempts with per-shell args', () => {
restorePlatform = setPlatform('win32')
const attempts = buildWindowsPowerShellSpawnAttempts({
shellPath: 'pwsh.exe',
cwd: 'C:\\repo',
defaultCwd: 'C:\\Users\\dev',
resolveOptions: {
platform: 'win32',
env: WIN_ENV,
isRealExecutable: (p) => p === PWSH7 || p === WINDOWS_POWERSHELL
}
})
expect(attempts.map((a) => a.shellPath)).toEqual([PWSH7, WINDOWS_POWERSHELL, CMD])
// PowerShell links use -EncodedCommand; cmd.exe uses /K chcp.
expect(attempts[0].shellArgs).toContain('-EncodedCommand')
expect(attempts[1].shellArgs).toContain('-EncodedCommand')
expect(attempts[2].shellArgs[0]).toBe('/K')
})
it('repro: when pwsh is only a Store alias, the primary attempt is the real Windows PowerShell', () => {
restorePlatform = setPlatform('win32')
const aliasStub = 'C:\\Users\\dev\\AppData\\Local\\Microsoft\\WindowsApps\\pwsh.exe'
const attempts = buildWindowsPowerShellSpawnAttempts({
shellPath: 'pwsh.exe',
cwd: 'C:\\repo',
defaultCwd: 'C:\\Users\\dev',
resolveOptions: {
platform: 'win32',
env: WIN_ENV,
isRealExecutable: (p) => p === aliasStub || p === WINDOWS_POWERSHELL
}
})
// The bare/alias pwsh.exe must never be the primary spawn target.
expect(attempts[0].shellPath).toBe(WINDOWS_POWERSHELL)
expect(attempts.map((a) => a.shellPath)).not.toContain(aliasStub)
expect(attempts.map((a) => a.shellPath)).not.toContain('pwsh.exe')
// Every attempt is an absolute path ConPTY can launch.
for (const attempt of attempts) {
expect(pathWin32.isAbsolute(attempt.shellPath)).toBe(true)
}
})
})
@@ -0,0 +1,71 @@
import { win32 as pathWin32 } from 'path'
import { resolveWindowsShellLaunchArgs } from './windows-shell-args'
import type { WindowsShellWslContext } from './windows-shell-args'
import {
resolveWindowsPowerShellSpawnChain,
type WindowsPowerShellResolveOptions
} from './windows-powershell-executable'
/** A single attempt in the Windows shell-spawn fallback chain: the absolute
* executable plus the launch args + cwd computed for it. */
export type WindowsShellSpawnAttempt = {
shellPath: string
shellArgs: string[]
effectiveCwd: string
validationCwd: string
startupCommandDeliveredInShellArgs: boolean
}
function toAttempt(
shellPath: string,
cwd: string,
defaultCwd: string,
wslContext: WindowsShellWslContext | undefined,
startupCommand: string | undefined
): WindowsShellSpawnAttempt {
const resolved = resolveWindowsShellLaunchArgs(
shellPath,
cwd,
defaultCwd,
wslContext,
startupCommand
)
return {
shellPath,
shellArgs: resolved.shellArgs,
effectiveCwd: resolved.effectiveCwd,
validationCwd: resolved.validationCwd,
startupCommandDeliveredInShellArgs: resolved.startupCommandDeliveredInShellArgs === true
}
}
/**
* Build the ordered list of Windows PowerShell spawn attempts for a resolved
* PowerShell shell path.
*
* Why: handing ConPTY a bare `pwsh.exe` lets Windows resolve it to the Store
* App Execution Alias stub, whose CreateProcessW launch fails with
* ERROR_ACCESS_DENIED (error code 5). Each attempt here is a real absolute
* executable: requested PowerShell -> inbox Windows PowerShell -> cmd.exe, with
* args recomputed per shell so the cmd.exe fallback still gets `chcp 65001`.
*
* Returns an empty array when `shellPath` is not a PowerShell family, so callers
* keep their existing single-shell behavior for cmd.exe / wsl.exe / Git Bash.
*/
export function buildWindowsPowerShellSpawnAttempts(args: {
shellPath: string
cwd: string
defaultCwd: string
wslContext?: WindowsShellWslContext
startupCommand?: string
resolveOptions?: WindowsPowerShellResolveOptions
}): WindowsShellSpawnAttempt[] {
const basename = pathWin32.basename(args.shellPath).toLowerCase()
if (basename !== 'pwsh.exe' && basename !== 'powershell.exe') {
return []
}
const chain = resolveWindowsPowerShellSpawnChain(basename, args.resolveOptions)
return chain.map((candidate) =>
toAttempt(candidate, args.cwd, args.defaultCwd, args.wslContext, args.startupCommand)
)
}