mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-10 14:10:37 -07:00
refactor(cli): unify shell confirmation dialogs (#16828)
This commit is contained in:
@@ -708,7 +708,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
slashCommands,
|
||||
pendingHistoryItems: pendingSlashCommandHistoryItems,
|
||||
commandContext,
|
||||
shellConfirmationRequest,
|
||||
confirmationRequest,
|
||||
} = useSlashCommandProcessor(
|
||||
config,
|
||||
@@ -1358,7 +1357,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
|
||||
useKeypress(handleGlobalKeypress, { isActive: true });
|
||||
|
||||
// Update terminal title with Gemini CLI status and thoughts
|
||||
useEffect(() => {
|
||||
// Respect hideWindowTitle settings
|
||||
if (settings.merged.ui.hideWindowTitle) return;
|
||||
@@ -1366,10 +1364,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
const paddedTitle = computeTerminalTitle({
|
||||
streamingState,
|
||||
thoughtSubject: thought?.subject,
|
||||
isConfirming:
|
||||
!!shellConfirmationRequest ||
|
||||
!!confirmationRequest ||
|
||||
showShellActionRequired,
|
||||
isConfirming: !!confirmationRequest || showShellActionRequired,
|
||||
folderName: basename(config.getTargetDir()),
|
||||
showThoughts: !!settings.merged.ui.showStatusInTitle,
|
||||
useDynamicTitle: settings.merged.ui.dynamicWindowTitle,
|
||||
@@ -1384,7 +1379,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
}, [
|
||||
streamingState,
|
||||
thought,
|
||||
shellConfirmationRequest,
|
||||
confirmationRequest,
|
||||
showShellActionRequired,
|
||||
settings.merged.ui.showStatusInTitle,
|
||||
@@ -1463,7 +1457,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
shouldShowIdePrompt ||
|
||||
isFolderTrustDialogOpen ||
|
||||
adminSettingsChanged ||
|
||||
!!shellConfirmationRequest ||
|
||||
!!confirmationRequest ||
|
||||
!!customDialog ||
|
||||
confirmUpdateExtensionRequests.length > 0 ||
|
||||
@@ -1558,7 +1551,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
slashCommands,
|
||||
pendingSlashCommandHistoryItems,
|
||||
commandContext,
|
||||
shellConfirmationRequest,
|
||||
confirmationRequest,
|
||||
confirmUpdateExtensionRequests,
|
||||
loopDetectionConfirmationRequest,
|
||||
@@ -1650,7 +1642,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
slashCommands,
|
||||
pendingSlashCommandHistoryItems,
|
||||
commandContext,
|
||||
shellConfirmationRequest,
|
||||
confirmationRequest,
|
||||
confirmUpdateExtensionRequests,
|
||||
loopDetectionConfirmationRequest,
|
||||
|
||||
@@ -11,7 +11,6 @@ import { Text } from 'ink';
|
||||
import { type UIState } from '../contexts/UIStateContext.js';
|
||||
import { type RestartReason } from '../hooks/useIdeTrustListener.js';
|
||||
import { type IdeInfo } from '@google/gemini-cli-core';
|
||||
import { type ShellConfirmationRequest } from '../types.js';
|
||||
|
||||
// Mock child components
|
||||
vi.mock('../IdeIntegrationNudge.js', () => ({
|
||||
@@ -23,9 +22,6 @@ vi.mock('./LoopDetectionConfirmation.js', () => ({
|
||||
vi.mock('./FolderTrustDialog.js', () => ({
|
||||
FolderTrustDialog: () => <Text>FolderTrustDialog</Text>,
|
||||
}));
|
||||
vi.mock('./ShellConfirmationDialog.js', () => ({
|
||||
ShellConfirmationDialog: () => <Text>ShellConfirmationDialog</Text>,
|
||||
}));
|
||||
vi.mock('./ConsentPrompt.js', () => ({
|
||||
ConsentPrompt: () => <Text>ConsentPrompt</Text>,
|
||||
}));
|
||||
@@ -79,7 +75,6 @@ describe('DialogManager', () => {
|
||||
proQuotaRequest: null,
|
||||
shouldShowIdePrompt: false,
|
||||
isFolderTrustDialogOpen: false,
|
||||
shellConfirmationRequest: null,
|
||||
loopDetectionConfirmationRequest: null,
|
||||
confirmationRequest: null,
|
||||
isThemeDialogOpen: false,
|
||||
@@ -130,15 +125,6 @@ describe('DialogManager', () => {
|
||||
'IdeIntegrationNudge',
|
||||
],
|
||||
[{ isFolderTrustDialogOpen: true }, 'FolderTrustDialog'],
|
||||
[
|
||||
{
|
||||
shellConfirmationRequest: {
|
||||
commands: [],
|
||||
onConfirm: vi.fn(),
|
||||
} as unknown as ShellConfirmationRequest,
|
||||
},
|
||||
'ShellConfirmationDialog',
|
||||
],
|
||||
[
|
||||
{ loopDetectionConfirmationRequest: { onComplete: vi.fn() } },
|
||||
'LoopDetectionConfirmation',
|
||||
|
||||
@@ -8,7 +8,6 @@ import { Box, Text } from 'ink';
|
||||
import { IdeIntegrationNudge } from '../IdeIntegrationNudge.js';
|
||||
import { LoopDetectionConfirmation } from './LoopDetectionConfirmation.js';
|
||||
import { FolderTrustDialog } from './FolderTrustDialog.js';
|
||||
import { ShellConfirmationDialog } from './ShellConfirmationDialog.js';
|
||||
import { ConsentPrompt } from './ConsentPrompt.js';
|
||||
import { ThemeDialog } from './ThemeDialog.js';
|
||||
import { SettingsDialog } from './SettingsDialog.js';
|
||||
@@ -85,11 +84,6 @@ export const DialogManager = ({
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (uiState.shellConfirmationRequest) {
|
||||
return (
|
||||
<ShellConfirmationDialog request={uiState.shellConfirmationRequest} />
|
||||
);
|
||||
}
|
||||
if (uiState.loopDetectionConfirmationRequest) {
|
||||
return (
|
||||
<LoopDetectionConfirmation
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { renderWithProviders } from '../../test-utils/render.js';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { ShellConfirmationDialog } from './ShellConfirmationDialog.js';
|
||||
|
||||
describe('ShellConfirmationDialog', () => {
|
||||
const onConfirm = vi.fn();
|
||||
|
||||
const request = {
|
||||
commands: ['ls -la', 'echo "hello"'],
|
||||
onConfirm,
|
||||
};
|
||||
|
||||
it('renders correctly', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<ShellConfirmationDialog request={request} />,
|
||||
{ width: 101 },
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('calls onConfirm with ProceedOnce when "Allow once" is selected', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<ShellConfirmationDialog request={request} />,
|
||||
);
|
||||
const select = lastFrame()!.toString();
|
||||
// Simulate selecting the first option
|
||||
// This is a simplified way to test the selection
|
||||
expect(select).toContain('Allow once');
|
||||
});
|
||||
|
||||
it('calls onConfirm with ProceedAlways when "Allow for this session" is selected', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<ShellConfirmationDialog request={request} />,
|
||||
);
|
||||
const select = lastFrame()!.toString();
|
||||
// Simulate selecting the second option
|
||||
expect(select).toContain('Allow for this session');
|
||||
});
|
||||
|
||||
it('calls onConfirm with Cancel when "No (esc)" is selected', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<ShellConfirmationDialog request={request} />,
|
||||
{ width: 100 },
|
||||
);
|
||||
const select = lastFrame()!.toString();
|
||||
// Simulate selecting the third option
|
||||
expect(select).toContain('No (esc)');
|
||||
});
|
||||
});
|
||||
@@ -1,110 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { ToolConfirmationOutcome } from '@google/gemini-cli-core';
|
||||
import { Box, Text } from 'ink';
|
||||
import type React from 'react';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { RenderInline } from '../utils/InlineMarkdownRenderer.js';
|
||||
import type { RadioSelectItem } from './shared/RadioButtonSelect.js';
|
||||
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
|
||||
export interface ShellConfirmationRequest {
|
||||
commands: string[];
|
||||
onConfirm: (
|
||||
outcome: ToolConfirmationOutcome,
|
||||
approvedCommands?: string[],
|
||||
) => void;
|
||||
}
|
||||
|
||||
export interface ShellConfirmationDialogProps {
|
||||
request: ShellConfirmationRequest;
|
||||
}
|
||||
|
||||
export const ShellConfirmationDialog: React.FC<
|
||||
ShellConfirmationDialogProps
|
||||
> = ({ request }) => {
|
||||
const { commands, onConfirm } = request;
|
||||
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (key.name === 'escape') {
|
||||
onConfirm(ToolConfirmationOutcome.Cancel);
|
||||
}
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
const handleSelect = (item: ToolConfirmationOutcome) => {
|
||||
if (item === ToolConfirmationOutcome.Cancel) {
|
||||
onConfirm(item);
|
||||
} else {
|
||||
// For both ProceedOnce and ProceedAlways, we approve all the
|
||||
// commands that were requested.
|
||||
onConfirm(item, commands);
|
||||
}
|
||||
};
|
||||
|
||||
const options: Array<RadioSelectItem<ToolConfirmationOutcome>> = [
|
||||
{
|
||||
label: 'Allow once',
|
||||
value: ToolConfirmationOutcome.ProceedOnce,
|
||||
key: 'Allow once',
|
||||
},
|
||||
{
|
||||
label: 'Allow for this session',
|
||||
value: ToolConfirmationOutcome.ProceedAlways,
|
||||
key: 'Allow for this session',
|
||||
},
|
||||
{
|
||||
label: 'No (esc)',
|
||||
value: ToolConfirmationOutcome.Cancel,
|
||||
key: 'No (esc)',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Box flexDirection="row" width="100%">
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={theme.status.warning}
|
||||
padding={1}
|
||||
flexGrow={1}
|
||||
marginLeft={1}
|
||||
>
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text bold color={theme.text.primary}>
|
||||
Shell Command Execution
|
||||
</Text>
|
||||
<Text color={theme.text.primary}>
|
||||
A custom command wants to run the following shell commands:
|
||||
</Text>
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
paddingX={1}
|
||||
marginTop={1}
|
||||
>
|
||||
{commands.map((cmd) => (
|
||||
<Text key={cmd} color={theme.text.link}>
|
||||
<RenderInline text={cmd} defaultColor={theme.text.link} />
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box marginBottom={1}>
|
||||
<Text color={theme.text.primary}>Do you want to proceed?</Text>
|
||||
</Box>
|
||||
|
||||
<RadioButtonSelect items={options} onSelect={handleSelect} isFocused />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -1,21 +0,0 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`ShellConfirmationDialog > renders correctly 1`] = `
|
||||
" ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ Shell Command Execution │
|
||||
│ A custom command wants to run the following shell commands: │
|
||||
│ │
|
||||
│ ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ │
|
||||
│ │ ls -la │ │
|
||||
│ │ echo "hello" │ │
|
||||
│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │
|
||||
│ │
|
||||
│ Do you want to proceed? │
|
||||
│ │
|
||||
│ ● 1. Allow once │
|
||||
│ 2. Allow for this session │
|
||||
│ 3. No (esc) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
@@ -66,6 +66,33 @@ describe('ToolConfirmationMessage', () => {
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should display multiple commands for exec type when provided', () => {
|
||||
const confirmationDetails: ToolCallConfirmationDetails = {
|
||||
type: 'exec',
|
||||
title: 'Confirm Multiple Commands',
|
||||
command: 'echo "hello"', // Primary command
|
||||
rootCommand: 'echo',
|
||||
rootCommands: ['echo'],
|
||||
commands: ['echo "hello"', 'ls -la', 'whoami'], // Multi-command list
|
||||
onConfirm: vi.fn(),
|
||||
};
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<ToolConfirmationMessage
|
||||
confirmationDetails={confirmationDetails}
|
||||
config={mockConfig}
|
||||
availableTerminalHeight={30}
|
||||
terminalWidth={80}
|
||||
/>,
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('echo "hello"');
|
||||
expect(output).toContain('ls -la');
|
||||
expect(output).toContain('whoami');
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
describe('with folder trust', () => {
|
||||
const editConfirmationDetails: ToolCallConfirmationDetails = {
|
||||
type: 'edit',
|
||||
|
||||
@@ -139,7 +139,11 @@ export const ToolConfirmationMessage: React.FC<
|
||||
} else if (confirmationDetails.type === 'exec') {
|
||||
const executionProps = confirmationDetails;
|
||||
|
||||
question = `Allow execution of: '${executionProps.rootCommand}'?`;
|
||||
if (executionProps.commands && executionProps.commands.length > 1) {
|
||||
question = `Allow execution of ${executionProps.commands.length} commands?`;
|
||||
} else {
|
||||
question = `Allow execution of: '${executionProps.rootCommand}'?`;
|
||||
}
|
||||
options.push({
|
||||
label: 'Allow once',
|
||||
value: ToolConfirmationOutcome.ProceedOnce,
|
||||
@@ -276,8 +280,18 @@ export const ToolConfirmationMessage: React.FC<
|
||||
maxHeight={bodyContentHeight}
|
||||
maxWidth={Math.max(terminalWidth, 1)}
|
||||
>
|
||||
<Box>
|
||||
<Text color={theme.text.link}>{executionProps.command}</Text>
|
||||
<Box flexDirection="column">
|
||||
{executionProps.commands && executionProps.commands.length > 1 ? (
|
||||
executionProps.commands.map((cmd, idx) => (
|
||||
<Text key={idx} color={theme.text.link}>
|
||||
{cmd}
|
||||
</Text>
|
||||
))
|
||||
) : (
|
||||
<Box>
|
||||
<Text color={theme.text.link}>{executionProps.command}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</MaxSizedBox>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`ToolConfirmationMessage > should display multiple commands for exec type when provided 1`] = `
|
||||
"echo "hello"
|
||||
ls -la
|
||||
whoami
|
||||
|
||||
Allow execution of 3 commands?
|
||||
|
||||
● 1. Allow once
|
||||
2. Allow for this session
|
||||
3. No, suggest changes (esc)
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ToolConfirmationMessage > should display urls if prompt and url are different 1`] = `
|
||||
"fetch https://github.com/google/gemini-react/blob/main/README.md
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ import type {
|
||||
HistoryItem,
|
||||
ThoughtSummary,
|
||||
ConsoleMessageItem,
|
||||
ShellConfirmationRequest,
|
||||
ConfirmationRequest,
|
||||
LoopDetectionConfirmationRequest,
|
||||
HistoryItemWithoutId,
|
||||
@@ -68,7 +67,6 @@ export interface UIState {
|
||||
slashCommands: readonly SlashCommand[] | undefined;
|
||||
pendingSlashCommandHistoryItems: HistoryItemWithoutId[];
|
||||
commandContext: CommandContext;
|
||||
shellConfirmationRequest: ShellConfirmationRequest | null;
|
||||
confirmationRequest: ConfirmationRequest | null;
|
||||
confirmUpdateExtensionRequests: ConfirmationRequest[];
|
||||
loopDetectionConfirmationRequest: LoopDetectionConfirmationRequest | null;
|
||||
|
||||
@@ -9,21 +9,16 @@ import { act } from 'react';
|
||||
import { renderHook } from '../../test-utils/render.js';
|
||||
import { waitFor } from '../../test-utils/async.js';
|
||||
import { useSlashCommandProcessor } from './slashCommandProcessor.js';
|
||||
import type {
|
||||
CommandContext,
|
||||
ConfirmShellCommandsActionReturn,
|
||||
SlashCommand,
|
||||
} from '../commands/types.js';
|
||||
import type { SlashCommand } from '../commands/types.js';
|
||||
import { CommandKind } from '../commands/types.js';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
import { MessageType, type SlashCommandProcessorResult } from '../types.js';
|
||||
import { MessageType } from '../types.js';
|
||||
import { BuiltinCommandLoader } from '../../services/BuiltinCommandLoader.js';
|
||||
import { FileCommandLoader } from '../../services/FileCommandLoader.js';
|
||||
import { McpPromptLoader } from '../../services/McpPromptLoader.js';
|
||||
import {
|
||||
type GeminiClient,
|
||||
SlashCommandStatus,
|
||||
ToolConfirmationOutcome,
|
||||
makeFakeConfig,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { appEvents } from '../../utils/events.js';
|
||||
@@ -638,197 +633,6 @@ describe('useSlashCommandProcessor', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Shell Command Confirmation Flow', () => {
|
||||
// Use a generic vi.fn() for the action. We will change its behavior in each test.
|
||||
const mockCommandAction = vi.fn();
|
||||
|
||||
const shellCommand = createTestCommand({
|
||||
name: 'shellcmd',
|
||||
action: mockCommandAction,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset the mock before each test
|
||||
mockCommandAction.mockClear();
|
||||
|
||||
// Default behavior: request confirmation
|
||||
mockCommandAction.mockResolvedValue({
|
||||
type: 'confirm_shell_commands',
|
||||
commandsToConfirm: ['rm -rf /'],
|
||||
originalInvocation: { raw: '/shellcmd' },
|
||||
} as ConfirmShellCommandsActionReturn);
|
||||
});
|
||||
|
||||
it('should set confirmation request when action returns confirm_shell_commands', async () => {
|
||||
const result = await setupProcessorHook([shellCommand]);
|
||||
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
|
||||
|
||||
// Trigger command, don't await it yet as it suspends for confirmation
|
||||
await act(async () => {
|
||||
void result.current.handleSlashCommand('/shellcmd');
|
||||
});
|
||||
|
||||
// We now wait for the state to be updated with the request.
|
||||
await act(async () => {
|
||||
await waitFor(() => {
|
||||
expect(result.current.shellConfirmationRequest).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.shellConfirmationRequest?.commands).toEqual([
|
||||
'rm -rf /',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should do nothing if user cancels confirmation', async () => {
|
||||
const result = await setupProcessorHook([shellCommand]);
|
||||
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
|
||||
|
||||
await act(async () => {
|
||||
void result.current.handleSlashCommand('/shellcmd');
|
||||
});
|
||||
|
||||
// Wait for the confirmation dialog to be set
|
||||
await act(async () => {
|
||||
await waitFor(() => {
|
||||
expect(result.current.shellConfirmationRequest).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
const onConfirm = result.current.shellConfirmationRequest?.onConfirm;
|
||||
expect(onConfirm).toBeDefined();
|
||||
|
||||
// Change the mock action's behavior for a potential second run.
|
||||
// If the test is flawed, this will be called, and we can detect it.
|
||||
mockCommandAction.mockResolvedValue({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'This should not be called',
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
onConfirm!(ToolConfirmationOutcome.Cancel, []); // Pass empty array for safety
|
||||
});
|
||||
|
||||
expect(result.current.shellConfirmationRequest).toBeNull();
|
||||
// Verify the action was only called the initial time.
|
||||
expect(mockCommandAction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should re-run command with one-time allowlist on "Proceed Once"', async () => {
|
||||
const result = await setupProcessorHook([shellCommand]);
|
||||
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
|
||||
|
||||
let commandPromise:
|
||||
| Promise<false | SlashCommandProcessorResult>
|
||||
| undefined;
|
||||
await act(async () => {
|
||||
commandPromise = result.current.handleSlashCommand('/shellcmd');
|
||||
});
|
||||
await act(async () => {
|
||||
await waitFor(() => {
|
||||
expect(result.current.shellConfirmationRequest).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
const onConfirm = result.current.shellConfirmationRequest?.onConfirm;
|
||||
|
||||
// **Change the mock's behavior for the SECOND run.**
|
||||
// This is the key to testing the outcome.
|
||||
mockCommandAction.mockResolvedValue({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'Success!',
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
onConfirm!(ToolConfirmationOutcome.ProceedOnce, ['rm -rf /']);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await commandPromise;
|
||||
});
|
||||
|
||||
expect(result.current.shellConfirmationRequest).toBeNull();
|
||||
|
||||
// The action should have been called twice (initial + re-run).
|
||||
await waitFor(() => {
|
||||
expect(mockCommandAction).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
// We can inspect the context of the second call to ensure the one-time list was used.
|
||||
const secondCallContext = mockCommandAction.mock
|
||||
.calls[1][0] as CommandContext;
|
||||
expect(
|
||||
secondCallContext.session.sessionShellAllowlist.has('rm -rf /'),
|
||||
).toBe(true);
|
||||
|
||||
// Verify the final success message was added.
|
||||
expect(mockAddItem).toHaveBeenCalledWith(
|
||||
{ type: MessageType.INFO, text: 'Success!' },
|
||||
expect.any(Number),
|
||||
);
|
||||
|
||||
// Verify the session-wide allowlist was NOT permanently updated.
|
||||
// Re-render the hook by calling a no-op command to get the latest context.
|
||||
await act(async () => {
|
||||
await result.current.handleSlashCommand('/no-op');
|
||||
});
|
||||
const finalContext = result.current.commandContext;
|
||||
expect(finalContext.session.sessionShellAllowlist.size).toBe(0);
|
||||
});
|
||||
|
||||
it('should re-run command and update session allowlist on "Proceed Always"', async () => {
|
||||
const result = await setupProcessorHook([shellCommand]);
|
||||
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
|
||||
|
||||
let commandPromise:
|
||||
| Promise<false | SlashCommandProcessorResult>
|
||||
| undefined;
|
||||
await act(async () => {
|
||||
commandPromise = result.current.handleSlashCommand('/shellcmd');
|
||||
});
|
||||
await act(async () => {
|
||||
await waitFor(() => {
|
||||
expect(result.current.shellConfirmationRequest).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
const onConfirm = result.current.shellConfirmationRequest?.onConfirm;
|
||||
mockCommandAction.mockResolvedValue({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'Success!',
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
onConfirm!(ToolConfirmationOutcome.ProceedAlways, ['rm -rf /']);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await commandPromise;
|
||||
});
|
||||
|
||||
expect(result.current.shellConfirmationRequest).toBeNull();
|
||||
await waitFor(() => {
|
||||
expect(mockCommandAction).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
expect(mockAddItem).toHaveBeenCalledWith(
|
||||
{ type: MessageType.INFO, text: 'Success!' },
|
||||
expect.any(Number),
|
||||
);
|
||||
|
||||
// Check that the session-wide allowlist WAS updated.
|
||||
await waitFor(() => {
|
||||
const finalContext = result.current.commandContext;
|
||||
expect(finalContext.session.sessionShellAllowlist.has('rm -rf /')).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Command Parsing and Matching', () => {
|
||||
it('should be case-sensitive', async () => {
|
||||
const command = createTestCommand({ name: 'test' });
|
||||
|
||||
@@ -18,6 +18,7 @@ import type {
|
||||
Config,
|
||||
ExtensionsStartingEvent,
|
||||
ExtensionsStoppingEvent,
|
||||
ToolCallConfirmationDetails,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
GitService,
|
||||
@@ -39,8 +40,9 @@ import type {
|
||||
SlashCommandProcessorResult,
|
||||
HistoryItem,
|
||||
ConfirmationRequest,
|
||||
IndividualToolCallDisplay,
|
||||
} from '../types.js';
|
||||
import { MessageType } from '../types.js';
|
||||
import { MessageType, ToolCallStatus } from '../types.js';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
import { type CommandContext, type SlashCommand } from '../commands/types.js';
|
||||
import { CommandService } from '../../services/CommandService.js';
|
||||
@@ -103,14 +105,6 @@ export const useSlashCommandProcessor = (
|
||||
const reloadCommands = useCallback(() => {
|
||||
setReloadTrigger((v) => v + 1);
|
||||
}, []);
|
||||
const [shellConfirmationRequest, setShellConfirmationRequest] =
|
||||
useState<null | {
|
||||
commands: string[];
|
||||
onConfirm: (
|
||||
outcome: ToolConfirmationOutcome,
|
||||
approvedCommands?: string[],
|
||||
) => void;
|
||||
}>(null);
|
||||
const [confirmationRequest, setConfirmationRequest] = useState<null | {
|
||||
prompt: React.ReactNode;
|
||||
onConfirm: (confirmed: boolean) => void;
|
||||
@@ -484,30 +478,59 @@ export const useSlashCommandProcessor = (
|
||||
content: result.content,
|
||||
};
|
||||
case 'confirm_shell_commands': {
|
||||
const callId = `expansion-${Date.now()}`;
|
||||
const { outcome, approvedCommands } = await new Promise<{
|
||||
outcome: ToolConfirmationOutcome;
|
||||
approvedCommands?: string[];
|
||||
}>((resolve) => {
|
||||
setShellConfirmationRequest({
|
||||
const confirmationDetails: ToolCallConfirmationDetails = {
|
||||
type: 'exec',
|
||||
title: `Confirm Shell Expansion`,
|
||||
command: result.commandsToConfirm[0] || '',
|
||||
rootCommand: result.commandsToConfirm[0] || '',
|
||||
rootCommands: result.commandsToConfirm,
|
||||
commands: result.commandsToConfirm,
|
||||
onConfirm: (
|
||||
resolvedOutcome,
|
||||
resolvedApprovedCommands,
|
||||
) => {
|
||||
setShellConfirmationRequest(null); // Close the dialog
|
||||
onConfirm: async (resolvedOutcome) => {
|
||||
// Close the pending tool display by resolving
|
||||
resolve({
|
||||
outcome: resolvedOutcome,
|
||||
approvedCommands: resolvedApprovedCommands,
|
||||
approvedCommands:
|
||||
resolvedOutcome === ToolConfirmationOutcome.Cancel
|
||||
? []
|
||||
: result.commandsToConfirm,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const toolDisplay: IndividualToolCallDisplay = {
|
||||
callId,
|
||||
name: 'Expansion',
|
||||
description: 'Command expansion needs shell access',
|
||||
status: ToolCallStatus.Confirming,
|
||||
resultDisplay: undefined,
|
||||
confirmationDetails,
|
||||
};
|
||||
|
||||
setPendingItem({
|
||||
type: 'tool_group',
|
||||
tools: [toolDisplay],
|
||||
});
|
||||
});
|
||||
|
||||
setPendingItem(null);
|
||||
|
||||
if (
|
||||
outcome === ToolConfirmationOutcome.Cancel ||
|
||||
!approvedCommands ||
|
||||
approvedCommands.length === 0
|
||||
) {
|
||||
addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: 'Slash command shell execution declined.',
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
return { type: 'handled' };
|
||||
}
|
||||
|
||||
@@ -521,6 +544,8 @@ export const useSlashCommandProcessor = (
|
||||
result.originalInvocation.raw,
|
||||
// Pass the approved commands as a one-time grant for this execution.
|
||||
new Set(approvedCommands),
|
||||
undefined,
|
||||
false, // Do not add to history again
|
||||
);
|
||||
}
|
||||
case 'confirm_action': {
|
||||
@@ -633,7 +658,6 @@ export const useSlashCommandProcessor = (
|
||||
commands,
|
||||
commandContext,
|
||||
addMessage,
|
||||
setShellConfirmationRequest,
|
||||
setSessionShellAllowlist,
|
||||
setIsProcessing,
|
||||
setConfirmationRequest,
|
||||
@@ -646,7 +670,6 @@ export const useSlashCommandProcessor = (
|
||||
slashCommands: commands,
|
||||
pendingHistoryItems,
|
||||
commandContext,
|
||||
shellConfirmationRequest,
|
||||
confirmationRequest,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -10,7 +10,6 @@ import type {
|
||||
MCPServerConfig,
|
||||
ThoughtSummary,
|
||||
ToolCallConfirmationDetails,
|
||||
ToolConfirmationOutcome,
|
||||
ToolResultDisplay,
|
||||
RetrieveUserQuotaResponse,
|
||||
SkillDefinition,
|
||||
@@ -417,14 +416,6 @@ export type SlashCommandProcessorResult =
|
||||
}
|
||||
| SubmitPromptResult;
|
||||
|
||||
export interface ShellConfirmationRequest {
|
||||
commands: string[];
|
||||
onConfirm: (
|
||||
outcome: ToolConfirmationOutcome,
|
||||
approvedCommands?: string[],
|
||||
) => void;
|
||||
}
|
||||
|
||||
export interface ConfirmationRequest {
|
||||
prompt: ReactNode;
|
||||
onConfirm: (confirm: boolean) => void;
|
||||
|
||||
@@ -694,6 +694,7 @@ export interface ToolExecuteConfirmationDetails {
|
||||
command: string;
|
||||
rootCommand: string;
|
||||
rootCommands: string[];
|
||||
commands?: string[];
|
||||
}
|
||||
|
||||
export interface ToolMcpConfirmationDetails {
|
||||
|
||||
Reference in New Issue
Block a user