Skip to content
Merged
19 changes: 1 addition & 18 deletions packages/cli/src/ui/components/SessionSummaryDisplay.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import { useConfig } from '../contexts/ConfigContext.js';
import { type SessionMetrics } from '../contexts/SessionContext.js';
import {
ToolCallDecision,
getShellConfiguration,
isWindows,
type WorktreeSettings,
} from '@google/gemini-cli-core';
Expand All @@ -22,7 +21,6 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
getShellConfiguration: vi.fn(),
isWindows: vi.fn(),
};
});
Expand All @@ -45,7 +43,6 @@ vi.mock('../contexts/ConfigContext.js', async (importOriginal) => {
};
});

const getShellConfigurationMock = vi.mocked(getShellConfiguration);
const isWindowsMock = vi.mocked(isWindows);
const useSessionStatsMock = vi.mocked(SessionContext.useSessionStats);

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

beforeEach(() => {
getShellConfigurationMock.mockReturnValue({
executable: 'bash',
argsPrefix: ['-c'],
shell: 'bash',
});
isWindowsMock.mockReturnValue(false);
});

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

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

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

it('sanitizes a malicious session ID in the footer (powershell)', async () => {
getShellConfigurationMock.mockReturnValue({
executable: 'powershell.exe',
argsPrefix: ['-NoProfile', '-Command'],
shell: 'powershell',
});
isWindowsMock.mockReturnValue(true);

const maliciousSessionId = "'; rm -rf / #";
const { lastFrame, unmount } = await renderWithMockedStats(
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/ui/components/SessionSummaryDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ import { useSessionStats } from '../contexts/SessionContext.js';
import { useConfig } from '../contexts/ConfigContext.js';
import {
escapeShellArg,
getShellConfiguration,
isWindows,
type ShellType,
} from '@google/gemini-cli-core';

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

const worktreeSettings = config.getWorktreeSettings();

Expand Down
6 changes: 3 additions & 3 deletions packages/core/src/hooks/hookRunner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -365,7 +365,7 @@ describe('HookRunner', () => {
);

expect(spawn).toHaveBeenCalledWith(
expect.stringMatching(/bash|powershell/),
expect.stringMatching(/bash|pwsh|powershell/),
expect.arrayContaining([
expect.stringMatching(/['"]?\/test\/project['"]?\/hooks\/test\.sh/),
]),
Expand Down Expand Up @@ -408,7 +408,7 @@ describe('HookRunner', () => {
);

expect(spawn).toHaveBeenCalledWith(
expect.stringMatching(/bash|powershell/),
expect.stringMatching(/bash|pwsh|powershell/),
expect.arrayContaining([
expect.stringMatching(
/ls ['"]\/test\/project\/plans with spaces['"]/,
Expand Down Expand Up @@ -447,7 +447,7 @@ describe('HookRunner', () => {

// If secure, spawn will be called with the shell executable and escaped command
expect(spawn).toHaveBeenCalledWith(
expect.stringMatching(/bash|powershell/),
expect.stringMatching(/bash|pwsh|powershell/),
expect.arrayContaining([
expect.stringMatching(/ls (['"]).*echo.*pwned.*\1/),
]),
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/services/shellExecutionService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,7 @@ describe('ShellExecutionService', () => {
mockSerializeTerminalToObject.mockReturnValue([]);
mockIsBinary.mockReturnValue(false);
mockPlatform.mockReturnValue('linux');
mockResolveExecutable.mockImplementation(async (exe: string) => exe);
mockResolveExecutable.mockImplementation((exe: string) => exe);
process.env['PATH'] = '/test/path';
mockGetPty.mockResolvedValue({
module: { spawn: mockPtySpawn },
Expand Down Expand Up @@ -2064,7 +2064,7 @@ describe('ShellExecutionService environment variables', () => {
sandboxManager: mockSandboxManager,
};

mockResolveExecutable.mockResolvedValue('/bin/bash/resolved');
mockResolveExecutable.mockReturnValue('/bin/bash/resolved');
const mockChild = new EventEmitter() as unknown as ChildProcess;
mockChild.stdout = new EventEmitter() as unknown as Readable;
mockChild.stderr = new EventEmitter() as unknown as Readable;
Expand Down
3 changes: 1 addition & 2 deletions packages/core/src/services/shellExecutionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -414,8 +414,7 @@ export class ShellExecutionService {
executable = 'cmd.exe';
}

const resolvedExecutable =
(await resolveExecutable(executable)) ?? executable;
const resolvedExecutable = resolveExecutable(executable) ?? executable;

const guardedCommand = ensurePromptvarsDisabled(commandToExecute, shell);
const spawnArgs = [...argsPrefix, guardedCommand];
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import { describe, it, expect } from 'vitest';
import os from 'node:os';
import { ShellExecutionService } from './shellExecutionService.js';
import { NoopSandboxManager } from './sandboxManager.js';

const isWindows = os.platform() === 'win32';

/**
* Real-shell integration tests that reproduce the regression class from
* issue #25859: commands with inline double quotes executed on Windows
* lose their quotes when they reach the native executable, because
* Windows PowerShell 5.1 mangles embedded " during native-command
* argument passing. PowerShell 7 (pwsh.exe) passes arguments correctly.
*
* These tests exercise the full pipeline end-to-end. They pass when
* gemini-cli selects pwsh.exe from PATH; they fail when the pipeline
* routes through Windows PowerShell 5.1.
*/
describe.skipIf(!isWindows)(
'ShellExecutionService Windows quoting (real shell)',
() => {
const baseConfig = {
sanitizationConfig: {
allowedEnvironmentVariables: [],
blockedEnvironmentVariables: [],
enableEnvironmentVariableRedaction: false,
},
sandboxManager: new NoopSandboxManager(),
};

async function runReal(command: string) {
const controller = new AbortController();
const handle = await ShellExecutionService.execute(
command,
process.cwd(),
() => {},
controller.signal,
false,
baseConfig,
);
const result = await handle.result;
return { result, output: result.output };
}

it('should preserve inline double quotes through node -e', async () => {
const { result, output } = await runReal(
`node -e 'console.log("preserved")'`,
);
expect(result.exitCode).toBe(0);
expect(output).toBe('preserved');
});

it('should preserve double quotes inside JSON output', async () => {
const { result, output } = await runReal(
`node -e 'console.log(JSON.stringify({ok:"yes"}))'`,
);
expect(result.exitCode).toBe(0);
expect(output).toBe('{"ok":"yes"}');
});

it('should handle quoted argument containing a space', async () => {
const { result, output } = await runReal(
`node -e "console.log('hello world')"`,
);
expect(result.exitCode).toBe(0);
expect(output).toBe('hello world');
});

it('should handle a mixed-quote regex literal', async () => {
const { result, output } = await runReal(
`node -e 'console.log(String("a").match(/"/))'`,
);
expect(result.exitCode).toBe(0);
expect(output).toBe('null');
});

it('should pass a literal double-quote byte through to stdout', async () => {
const { result, output } = await runReal(`node -e 'console.log("\\"")'`);
expect(result.exitCode).toBe(0);
expect(output).toBe('"');
});
},
);
12 changes: 6 additions & 6 deletions packages/core/src/tools/ripGrep.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1825,7 +1825,7 @@ describe('resolveRipgrepPath', () => {

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

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

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

vi.mocked(resolveExecutable).mockResolvedValue(trustedLink);
vi.mocked(resolveExecutable).mockReturnValue(trustedLink);
vi.mocked(resolveToRealPath).mockReturnValue(trustedRealPath);

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

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

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

it('should fall back to system PATH if system is trusted on Windows', async () => {
vi.mocked(fileExists).mockResolvedValue(false);
vi.mocked(resolveExecutable).mockResolvedValue(
vi.mocked(resolveExecutable).mockReturnValue(
'C:\\Windows\\System32\\rg.exe',
);
vi.mocked(resolveToRealPath).mockReturnValue(
Expand All @@ -1898,7 +1898,7 @@ describe('resolveRipgrepPath', () => {
it('should reject system PATH if it is untrusted on Windows', async () => {
vi.mocked(fileExists).mockResolvedValue(false);
const unsafePath = 'D:\\Downloads\\rg.exe';
vi.mocked(resolveExecutable).mockResolvedValue(unsafePath);
vi.mocked(resolveExecutable).mockReturnValue(unsafePath);
vi.mocked(resolveToRealPath).mockReturnValue(unsafePath);

const resolvedPath = await resolveRipgrepPath();
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/tools/ripGrep.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ export async function resolveRipgrepPath(): Promise<string | null> {
}

// 3. Fallback: check system PATH
const systemRg = await resolveExecutable('rg');
const systemRg = resolveExecutable('rg');
if (systemRg) {
// Security: Validate the system executable to prevent Search Path Interruption.
const realPath = resolveToRealPath(systemRg);
Expand Down
Loading
Loading