mirror of
https://github.com/stablyai/orca.git
synced 2026-10-28 08:02:54 +00:00
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:
@@ -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' })
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}))
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user