From 43daa2e34c6d909a60c12c673e22bb88d3763508 Mon Sep 17 00:00:00 2001 From: brennanb2025 Date: Sat, 27 Jun 2026 21:58:15 -0700 Subject: [PATCH] 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 --- src/main/daemon/pty-subprocess.test.ts | 26 ++- src/main/daemon/pty-subprocess.ts | 133 +++++++++++-- src/main/providers/local-pty-provider.test.ts | 17 ++ src/main/providers/local-pty-provider.ts | 55 ++++-- .../local-pty-utils-windows-fallback.test.ts | 151 +++++++++++++++ src/main/providers/local-pty-utils.ts | 70 ++++++- .../windows-powershell-executable.test.ts | 126 +++++++++++++ .../windows-powershell-executable.ts | 177 ++++++++++++++++++ .../windows-shell-fallback-chain.test.ts | 80 ++++++++ .../providers/windows-shell-fallback-chain.ts | 71 +++++++ 10 files changed, 864 insertions(+), 42 deletions(-) create mode 100644 src/main/providers/local-pty-utils-windows-fallback.test.ts create mode 100644 src/main/providers/windows-powershell-executable.test.ts create mode 100644 src/main/providers/windows-powershell-executable.ts create mode 100644 src/main/providers/windows-shell-fallback-chain.test.ts create mode 100644 src/main/providers/windows-shell-fallback-chain.ts diff --git a/src/main/daemon/pty-subprocess.test.ts b/src/main/daemon/pty-subprocess.test.ts index 3014e03b0f5..6becd96b330 100644 --- a/src/main/daemon/pty-subprocess.test.ts +++ b/src/main/daemon/pty-subprocess.test.ts @@ -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() 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' }) ) diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts index b0f0084f7b3..2418fabe8a0 100644 --- a/src/main/daemon/pty-subprocess.ts +++ b/src/main/daemon/pty-subprocess.ts @@ -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 + 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) diff --git a/src/main/providers/local-pty-provider.test.ts b/src/main/providers/local-pty-provider.test.ts index 06f05fb3386..49114e38c3c 100644 --- a/src/main/providers/local-pty-provider.test.ts +++ b/src/main/providers/local-pty-provider.test.ts @@ -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 })) diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index 7898285c2ac..e1bd6f2870c 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -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 = [] let shellReadyLaunch: ReturnType | null = null let getFallbackShellReadyConfig: | ((shell: string) => ReturnType) @@ -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/ 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) } diff --git a/src/main/providers/local-pty-utils-windows-fallback.test.ts b/src/main/providers/local-pty-utils-windows-fallback.test.ts new file mode 100644 index 00000000000..97fd3b6ea8f --- /dev/null +++ b/src/main/providers/local-pty-utils-windows-fallback.test.ts @@ -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 { + 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/) + }) +}) diff --git a/src/main/providers/local-pty-utils.ts b/src/main/providers/local-pty-utils.ts index 4dc42c3c044..5e7bac78153 100644 --- a/src/main/providers/local-pty-utils.ts +++ b/src/main/providers/local-pty-utils.ts @@ -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, 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) diff --git a/src/main/providers/windows-powershell-executable.test.ts b/src/main/providers/windows-powershell-executable.test.ts new file mode 100644 index 00000000000..28400332c01 --- /dev/null +++ b/src/main/providers/windows-powershell-executable.test.ts @@ -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') + }) +}) diff --git a/src/main/providers/windows-powershell-executable.ts b/src/main/providers/windows-powershell-executable.ts new file mode 100644 index 00000000000..f630df5bfc5 --- /dev/null +++ b/src/main/providers/windows-powershell-executable.ts @@ -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=) 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, + 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() + 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() + 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 +} diff --git a/src/main/providers/windows-shell-fallback-chain.test.ts b/src/main/providers/windows-shell-fallback-chain.test.ts new file mode 100644 index 00000000000..e0f8ad19966 --- /dev/null +++ b/src/main/providers/windows-shell-fallback-chain.test.ts @@ -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) + } + }) +}) diff --git a/src/main/providers/windows-shell-fallback-chain.ts b/src/main/providers/windows-shell-fallback-chain.ts new file mode 100644 index 00000000000..4dac7d8fcfb --- /dev/null +++ b/src/main/providers/windows-shell-fallback-chain.ts @@ -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) + ) +}