Skip to content

Commit dc47aaa

Browse files
kaluchiscidomino
andauthored
fix(core): prefer pwsh.exe over Windows PowerShell 5.1 (#25859) (#25900)
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
1 parent 7ff6080 commit dc47aaa

10 files changed

Lines changed: 202 additions & 93 deletions

packages/cli/src/ui/components/SessionSummaryDisplay.test.tsx

Lines changed: 1 addition & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import { useConfig } from '../contexts/ConfigContext.js';
1212
import { type SessionMetrics } from '../contexts/SessionContext.js';
1313
import {
1414
ToolCallDecision,
15-
getShellConfiguration,
1615
isWindows,
1716
type WorktreeSettings,
1817
} from '@google/gemini-cli-core';
@@ -22,7 +21,6 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
2221
await importOriginal<typeof import('@google/gemini-cli-core')>();
2322
return {
2423
...actual,
25-
getShellConfiguration: vi.fn(),
2624
isWindows: vi.fn(),
2725
};
2826
});
@@ -45,7 +43,6 @@ vi.mock('../contexts/ConfigContext.js', async (importOriginal) => {
4543
};
4644
});
4745

48-
const getShellConfigurationMock = vi.mocked(getShellConfiguration);
4946
const isWindowsMock = vi.mocked(isWindows);
5047
const useSessionStatsMock = vi.mocked(SessionContext.useSessionStats);
5148

@@ -104,11 +101,6 @@ describe('<SessionSummaryDisplay />', () => {
104101
};
105102

106103
beforeEach(() => {
107-
getShellConfigurationMock.mockReturnValue({
108-
executable: 'bash',
109-
argsPrefix: ['-c'],
110-
shell: 'bash',
111-
});
112104
isWindowsMock.mockReturnValue(false);
113105
});
114106

@@ -173,11 +165,6 @@ describe('<SessionSummaryDisplay />', () => {
173165

174166
it('renders a standard UUID-formatted session ID in the footer (powershell) on Windows', async () => {
175167
isWindowsMock.mockReturnValue(true);
176-
getShellConfigurationMock.mockReturnValue({
177-
executable: 'powershell.exe',
178-
argsPrefix: ['-NoProfile', '-Command'],
179-
shell: 'powershell',
180-
});
181168

182169
const uuidSessionId = '1234-abcd-5678-efgh';
183170
const { lastFrame, unmount } = await renderWithMockedStats(
@@ -192,11 +179,7 @@ describe('<SessionSummaryDisplay />', () => {
192179
});
193180

194181
it('sanitizes a malicious session ID in the footer (powershell)', async () => {
195-
getShellConfigurationMock.mockReturnValue({
196-
executable: 'powershell.exe',
197-
argsPrefix: ['-NoProfile', '-Command'],
198-
shell: 'powershell',
199-
});
182+
isWindowsMock.mockReturnValue(true);
200183

201184
const maliciousSessionId = "'; rm -rf / #";
202185
const { lastFrame, unmount } = await renderWithMockedStats(

packages/cli/src/ui/components/SessionSummaryDisplay.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ import { useSessionStats } from '../contexts/SessionContext.js';
1010
import { useConfig } from '../contexts/ConfigContext.js';
1111
import {
1212
escapeShellArg,
13-
getShellConfiguration,
1413
isWindows,
14+
type ShellType,
1515
} from '@google/gemini-cli-core';
1616

1717
interface SessionSummaryDisplayProps {
@@ -23,7 +23,7 @@ export const SessionSummaryDisplay: React.FC<SessionSummaryDisplayProps> = ({
2323
}) => {
2424
const { stats } = useSessionStats();
2525
const config = useConfig();
26-
const { shell } = getShellConfiguration();
26+
const shell: ShellType = isWindows() ? 'powershell' : 'bash';
2727

2828
const worktreeSettings = config.getWorktreeSettings();
2929

packages/core/src/hooks/hookRunner.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -365,7 +365,7 @@ describe('HookRunner', () => {
365365
);
366366

367367
expect(spawn).toHaveBeenCalledWith(
368-
expect.stringMatching(/bash|powershell/),
368+
expect.stringMatching(/bash|pwsh|powershell/),
369369
expect.arrayContaining([
370370
expect.stringMatching(/['"]?\/test\/project['"]?\/hooks\/test\.sh/),
371371
]),
@@ -408,7 +408,7 @@ describe('HookRunner', () => {
408408
);
409409

410410
expect(spawn).toHaveBeenCalledWith(
411-
expect.stringMatching(/bash|powershell/),
411+
expect.stringMatching(/bash|pwsh|powershell/),
412412
expect.arrayContaining([
413413
expect.stringMatching(
414414
/ls ['"]\/test\/project\/plans with spaces['"]/,
@@ -447,7 +447,7 @@ describe('HookRunner', () => {
447447

448448
// If secure, spawn will be called with the shell executable and escaped command
449449
expect(spawn).toHaveBeenCalledWith(
450-
expect.stringMatching(/bash|powershell/),
450+
expect.stringMatching(/bash|pwsh|powershell/),
451451
expect.arrayContaining([
452452
expect.stringMatching(/ls (['"]).*echo.*pwned.*\1/),
453453
]),

packages/core/src/services/shellExecutionService.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,7 @@ describe('ShellExecutionService', () => {
213213
mockSerializeTerminalToObject.mockReturnValue([]);
214214
mockIsBinary.mockReturnValue(false);
215215
mockPlatform.mockReturnValue('linux');
216-
mockResolveExecutable.mockImplementation(async (exe: string) => exe);
216+
mockResolveExecutable.mockImplementation((exe: string) => exe);
217217
process.env['PATH'] = '/test/path';
218218
mockGetPty.mockResolvedValue({
219219
module: { spawn: mockPtySpawn },
@@ -2064,7 +2064,7 @@ describe('ShellExecutionService environment variables', () => {
20642064
sandboxManager: mockSandboxManager,
20652065
};
20662066

2067-
mockResolveExecutable.mockResolvedValue('/bin/bash/resolved');
2067+
mockResolveExecutable.mockReturnValue('/bin/bash/resolved');
20682068
const mockChild = new EventEmitter() as unknown as ChildProcess;
20692069
mockChild.stdout = new EventEmitter() as unknown as Readable;
20702070
mockChild.stderr = new EventEmitter() as unknown as Readable;

packages/core/src/services/shellExecutionService.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -414,8 +414,7 @@ export class ShellExecutionService {
414414
executable = 'cmd.exe';
415415
}
416416

417-
const resolvedExecutable =
418-
(await resolveExecutable(executable)) ?? executable;
417+
const resolvedExecutable = resolveExecutable(executable) ?? executable;
419418

420419
const guardedCommand = ensurePromptvarsDisabled(commandToExecute, shell);
421420
const spawnArgs = [...argsPrefix, guardedCommand];
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import { describe, it, expect } from 'vitest';
8+
import os from 'node:os';
9+
import { ShellExecutionService } from './shellExecutionService.js';
10+
import { NoopSandboxManager } from './sandboxManager.js';
11+
12+
const isWindows = os.platform() === 'win32';
13+
14+
/**
15+
* Real-shell integration tests that reproduce the regression class from
16+
* issue #25859: commands with inline double quotes executed on Windows
17+
* lose their quotes when they reach the native executable, because
18+
* Windows PowerShell 5.1 mangles embedded " during native-command
19+
* argument passing. PowerShell 7 (pwsh.exe) passes arguments correctly.
20+
*
21+
* These tests exercise the full pipeline end-to-end. They pass when
22+
* gemini-cli selects pwsh.exe from PATH; they fail when the pipeline
23+
* routes through Windows PowerShell 5.1.
24+
*/
25+
describe.skipIf(!isWindows)(
26+
'ShellExecutionService Windows quoting (real shell)',
27+
() => {
28+
const baseConfig = {
29+
sanitizationConfig: {
30+
allowedEnvironmentVariables: [],
31+
blockedEnvironmentVariables: [],
32+
enableEnvironmentVariableRedaction: false,
33+
},
34+
sandboxManager: new NoopSandboxManager(),
35+
};
36+
37+
async function runReal(command: string) {
38+
const controller = new AbortController();
39+
const handle = await ShellExecutionService.execute(
40+
command,
41+
process.cwd(),
42+
() => {},
43+
controller.signal,
44+
false,
45+
baseConfig,
46+
);
47+
const result = await handle.result;
48+
return { result, output: result.output };
49+
}
50+
51+
it('should preserve inline double quotes through node -e', async () => {
52+
const { result, output } = await runReal(
53+
`node -e 'console.log("preserved")'`,
54+
);
55+
expect(result.exitCode).toBe(0);
56+
expect(output).toBe('preserved');
57+
});
58+
59+
it('should preserve double quotes inside JSON output', async () => {
60+
const { result, output } = await runReal(
61+
`node -e 'console.log(JSON.stringify({ok:"yes"}))'`,
62+
);
63+
expect(result.exitCode).toBe(0);
64+
expect(output).toBe('{"ok":"yes"}');
65+
});
66+
67+
it('should handle quoted argument containing a space', async () => {
68+
const { result, output } = await runReal(
69+
`node -e "console.log('hello world')"`,
70+
);
71+
expect(result.exitCode).toBe(0);
72+
expect(output).toBe('hello world');
73+
});
74+
75+
it('should handle a mixed-quote regex literal', async () => {
76+
const { result, output } = await runReal(
77+
`node -e 'console.log(String("a").match(/"/))'`,
78+
);
79+
expect(result.exitCode).toBe(0);
80+
expect(output).toBe('null');
81+
});
82+
83+
it('should pass a literal double-quote byte through to stdout', async () => {
84+
const { result, output } = await runReal(`node -e 'console.log("\\"")'`);
85+
expect(result.exitCode).toBe(0);
86+
expect(output).toBe('"');
87+
});
88+
},
89+
);

packages/core/src/tools/ripGrep.test.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1825,7 +1825,7 @@ describe('resolveRipgrepPath', () => {
18251825

18261826
it('should fall back to system PATH if both bundled paths are missing and system is trusted', async () => {
18271827
vi.mocked(fileExists).mockResolvedValue(false);
1828-
vi.mocked(resolveExecutable).mockResolvedValue('/usr/bin/rg');
1828+
vi.mocked(resolveExecutable).mockReturnValue('/usr/bin/rg');
18291829
vi.mocked(resolveToRealPath).mockReturnValue('/usr/bin/rg');
18301830

18311831
const resolvedPath = await resolveRipgrepPath();
@@ -1836,7 +1836,7 @@ describe('resolveRipgrepPath', () => {
18361836
it('should reject system PATH if it is in the current working directory', async () => {
18371837
vi.mocked(fileExists).mockResolvedValue(false);
18381838
const unsafePath = path.join(process.cwd(), 'rg');
1839-
vi.mocked(resolveExecutable).mockResolvedValue(unsafePath);
1839+
vi.mocked(resolveExecutable).mockReturnValue(unsafePath);
18401840
vi.mocked(resolveToRealPath).mockReturnValue(unsafePath);
18411841

18421842
const resolvedPath = await resolveRipgrepPath();
@@ -1848,7 +1848,7 @@ describe('resolveRipgrepPath', () => {
18481848
const trustedLink = '/usr/local/bin/rg';
18491849
const trustedRealPath = '/opt/homebrew/Cellar/ripgrep/13.0.0/bin/rg';
18501850

1851-
vi.mocked(resolveExecutable).mockResolvedValue(trustedLink);
1851+
vi.mocked(resolveExecutable).mockReturnValue(trustedLink);
18521852
vi.mocked(resolveToRealPath).mockReturnValue(trustedRealPath);
18531853

18541854
const resolvedPath = await resolveRipgrepPath();
@@ -1857,7 +1857,7 @@ describe('resolveRipgrepPath', () => {
18571857

18581858
it('should return null if binary is missing from both bundled paths and system PATH', async () => {
18591859
vi.mocked(fileExists).mockResolvedValue(false);
1860-
vi.mocked(resolveExecutable).mockResolvedValue(undefined);
1860+
vi.mocked(resolveExecutable).mockReturnValue(undefined);
18611861

18621862
const resolvedPath = await resolveRipgrepPath();
18631863
expect(resolvedPath).toBeNull();
@@ -1883,7 +1883,7 @@ describe('resolveRipgrepPath', () => {
18831883

18841884
it('should fall back to system PATH if system is trusted on Windows', async () => {
18851885
vi.mocked(fileExists).mockResolvedValue(false);
1886-
vi.mocked(resolveExecutable).mockResolvedValue(
1886+
vi.mocked(resolveExecutable).mockReturnValue(
18871887
'C:\\Windows\\System32\\rg.exe',
18881888
);
18891889
vi.mocked(resolveToRealPath).mockReturnValue(
@@ -1898,7 +1898,7 @@ describe('resolveRipgrepPath', () => {
18981898
it('should reject system PATH if it is untrusted on Windows', async () => {
18991899
vi.mocked(fileExists).mockResolvedValue(false);
19001900
const unsafePath = 'D:\\Downloads\\rg.exe';
1901-
vi.mocked(resolveExecutable).mockResolvedValue(unsafePath);
1901+
vi.mocked(resolveExecutable).mockReturnValue(unsafePath);
19021902
vi.mocked(resolveToRealPath).mockReturnValue(unsafePath);
19031903

19041904
const resolvedPath = await resolveRipgrepPath();

packages/core/src/tools/ripGrep.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ export async function resolveRipgrepPath(): Promise<string | null> {
7272
}
7373

7474
// 3. Fallback: check system PATH
75-
const systemRg = await resolveExecutable('rg');
75+
const systemRg = resolveExecutable('rg');
7676
if (systemRg) {
7777
// Security: Validate the system executable to prevent Search Path Interruption.
7878
const realPath = resolveToRealPath(systemRg);

0 commit comments

Comments
 (0)