fix(core): prefer pwsh.exe over Windows PowerShell 5.1 (#25859) (#25900)

Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
This commit is contained in:
kaluchi
2026-05-18 20:44:05 +03:00
committed by GitHub
parent 7ff60809ef
commit dc47aaa2d9
10 changed files with 203 additions and 94 deletions
+61 -41
View File
@@ -38,17 +38,13 @@ vi.mock('os', () => ({
homedir: mockHomedir,
}));
const mockAccess = vi.hoisted(() => vi.fn());
const mockAccessSync = vi.hoisted(() => vi.fn());
vi.mock('node:fs', () => ({
default: {
promises: {
access: mockAccess,
},
accessSync: mockAccessSync,
constants: { X_OK: 1 },
},
promises: {
access: mockAccess,
},
accessSync: mockAccessSync,
constants: { X_OK: 1 },
}));
@@ -458,10 +454,8 @@ describe('escapeShellArg', () => {
});
describe('getShellConfiguration', () => {
const originalEnv = { ...process.env };
afterEach(() => {
process.env = originalEnv;
vi.unstubAllEnvs();
});
it('should return bash configuration on Linux', () => {
@@ -483,19 +477,38 @@ describe('getShellConfiguration', () => {
describe('on Windows', () => {
beforeEach(() => {
mockPlatform.mockReturnValue('win32');
vi.stubEnv('ComSpec', '');
mockAccessSync.mockImplementation(() => {
throw new Error('ENOENT');
});
});
it('should return PowerShell configuration by default', () => {
delete process.env['ComSpec'];
const config = getShellConfiguration();
expect(config.executable).toBe('powershell.exe');
expect(config.argsPrefix).toEqual(['-NoProfile', '-Command']);
expect(config.shell).toBe('powershell');
});
it.skipIf(!isWindowsRuntime)(
'should prefer pwsh.exe over powershell.exe when pwsh is available in PATH',
() => {
const pwshDir = path.resolve('C:\\Program Files\\PowerShell\\7');
const pwshPath = path.join(pwshDir, 'pwsh.exe');
vi.stubEnv('PATH', pwshDir);
mockAccessSync.mockImplementation((p: string) => {
if (p === pwshPath) return;
throw new Error('ENOENT');
});
const config = getShellConfiguration();
expect(config.executable).toBe(pwshPath);
expect(config.argsPrefix).toEqual(['-NoProfile', '-Command']);
expect(config.shell).toBe('powershell');
},
);
it('should ignore ComSpec when pointing to cmd.exe', () => {
const cmdPath = 'C:\\WINDOWS\\system32\\cmd.exe';
process.env['ComSpec'] = cmdPath;
vi.stubEnv('ComSpec', 'C:\\WINDOWS\\system32\\cmd.exe');
const config = getShellConfiguration();
expect(config.executable).toBe('powershell.exe');
expect(config.argsPrefix).toEqual(['-NoProfile', '-Command']);
@@ -505,7 +518,7 @@ describe('getShellConfiguration', () => {
it('should return PowerShell configuration if ComSpec points to powershell.exe', () => {
const psPath =
'C:\\WINDOWS\\System32\\WindowsPowerShell\\v1.0\\powershell.exe';
process.env['ComSpec'] = psPath;
vi.stubEnv('ComSpec', psPath);
const config = getShellConfiguration();
expect(config.executable).toBe(psPath);
expect(config.argsPrefix).toEqual(['-NoProfile', '-Command']);
@@ -514,7 +527,7 @@ describe('getShellConfiguration', () => {
it('should return PowerShell configuration if ComSpec points to pwsh.exe', () => {
const pwshPath = 'C:\\Program Files\\PowerShell\\7\\pwsh.exe';
process.env['ComSpec'] = pwshPath;
vi.stubEnv('ComSpec', pwshPath);
const config = getShellConfiguration();
expect(config.executable).toBe(pwshPath);
expect(config.argsPrefix).toEqual(['-NoProfile', '-Command']);
@@ -522,7 +535,7 @@ describe('getShellConfiguration', () => {
});
it('should be case-insensitive when checking ComSpec', () => {
process.env['ComSpec'] = 'C:\\Path\\To\\POWERSHELL.EXE';
vi.stubEnv('ComSpec', 'C:\\Path\\To\\POWERSHELL.EXE');
const config = getShellConfiguration();
expect(config.executable).toBe('C:\\Path\\To\\POWERSHELL.EXE');
expect(config.argsPrefix).toEqual(['-NoProfile', '-Command']);
@@ -566,63 +579,70 @@ describe('hasRedirection (PowerShell via mock)', () => {
});
describe('resolveExecutable', () => {
const originalEnv = process.env;
beforeEach(() => {
process.env = { ...originalEnv };
mockAccess.mockReset();
mockAccessSync.mockReset();
});
afterEach(() => {
process.env = originalEnv;
vi.unstubAllEnvs();
});
it('should return the absolute path if it exists and is executable', async () => {
it('should return the absolute path if it exists and is executable', () => {
const absPath = path.resolve('/usr/bin/git');
mockAccess.mockResolvedValue(undefined); // success
expect(await resolveExecutable(absPath)).toBe(absPath);
expect(mockAccess).toHaveBeenCalledWith(absPath, 1);
mockAccessSync.mockImplementation(() => undefined);
expect(resolveExecutable(absPath)).toBe(absPath);
expect(mockAccessSync).toHaveBeenCalledWith(absPath, 1);
});
it('should return undefined for absolute path if it does not exist', async () => {
it('should return undefined for absolute path if it does not exist', () => {
const absPath = path.resolve('/usr/bin/nonexistent');
mockAccess.mockRejectedValue(new Error('ENOENT'));
expect(await resolveExecutable(absPath)).toBeUndefined();
mockAccessSync.mockImplementation(() => {
throw new Error('ENOENT');
});
expect(resolveExecutable(absPath)).toBeUndefined();
});
it('should resolve executable in PATH', async () => {
it('should resolve executable in PATH', () => {
const binDir = path.resolve('/bin');
const usrBinDir = path.resolve('/usr/bin');
process.env['PATH'] = `${binDir}${path.delimiter}${usrBinDir}`;
vi.stubEnv('PATH', `${binDir}${path.delimiter}${usrBinDir}`);
mockPlatform.mockReturnValue('linux');
const targetPath = path.join(usrBinDir, 'ls');
mockAccess.mockImplementation(async (p: string) => {
mockAccessSync.mockImplementation((p: string) => {
if (p === targetPath) return undefined;
throw new Error('ENOENT');
});
expect(await resolveExecutable('ls')).toBe(targetPath);
expect(resolveExecutable('ls')).toBe(targetPath);
});
it('should try extensions on Windows', async () => {
it('should try extensions on Windows', () => {
const sys32 = path.resolve('C:\\Windows\\System32');
process.env['PATH'] = sys32;
vi.stubEnv('PATH', sys32);
mockPlatform.mockReturnValue('win32');
mockAccess.mockImplementation(async (p: string) => {
// Use includes because on Windows path separators might differ
mockAccessSync.mockImplementation((p: string) => {
if (p.includes('cmd.exe')) return undefined;
throw new Error('ENOENT');
});
expect(await resolveExecutable('cmd')).toContain('cmd.exe');
expect(resolveExecutable('cmd')).toContain('cmd.exe');
});
it('should return undefined if not found in PATH', async () => {
process.env['PATH'] = path.resolve('/bin');
it('should return undefined if not found in PATH', () => {
vi.stubEnv('PATH', path.resolve('/bin'));
mockPlatform.mockReturnValue('linux');
mockAccess.mockRejectedValue(new Error('ENOENT'));
mockAccessSync.mockImplementation(() => {
throw new Error('ENOENT');
});
expect(await resolveExecutable('unknown')).toBeUndefined();
expect(resolveExecutable('unknown')).toBeUndefined();
});
it('should return undefined if PATH is unset', () => {
vi.stubEnv('PATH', '');
mockPlatform.mockReturnValue('linux');
expect(resolveExecutable('anything')).toBeUndefined();
});
});
+37 -19
View File
@@ -76,29 +76,30 @@ export interface ShellConfiguration {
shell: ShellType;
}
export async function resolveExecutable(
exe: string,
): Promise<string | undefined> {
if (path.isAbsolute(exe)) {
try {
await fs.promises.access(exe, fs.constants.X_OK);
return exe;
} catch {
return undefined;
}
function isExecutable(filePath: string): boolean {
try {
fs.accessSync(filePath, fs.constants.X_OK);
return true;
} catch {
return false;
}
}
export function resolveExecutable(exe: string): string | undefined {
if (path.isAbsolute(exe)) {
return isExecutable(exe) ? exe : undefined;
}
const pathEnv = process.env['PATH'];
if (!pathEnv) {
return undefined;
}
const paths = (process.env['PATH'] || '').split(path.delimiter);
const extensions =
os.platform() === 'win32' ? ['.exe', '.cmd', '.bat', ''] : [''];
for (const p of paths) {
for (const dir of pathEnv.split(path.delimiter)) {
for (const ext of extensions) {
const fullPath = path.join(p, exe + ext);
try {
await fs.promises.access(fullPath, fs.constants.X_OK);
const fullPath = path.join(dir, exe + ext);
if (isExecutable(fullPath)) {
return fullPath;
} catch {
continue;
}
}
}
@@ -644,6 +645,14 @@ export function parseCommandDetails(
* This ensures we can execute command strings predictably and securely across platforms
* using the `spawn(executable, [...argsPrefix, commandString], { shell: false })` pattern.
*
* On Windows, PowerShell 7 (pwsh.exe) is preferred over Windows PowerShell 5.1
* (powershell.exe) when available on PATH. Windows PowerShell 5.1 silently
* strips embedded double quotes from arguments to native executables see
* issue #25859. PowerShell 7 uses standards-compliant argument passing and
* does not exhibit this regression. When pwsh.exe is not installed, we fall
* back to powershell.exe to preserve the existing behavior and the full
* cmdlet surface users depend on.
*
* @returns The ShellConfiguration for the current environment.
*/
export function getShellConfiguration(): ShellConfiguration {
@@ -663,7 +672,16 @@ export function getShellConfiguration(): ShellConfiguration {
}
}
// Default to PowerShell for all other Windows configurations.
const pwshPath = resolveExecutable('pwsh.exe');
if (pwshPath) {
return {
executable: pwshPath,
argsPrefix: ['-NoProfile', '-Command'],
shell: 'powershell',
};
}
// Fall back to Windows PowerShell 5.1 when pwsh.exe is not installed.
return {
executable: 'powershell.exe',
argsPrefix: ['-NoProfile', '-Command'],