mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-02-01 22:48:03 +00:00
Check folder trust before allowing add directory (#12652)
This commit is contained in:
@@ -112,6 +112,7 @@ vi.mock('@google/gemini-cli-core', async () => {
|
||||
return Promise.resolve({
|
||||
memoryContent: extensionPaths.join(',') || '',
|
||||
fileCount: extensionPaths?.length || 0,
|
||||
filePaths: extensionPaths,
|
||||
});
|
||||
},
|
||||
),
|
||||
@@ -1547,11 +1548,13 @@ describe('loadCliConfig with includeDirectories', () => {
|
||||
path.join(os.homedir(), 'settings', 'path2'),
|
||||
path.join(mockCwd, 'settings', 'path3'),
|
||||
];
|
||||
expect(config.getWorkspaceContext().getDirectories()).toEqual(
|
||||
expect.arrayContaining(expected),
|
||||
const directories = config.getWorkspaceContext().getDirectories();
|
||||
expect(directories).toEqual([mockCwd]);
|
||||
expect(config.getPendingIncludeDirectories()).toEqual(
|
||||
expect.arrayContaining(expected.filter((dir) => dir !== mockCwd)),
|
||||
);
|
||||
expect(config.getWorkspaceContext().getDirectories()).toHaveLength(
|
||||
expected.length,
|
||||
expect(config.getPendingIncludeDirectories()).toHaveLength(
|
||||
expected.length - 1,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -424,9 +424,7 @@ export async function loadCliConfig(
|
||||
const { memoryContent, fileCount, filePaths } =
|
||||
await loadServerHierarchicalMemory(
|
||||
cwd,
|
||||
settings.context?.loadMemoryFromIncludeDirectories
|
||||
? includeDirectories
|
||||
: [],
|
||||
[],
|
||||
debugMode,
|
||||
fileService,
|
||||
extensionManager,
|
||||
|
||||
@@ -105,6 +105,8 @@ import { ShellFocusContext } from './contexts/ShellFocusContext.js';
|
||||
import { useSessionResume } from './hooks/useSessionResume.js';
|
||||
import { type ExtensionManager } from '../config/extension-manager.js';
|
||||
import { requestConsentInteractive } from '../config/extensions/consent.js';
|
||||
import { useIncludeDirsTrust } from './hooks/useIncludeDirsTrust.js';
|
||||
import { isWorkspaceTrusted } from '../config/trustedFolders.js';
|
||||
import { disableMouseEvents, enableMouseEvents } from './utils/mouse.js';
|
||||
import { useAlternateBuffer } from './hooks/useAlternateBuffer.js';
|
||||
import { useSettings } from './contexts/SettingsContext.js';
|
||||
@@ -161,6 +163,9 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
const [isProcessing, setIsProcessing] = useState<boolean>(false);
|
||||
const [embeddedShellFocused, setEmbeddedShellFocused] = useState(false);
|
||||
const [showDebugProfiler, setShowDebugProfiler] = useState(false);
|
||||
const [customDialog, setCustomDialog] = useState<React.ReactNode | null>(
|
||||
null,
|
||||
);
|
||||
const [copyModeEnabled, setCopyModeEnabled] = useState(false);
|
||||
|
||||
const [shellModeActive, setShellModeActive] = useState(false);
|
||||
@@ -169,7 +174,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
const [historyRemountKey, setHistoryRemountKey] = useState(0);
|
||||
const [updateInfo, setUpdateInfo] = useState<UpdateObject | null>(null);
|
||||
const [isTrustedFolder, setIsTrustedFolder] = useState<boolean | undefined>(
|
||||
config.isTrustedFolder(),
|
||||
isWorkspaceTrusted(settings.merged).isTrusted,
|
||||
);
|
||||
|
||||
const [queueErrorMessage, setQueueErrorMessage] = useState<string | null>(
|
||||
@@ -591,6 +596,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
|
||||
slashCommandActions,
|
||||
extensionsUpdateStateInternal,
|
||||
isConfigInitialized,
|
||||
setCustomDialog,
|
||||
);
|
||||
|
||||
const performMemoryRefresh = useCallback(async () => {
|
||||
@@ -908,6 +914,8 @@ Logging in with Google... Please restart Gemini CLI to continue.
|
||||
} = useIdeTrustListener();
|
||||
const isInitialMount = useRef(true);
|
||||
|
||||
useIncludeDirsTrust(config, isTrustedFolder, historyManager, setCustomDialog);
|
||||
|
||||
useEffect(() => {
|
||||
let timeoutId: NodeJS.Timeout;
|
||||
|
||||
@@ -1263,6 +1271,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
|
||||
isFolderTrustDialogOpen ||
|
||||
!!shellConfirmationRequest ||
|
||||
!!confirmationRequest ||
|
||||
!!customDialog ||
|
||||
confirmUpdateExtensionRequests.length > 0 ||
|
||||
!!loopDetectionConfirmationRequest ||
|
||||
isThemeDialogOpen ||
|
||||
@@ -1382,6 +1391,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
|
||||
activePtyId,
|
||||
embeddedShellFocused,
|
||||
showDebugProfiler,
|
||||
customDialog,
|
||||
copyModeEnabled,
|
||||
warningMessage,
|
||||
}),
|
||||
@@ -1467,6 +1477,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
|
||||
historyManager,
|
||||
embeddedShellFocused,
|
||||
showDebugProfiler,
|
||||
customDialog,
|
||||
apiKeyDefaultValue,
|
||||
authState,
|
||||
copyModeEnabled,
|
||||
|
||||
@@ -4,14 +4,18 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
import type { Mock } from 'vitest';
|
||||
import { directoryCommand } from './directoryCommand.js';
|
||||
import { expandHomeDir } from '../utils/directoryUtils.js';
|
||||
import type { Config, WorkspaceContext } from '@google/gemini-cli-core';
|
||||
import type { CommandContext } from './types.js';
|
||||
import type { MultiFolderTrustDialogProps } from '../components/MultiFolderTrustDialog.js';
|
||||
import type { CommandContext, OpenCustomDialogActionReturn } from './types.js';
|
||||
import { MessageType } from '../types.js';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import * as trustedFolders from '../../config/trustedFolders.js';
|
||||
import type { LoadedTrustedFolders } from '../../config/trustedFolders.js';
|
||||
|
||||
describe('directoryCommand', () => {
|
||||
let mockContext: CommandContext;
|
||||
@@ -83,6 +87,18 @@ describe('directoryCommand', () => {
|
||||
});
|
||||
|
||||
describe('add', () => {
|
||||
it('should show an error in a restrictive sandbox', async () => {
|
||||
if (!addCommand?.action) throw new Error('No action');
|
||||
vi.mocked(mockConfig.isRestrictiveSandbox).mockReturnValue(true);
|
||||
const result = await addCommand.action(mockContext, '/some/path');
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content:
|
||||
'The /directory add command is not supported in restrictive sandbox profiles. Please use --include-directories when starting the session instead.',
|
||||
});
|
||||
});
|
||||
|
||||
it('should show an error if no path is provided', () => {
|
||||
if (!addCommand?.action) throw new Error('No action');
|
||||
addCommand.action(mockContext, '');
|
||||
@@ -142,6 +158,32 @@ describe('directoryCommand', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should add directory directly when folder trust is disabled', async () => {
|
||||
if (!addCommand?.action) throw new Error('No action');
|
||||
vi.spyOn(trustedFolders, 'isFolderTrustEnabled').mockReturnValue(false);
|
||||
const newPath = path.normalize('/home/user/new-project');
|
||||
|
||||
await addCommand.action(mockContext, newPath);
|
||||
|
||||
expect(mockWorkspaceContext.addDirectory).toHaveBeenCalledWith(newPath);
|
||||
});
|
||||
|
||||
it('should show an info message for an already added directory', async () => {
|
||||
const existingPath = path.normalize('/home/user/project1');
|
||||
if (!addCommand?.action) throw new Error('No action');
|
||||
await addCommand.action(mockContext, existingPath);
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.INFO,
|
||||
text: `The following directories are already in the workspace:\n- ${existingPath}`,
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
expect(mockWorkspaceContext.addDirectory).not.toHaveBeenCalledWith(
|
||||
existingPath,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle a mix of successful and failed additions', async () => {
|
||||
const validPath = path.normalize('/home/user/valid-project');
|
||||
const invalidPath = path.normalize('/home/user/invalid-project');
|
||||
@@ -174,6 +216,80 @@ describe('directoryCommand', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('add with folder trust enabled', () => {
|
||||
let mockIsPathTrusted: Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(trustedFolders, 'isFolderTrustEnabled').mockReturnValue(true);
|
||||
vi.spyOn(trustedFolders, 'isWorkspaceTrusted').mockReturnValue({
|
||||
isTrusted: true,
|
||||
source: 'file',
|
||||
});
|
||||
mockIsPathTrusted = vi.fn();
|
||||
const mockLoadedFolders = {
|
||||
isPathTrusted: mockIsPathTrusted,
|
||||
} as unknown as LoadedTrustedFolders;
|
||||
vi.spyOn(trustedFolders, 'loadTrustedFolders').mockReturnValue(
|
||||
mockLoadedFolders,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should add a trusted directory', async () => {
|
||||
if (!addCommand?.action) throw new Error('No action');
|
||||
mockIsPathTrusted.mockReturnValue(true);
|
||||
const newPath = path.normalize('/home/user/trusted-project');
|
||||
|
||||
await addCommand.action(mockContext, newPath);
|
||||
|
||||
expect(mockWorkspaceContext.addDirectory).toHaveBeenCalledWith(newPath);
|
||||
});
|
||||
|
||||
it('should show an error for an untrusted directory', async () => {
|
||||
if (!addCommand?.action) throw new Error('No action');
|
||||
mockIsPathTrusted.mockReturnValue(false);
|
||||
const newPath = path.normalize('/home/user/untrusted-project');
|
||||
|
||||
await addCommand.action(mockContext, newPath);
|
||||
|
||||
expect(mockWorkspaceContext.addDirectory).not.toHaveBeenCalled();
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.ERROR,
|
||||
text: expect.stringContaining('explicitly untrusted'),
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return a custom dialog for a directory with undefined trust', async () => {
|
||||
if (!addCommand?.action) throw new Error('No action');
|
||||
mockIsPathTrusted.mockReturnValue(undefined);
|
||||
const newPath = path.normalize('/home/user/undefined-trust-project');
|
||||
|
||||
const result = await addCommand.action(mockContext, newPath);
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
type: 'custom_dialog',
|
||||
component: expect.objectContaining({
|
||||
type: expect.any(Function), // React component for MultiFolderTrustDialog
|
||||
}),
|
||||
}),
|
||||
);
|
||||
if (!result) {
|
||||
throw new Error('Command did not return a result');
|
||||
}
|
||||
const component = (result as OpenCustomDialogActionReturn)
|
||||
.component as React.ReactElement<MultiFolderTrustDialogProps>;
|
||||
expect(component.props.folders.includes(newPath)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('should correctly expand a Windows-style home directory path', () => {
|
||||
const windowsPath = '%userprofile%\\Documents';
|
||||
const expectedPath = path.win32.join(os.homedir(), 'Documents');
|
||||
|
||||
@@ -4,11 +4,69 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
isFolderTrustEnabled,
|
||||
isWorkspaceTrusted,
|
||||
loadTrustedFolders,
|
||||
} from '../../config/trustedFolders.js';
|
||||
import { MultiFolderTrustDialog } from '../components/MultiFolderTrustDialog.js';
|
||||
import type { SlashCommand, CommandContext } from './types.js';
|
||||
import { CommandKind } from './types.js';
|
||||
import { MessageType } from '../types.js';
|
||||
import { MessageType, type HistoryItem } from '../types.js';
|
||||
import { refreshServerHierarchicalMemory } from '@google/gemini-cli-core';
|
||||
import { expandHomeDir } from '../utils/directoryUtils.js';
|
||||
import type { Config } from '@google/gemini-cli-core';
|
||||
|
||||
async function finishAddingDirectories(
|
||||
config: Config,
|
||||
addItem: (itemData: Omit<HistoryItem, 'id'>, baseTimestamp: number) => number,
|
||||
added: string[],
|
||||
errors: string[],
|
||||
) {
|
||||
if (!config) {
|
||||
addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: 'Configuration is not available.',
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (config.shouldLoadMemoryFromIncludeDirectories()) {
|
||||
await refreshServerHierarchicalMemory(config);
|
||||
}
|
||||
addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: `Successfully added GEMINI.md files from the following directories if there are:\n- ${added.join('\n- ')}`,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
} catch (error) {
|
||||
errors.push(`Error refreshing memory: ${(error as Error).message}`);
|
||||
}
|
||||
|
||||
if (added.length > 0) {
|
||||
const gemini = config.getGeminiClient();
|
||||
if (gemini) {
|
||||
await gemini.addDirectoryContext();
|
||||
}
|
||||
addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: `Successfully added directories:\n- ${added.join('\n- ')}`,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
addItem({ type: MessageType.ERROR, text: errors.join('\n') }, Date.now());
|
||||
}
|
||||
}
|
||||
|
||||
export const directoryCommand: SlashCommand = {
|
||||
name: 'directory',
|
||||
@@ -24,7 +82,7 @@ export const directoryCommand: SlashCommand = {
|
||||
action: async (context: CommandContext, args: string) => {
|
||||
const {
|
||||
ui: { addItem },
|
||||
services: { config },
|
||||
services: { config, settings },
|
||||
} = context;
|
||||
const [...rest] = args.split(' ');
|
||||
|
||||
@@ -39,7 +97,14 @@ export const directoryCommand: SlashCommand = {
|
||||
return;
|
||||
}
|
||||
|
||||
const workspaceContext = config.getWorkspaceContext();
|
||||
if (config.isRestrictiveSandbox()) {
|
||||
return {
|
||||
type: 'message' as const,
|
||||
messageType: 'error' as const,
|
||||
content:
|
||||
'The /directory add command is not supported in restrictive sandbox profiles. Please use --include-directories when starting the session instead.',
|
||||
};
|
||||
}
|
||||
|
||||
const pathsToAdd = rest
|
||||
.join(' ')
|
||||
@@ -56,63 +121,109 @@ export const directoryCommand: SlashCommand = {
|
||||
return;
|
||||
}
|
||||
|
||||
if (config.isRestrictiveSandbox()) {
|
||||
return {
|
||||
type: 'message' as const,
|
||||
messageType: 'error' as const,
|
||||
content:
|
||||
'The /directory add command is not supported in restrictive sandbox profiles. Please use --include-directories when starting the session instead.',
|
||||
};
|
||||
}
|
||||
|
||||
const added: string[] = [];
|
||||
const errors: string[] = [];
|
||||
const alreadyAdded: string[] = [];
|
||||
|
||||
const workspaceContext = config.getWorkspaceContext();
|
||||
const currentWorkspaceDirs = workspaceContext.getDirectories();
|
||||
const pathsToProcess: string[] = [];
|
||||
|
||||
for (const pathToAdd of pathsToAdd) {
|
||||
try {
|
||||
workspaceContext.addDirectory(expandHomeDir(pathToAdd.trim()));
|
||||
added.push(pathToAdd.trim());
|
||||
} catch (e) {
|
||||
const error = e as Error;
|
||||
errors.push(`Error adding '${pathToAdd.trim()}': ${error.message}`);
|
||||
const expandedPath = expandHomeDir(pathToAdd.trim());
|
||||
if (currentWorkspaceDirs.includes(expandedPath)) {
|
||||
alreadyAdded.push(pathToAdd.trim());
|
||||
} else {
|
||||
pathsToProcess.push(pathToAdd.trim());
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (config.shouldLoadMemoryFromIncludeDirectories()) {
|
||||
await refreshServerHierarchicalMemory(config);
|
||||
}
|
||||
if (alreadyAdded.length > 0) {
|
||||
addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: `Successfully added GEMINI.md files from the following directories if there are:\n- ${added.join('\n- ')}`,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
} catch (error) {
|
||||
errors.push(`Error refreshing memory: ${(error as Error).message}`);
|
||||
}
|
||||
|
||||
if (added.length > 0) {
|
||||
const gemini = config.getGeminiClient();
|
||||
if (gemini) {
|
||||
await gemini.addDirectoryContext();
|
||||
}
|
||||
addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: `Successfully added directories:\n- ${added.join('\n- ')}`,
|
||||
text: `The following directories are already in the workspace:\n- ${alreadyAdded.join(
|
||||
'\n- ',
|
||||
)}`,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
addItem(
|
||||
{ type: MessageType.ERROR, text: errors.join('\n') },
|
||||
Date.now(),
|
||||
);
|
||||
if (pathsToProcess.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
isFolderTrustEnabled(settings.merged) &&
|
||||
isWorkspaceTrusted(settings.merged).isTrusted
|
||||
) {
|
||||
const trustedFolders = loadTrustedFolders();
|
||||
const untrustedDirs: string[] = [];
|
||||
const undefinedTrustDirs: string[] = [];
|
||||
const trustedDirs: string[] = [];
|
||||
|
||||
for (const pathToAdd of pathsToProcess) {
|
||||
const expandedPath = expandHomeDir(pathToAdd.trim());
|
||||
const isTrusted = trustedFolders.isPathTrusted(expandedPath);
|
||||
if (isTrusted === false) {
|
||||
untrustedDirs.push(pathToAdd.trim());
|
||||
} else if (isTrusted === undefined) {
|
||||
undefinedTrustDirs.push(pathToAdd.trim());
|
||||
} else {
|
||||
trustedDirs.push(pathToAdd.trim());
|
||||
}
|
||||
}
|
||||
|
||||
if (untrustedDirs.length > 0) {
|
||||
errors.push(
|
||||
`The following directories are explicitly untrusted and cannot be added to a trusted workspace:\n- ${untrustedDirs.join(
|
||||
'\n- ',
|
||||
)}\nPlease use the permissions command to modify their trust level.`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const pathToAdd of trustedDirs) {
|
||||
try {
|
||||
workspaceContext.addDirectory(expandHomeDir(pathToAdd));
|
||||
added.push(pathToAdd);
|
||||
} catch (e) {
|
||||
const error = e as Error;
|
||||
errors.push(`Error adding '${pathToAdd}': ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (undefinedTrustDirs.length > 0) {
|
||||
return {
|
||||
type: 'custom_dialog',
|
||||
component: (
|
||||
<MultiFolderTrustDialog
|
||||
folders={undefinedTrustDirs}
|
||||
onComplete={context.ui.removeComponent}
|
||||
trustedDirs={added}
|
||||
errors={errors}
|
||||
finishAddingDirectories={finishAddingDirectories}
|
||||
config={config}
|
||||
addItem={addItem}
|
||||
/>
|
||||
),
|
||||
};
|
||||
}
|
||||
} else {
|
||||
for (const pathToAdd of pathsToProcess) {
|
||||
try {
|
||||
workspaceContext.addDirectory(expandHomeDir(pathToAdd.trim()));
|
||||
added.push(pathToAdd.trim());
|
||||
} catch (e) {
|
||||
const error = e as Error;
|
||||
errors.push(
|
||||
`Error adding '${pathToAdd.trim()}': ${error.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await finishAddingDirectories(config, addItem, added, errors);
|
||||
return;
|
||||
},
|
||||
},
|
||||
|
||||
@@ -72,6 +72,7 @@ export interface CommandContext {
|
||||
extensionsUpdateState: Map<string, ExtensionUpdateStatus>;
|
||||
dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void;
|
||||
addConfirmUpdateExtensionRequest: (value: ConfirmationRequest) => void;
|
||||
removeComponent: () => void;
|
||||
};
|
||||
// Session-specific data
|
||||
session: {
|
||||
@@ -169,6 +170,11 @@ export interface ConfirmActionReturn {
|
||||
};
|
||||
}
|
||||
|
||||
export interface OpenCustomDialogActionReturn {
|
||||
type: 'custom_dialog';
|
||||
component: ReactNode;
|
||||
}
|
||||
|
||||
export type SlashCommandActionReturn =
|
||||
| ToolActionReturn
|
||||
| MessageActionReturn
|
||||
@@ -177,7 +183,8 @@ export type SlashCommandActionReturn =
|
||||
| LoadHistoryActionReturn
|
||||
| SubmitPromptActionReturn
|
||||
| ConfirmShellCommandsActionReturn
|
||||
| ConfirmActionReturn;
|
||||
| ConfirmActionReturn
|
||||
| OpenCustomDialogActionReturn;
|
||||
|
||||
export enum CommandKind {
|
||||
BUILT_IN = 'built-in',
|
||||
|
||||
259
packages/cli/src/ui/components/MultiFolderTrustDialog.test.tsx
Normal file
259
packages/cli/src/ui/components/MultiFolderTrustDialog.test.tsx
Normal file
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { render } from '../../test-utils/render.js';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import {
|
||||
MultiFolderTrustDialog,
|
||||
MultiFolderTrustChoice,
|
||||
type MultiFolderTrustDialogProps,
|
||||
} from './MultiFolderTrustDialog.js';
|
||||
import { vi } from 'vitest';
|
||||
import {
|
||||
TrustLevel,
|
||||
type LoadedTrustedFolders,
|
||||
} from '../../config/trustedFolders.js';
|
||||
import * as trustedFolders from '../../config/trustedFolders.js';
|
||||
import * as directoryUtils from '../utils/directoryUtils.js';
|
||||
import type { Config } from '@google/gemini-cli-core';
|
||||
import { MessageType } from '../types.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
|
||||
|
||||
// Mocks
|
||||
vi.mock('../hooks/useKeypress.js');
|
||||
vi.mock('../../config/trustedFolders.js');
|
||||
vi.mock('../utils/directoryUtils.js');
|
||||
vi.mock('./shared/RadioButtonSelect.js');
|
||||
|
||||
const mockedUseKeypress = vi.mocked(useKeypress);
|
||||
const mockedRadioButtonSelect = vi.mocked(RadioButtonSelect);
|
||||
|
||||
const mockOnComplete = vi.fn();
|
||||
const mockFinishAddingDirectories = vi.fn();
|
||||
const mockAddItem = vi.fn();
|
||||
const mockAddDirectory = vi.fn();
|
||||
const mockSetValue = vi.fn();
|
||||
|
||||
const mockConfig = {
|
||||
getWorkspaceContext: () => ({
|
||||
addDirectory: mockAddDirectory,
|
||||
}),
|
||||
} as unknown as Config;
|
||||
|
||||
const mockTrustedFolders = {
|
||||
setValue: mockSetValue,
|
||||
} as unknown as LoadedTrustedFolders;
|
||||
|
||||
const defaultProps: MultiFolderTrustDialogProps = {
|
||||
folders: [],
|
||||
onComplete: mockOnComplete,
|
||||
trustedDirs: [],
|
||||
errors: [],
|
||||
finishAddingDirectories: mockFinishAddingDirectories,
|
||||
config: mockConfig,
|
||||
addItem: mockAddItem,
|
||||
};
|
||||
|
||||
describe('MultiFolderTrustDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(trustedFolders.loadTrustedFolders).mockReturnValue(
|
||||
mockTrustedFolders,
|
||||
);
|
||||
vi.mocked(directoryUtils.expandHomeDir).mockImplementation((path) => path);
|
||||
mockedRadioButtonSelect.mockImplementation((props) => (
|
||||
<div data-testid="RadioButtonSelect" {...props} />
|
||||
));
|
||||
});
|
||||
|
||||
it('renders the dialog with the list of folders', () => {
|
||||
const folders = ['/path/to/folder1', '/path/to/folder2'];
|
||||
const { lastFrame } = render(
|
||||
<MultiFolderTrustDialog {...defaultProps} folders={folders} />,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain(
|
||||
'Do you trust the following folders being added to this workspace?',
|
||||
);
|
||||
expect(lastFrame()).toContain('- /path/to/folder1');
|
||||
expect(lastFrame()).toContain('- /path/to/folder2');
|
||||
});
|
||||
|
||||
it('calls onComplete and finishAddingDirectories with an error on escape', async () => {
|
||||
const folders = ['/path/to/folder1'];
|
||||
render(<MultiFolderTrustDialog {...defaultProps} folders={folders} />);
|
||||
|
||||
const keypressCallback = mockedUseKeypress.mock.calls[0][0];
|
||||
await act(async () => {
|
||||
await keypressCallback({
|
||||
name: 'escape',
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
shift: false,
|
||||
paste: false,
|
||||
sequence: '',
|
||||
insertable: false,
|
||||
});
|
||||
});
|
||||
|
||||
expect(mockFinishAddingDirectories).toHaveBeenCalledWith(
|
||||
mockConfig,
|
||||
mockAddItem,
|
||||
[],
|
||||
[
|
||||
'Operation cancelled. The following directories were not added:\n- /path/to/folder1',
|
||||
],
|
||||
);
|
||||
expect(mockOnComplete).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls finishAddingDirectories with an error and does not add directories when "No" is chosen', async () => {
|
||||
const folders = ['/path/to/folder1'];
|
||||
render(<MultiFolderTrustDialog {...defaultProps} folders={folders} />);
|
||||
|
||||
const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0];
|
||||
await act(async () => {
|
||||
await onSelect(MultiFolderTrustChoice.NO);
|
||||
});
|
||||
|
||||
expect(mockFinishAddingDirectories).toHaveBeenCalledWith(
|
||||
mockConfig,
|
||||
mockAddItem,
|
||||
[],
|
||||
[
|
||||
'The following directories were not added because they were not trusted:\n- /path/to/folder1',
|
||||
],
|
||||
);
|
||||
expect(mockOnComplete).toHaveBeenCalled();
|
||||
expect(mockAddDirectory).not.toHaveBeenCalled();
|
||||
expect(mockSetValue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('adds directories to workspace context when "Yes" is chosen', async () => {
|
||||
const folders = ['/path/to/folder1', '/path/to/folder2'];
|
||||
render(
|
||||
<MultiFolderTrustDialog
|
||||
{...defaultProps}
|
||||
folders={folders}
|
||||
trustedDirs={['/already/trusted']}
|
||||
/>,
|
||||
);
|
||||
|
||||
const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0];
|
||||
await act(async () => {
|
||||
await onSelect(MultiFolderTrustChoice.YES);
|
||||
});
|
||||
|
||||
expect(mockAddDirectory).toHaveBeenCalledWith('/path/to/folder1');
|
||||
expect(mockAddDirectory).toHaveBeenCalledWith('/path/to/folder2');
|
||||
expect(mockSetValue).not.toHaveBeenCalled();
|
||||
expect(mockFinishAddingDirectories).toHaveBeenCalledWith(
|
||||
mockConfig,
|
||||
mockAddItem,
|
||||
['/already/trusted', '/path/to/folder1', '/path/to/folder2'],
|
||||
[],
|
||||
);
|
||||
expect(mockOnComplete).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('adds directories to workspace context and remembers them as trusted when "Yes, and remember" is chosen', async () => {
|
||||
const folders = ['/path/to/folder1'];
|
||||
render(<MultiFolderTrustDialog {...defaultProps} folders={folders} />);
|
||||
|
||||
const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0];
|
||||
await act(async () => {
|
||||
await onSelect(MultiFolderTrustChoice.YES_AND_REMEMBER);
|
||||
});
|
||||
|
||||
expect(mockAddDirectory).toHaveBeenCalledWith('/path/to/folder1');
|
||||
expect(mockSetValue).toHaveBeenCalledWith(
|
||||
'/path/to/folder1',
|
||||
TrustLevel.TRUST_FOLDER,
|
||||
);
|
||||
expect(mockFinishAddingDirectories).toHaveBeenCalledWith(
|
||||
mockConfig,
|
||||
mockAddItem,
|
||||
['/path/to/folder1'],
|
||||
[],
|
||||
);
|
||||
expect(mockOnComplete).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows submitting message after a choice is made', async () => {
|
||||
const folders = ['/path/to/folder1'];
|
||||
const { lastFrame } = render(
|
||||
<MultiFolderTrustDialog {...defaultProps} folders={folders} />,
|
||||
);
|
||||
|
||||
const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0];
|
||||
|
||||
await act(async () => {
|
||||
await onSelect(MultiFolderTrustChoice.NO);
|
||||
});
|
||||
|
||||
expect(lastFrame()).toContain('Applying trust settings...');
|
||||
});
|
||||
|
||||
it('shows an error message and completes when config is missing', async () => {
|
||||
const folders = ['/path/to/folder1'];
|
||||
render(
|
||||
<MultiFolderTrustDialog
|
||||
{...defaultProps}
|
||||
folders={folders}
|
||||
config={null as unknown as Config}
|
||||
/>,
|
||||
);
|
||||
|
||||
const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0];
|
||||
await act(async () => {
|
||||
await onSelect(MultiFolderTrustChoice.YES);
|
||||
});
|
||||
|
||||
expect(mockAddItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: 'Configuration is not available.',
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
expect(mockOnComplete).toHaveBeenCalled();
|
||||
expect(mockFinishAddingDirectories).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('collects and reports errors when some directories fail to be added', async () => {
|
||||
vi.mocked(directoryUtils.expandHomeDir).mockImplementation((path) => {
|
||||
if (path === '/path/to/error') {
|
||||
throw new Error('Test error');
|
||||
}
|
||||
return path;
|
||||
});
|
||||
|
||||
const folders = ['/path/to/good', '/path/to/error'];
|
||||
render(
|
||||
<MultiFolderTrustDialog
|
||||
{...defaultProps}
|
||||
folders={folders}
|
||||
errors={['initial error']}
|
||||
/>,
|
||||
);
|
||||
|
||||
const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0];
|
||||
await act(async () => {
|
||||
await onSelect(MultiFolderTrustChoice.YES);
|
||||
});
|
||||
|
||||
expect(mockAddDirectory).toHaveBeenCalledWith('/path/to/good');
|
||||
expect(mockAddDirectory).not.toHaveBeenCalledWith('/path/to/error');
|
||||
expect(mockFinishAddingDirectories).toHaveBeenCalledWith(
|
||||
mockConfig,
|
||||
mockAddItem,
|
||||
['/path/to/good'],
|
||||
['initial error', "Error adding '/path/to/error': Test error"],
|
||||
);
|
||||
expect(mockOnComplete).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
176
packages/cli/src/ui/components/MultiFolderTrustDialog.tsx
Normal file
176
packages/cli/src/ui/components/MultiFolderTrustDialog.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { Box, Text } from 'ink';
|
||||
import type React from 'react';
|
||||
import { useState } from 'react';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import type { RadioSelectItem } from './shared/RadioButtonSelect.js';
|
||||
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { loadTrustedFolders, TrustLevel } from '../../config/trustedFolders.js';
|
||||
import { expandHomeDir } from '../utils/directoryUtils.js';
|
||||
import { MessageType, type HistoryItem } from '../types.js';
|
||||
import type { Config } from '@google/gemini-cli-core';
|
||||
|
||||
export enum MultiFolderTrustChoice {
|
||||
YES,
|
||||
YES_AND_REMEMBER,
|
||||
NO,
|
||||
}
|
||||
|
||||
export interface MultiFolderTrustDialogProps {
|
||||
folders: string[];
|
||||
onComplete: () => void;
|
||||
trustedDirs: string[];
|
||||
errors: string[];
|
||||
finishAddingDirectories: (
|
||||
config: Config,
|
||||
addItem: (
|
||||
itemData: Omit<HistoryItem, 'id'>,
|
||||
baseTimestamp: number,
|
||||
) => number,
|
||||
added: string[],
|
||||
errors: string[],
|
||||
) => Promise<void>;
|
||||
config: Config;
|
||||
addItem: (itemData: Omit<HistoryItem, 'id'>, baseTimestamp: number) => number;
|
||||
}
|
||||
|
||||
export const MultiFolderTrustDialog: React.FC<MultiFolderTrustDialogProps> = ({
|
||||
folders,
|
||||
onComplete,
|
||||
trustedDirs,
|
||||
errors: initialErrors,
|
||||
finishAddingDirectories,
|
||||
config,
|
||||
addItem,
|
||||
}) => {
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
|
||||
const handleCancel = async () => {
|
||||
setSubmitted(true);
|
||||
const errors = [...initialErrors];
|
||||
errors.push(
|
||||
`Operation cancelled. The following directories were not added:\n- ${folders.join(
|
||||
'\n- ',
|
||||
)}`,
|
||||
);
|
||||
await finishAddingDirectories(config, addItem, trustedDirs, errors);
|
||||
onComplete();
|
||||
};
|
||||
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (key.name === 'escape') {
|
||||
handleCancel();
|
||||
}
|
||||
},
|
||||
{ isActive: !submitted },
|
||||
);
|
||||
|
||||
const options: Array<RadioSelectItem<MultiFolderTrustChoice>> = [
|
||||
{
|
||||
label: 'Yes',
|
||||
value: MultiFolderTrustChoice.YES,
|
||||
key: 'yes',
|
||||
},
|
||||
{
|
||||
label: 'Yes, and remember the directories as trusted',
|
||||
value: MultiFolderTrustChoice.YES_AND_REMEMBER,
|
||||
key: 'yes-and-remember',
|
||||
},
|
||||
{
|
||||
label: 'No',
|
||||
value: MultiFolderTrustChoice.NO,
|
||||
key: 'no',
|
||||
},
|
||||
];
|
||||
|
||||
const handleSelect = async (choice: MultiFolderTrustChoice) => {
|
||||
setSubmitted(true);
|
||||
|
||||
if (!config) {
|
||||
addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: 'Configuration is not available.',
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
onComplete();
|
||||
return;
|
||||
}
|
||||
|
||||
const workspaceContext = config.getWorkspaceContext();
|
||||
const trustedFolders = loadTrustedFolders();
|
||||
const errors = [...initialErrors];
|
||||
const added = [...trustedDirs];
|
||||
|
||||
if (choice === MultiFolderTrustChoice.NO) {
|
||||
errors.push(
|
||||
`The following directories were not added because they were not trusted:\n- ${folders.join(
|
||||
'\n- ',
|
||||
)}`,
|
||||
);
|
||||
} else {
|
||||
for (const dir of folders) {
|
||||
try {
|
||||
const expandedPath = expandHomeDir(dir);
|
||||
if (choice === MultiFolderTrustChoice.YES_AND_REMEMBER) {
|
||||
trustedFolders.setValue(expandedPath, TrustLevel.TRUST_FOLDER);
|
||||
}
|
||||
workspaceContext.addDirectory(expandedPath);
|
||||
added.push(dir);
|
||||
} catch (e) {
|
||||
const error = e as Error;
|
||||
errors.push(`Error adding '${dir}': ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await finishAddingDirectories(config, addItem, added, errors);
|
||||
onComplete();
|
||||
};
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={theme.status.warning}
|
||||
padding={1}
|
||||
width="100%"
|
||||
marginLeft={1}
|
||||
>
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text bold color={theme.text.primary}>
|
||||
Do you trust the following folders being added to this workspace?
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}>
|
||||
{folders.map((f) => `- ${f}`).join('\n')}
|
||||
</Text>
|
||||
<Text color={theme.text.primary}>
|
||||
Trusting a folder allows Gemini to read and perform auto-edits when
|
||||
in auto-approval mode. This is a security feature to prevent
|
||||
accidental execution in untrusted directories.
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<RadioButtonSelect
|
||||
items={options}
|
||||
onSelect={handleSelect}
|
||||
isFocused={!submitted}
|
||||
/>
|
||||
</Box>
|
||||
{submitted && (
|
||||
<Box marginLeft={1} marginTop={1}>
|
||||
<Text color={theme.text.primary}>Applying trust settings...</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -126,6 +126,7 @@ export interface UIState {
|
||||
showFullTodos: boolean;
|
||||
copyModeEnabled: boolean;
|
||||
warningMessage: string | null;
|
||||
customDialog: React.ReactNode | null;
|
||||
}
|
||||
|
||||
export const UIStateContext = createContext<UIState | null>(null);
|
||||
|
||||
@@ -201,6 +201,7 @@ describe('useSlashCommandProcessor', () => {
|
||||
},
|
||||
new Map(), // extensionsUpdateState
|
||||
true, // isConfigInitialized
|
||||
vi.fn(), // setCustomDialog
|
||||
),
|
||||
);
|
||||
result = hook.result;
|
||||
|
||||
@@ -77,6 +77,7 @@ export const useSlashCommandProcessor = (
|
||||
actions: SlashCommandProcessorActions,
|
||||
extensionsUpdateState: Map<string, ExtensionUpdateStatus>,
|
||||
isConfigInitialized: boolean,
|
||||
setCustomDialog: (dialog: React.ReactNode | null) => void,
|
||||
) => {
|
||||
const session = useSessionStats();
|
||||
const [commands, setCommands] = useState<readonly SlashCommand[] | undefined>(
|
||||
@@ -215,6 +216,7 @@ export const useSlashCommandProcessor = (
|
||||
dispatchExtensionStateUpdate: actions.dispatchExtensionStateUpdate,
|
||||
addConfirmUpdateExtensionRequest:
|
||||
actions.addConfirmUpdateExtensionRequest,
|
||||
removeComponent: () => setCustomDialog(null),
|
||||
},
|
||||
session: {
|
||||
stats: session.stats,
|
||||
@@ -239,6 +241,7 @@ export const useSlashCommandProcessor = (
|
||||
sessionShellAllowlist,
|
||||
reloadCommands,
|
||||
extensionsUpdateState,
|
||||
setCustomDialog,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -505,6 +508,10 @@ export const useSlashCommandProcessor = (
|
||||
true,
|
||||
);
|
||||
}
|
||||
case 'custom_dialog': {
|
||||
setCustomDialog(result.component);
|
||||
return { type: 'handled' };
|
||||
}
|
||||
default: {
|
||||
const unhandled: never = result;
|
||||
throw new Error(
|
||||
@@ -578,6 +585,7 @@ export const useSlashCommandProcessor = (
|
||||
setSessionShellAllowlist,
|
||||
setIsProcessing,
|
||||
setConfirmationRequest,
|
||||
setCustomDialog,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
214
packages/cli/src/ui/hooks/useIncludeDirsTrust.test.tsx
Normal file
214
packages/cli/src/ui/hooks/useIncludeDirsTrust.test.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
import type { Mock } from 'vitest';
|
||||
import { renderHook } from '../../test-utils/render.js';
|
||||
import { waitFor } from '../../test-utils/async.js';
|
||||
import { useIncludeDirsTrust } from './useIncludeDirsTrust.js';
|
||||
import * as trustedFolders from '../../config/trustedFolders.js';
|
||||
import type { Config, WorkspaceContext } from '@google/gemini-cli-core';
|
||||
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
|
||||
import type { LoadedTrustedFolders } from '../../config/trustedFolders.js';
|
||||
|
||||
import type { MultiFolderTrustDialogProps } from '../components/MultiFolderTrustDialog.js';
|
||||
|
||||
vi.mock('../utils/directoryUtils.js', () => ({
|
||||
expandHomeDir: (p: string) => p, // Simple pass-through for testing
|
||||
loadMemoryFromDirectories: vi.fn().mockResolvedValue({ fileCount: 1 }),
|
||||
}));
|
||||
|
||||
vi.mock('../components/MultiFolderTrustDialog.js', () => ({
|
||||
MultiFolderTrustDialog: (props: MultiFolderTrustDialogProps) => (
|
||||
<div data-testid="mock-dialog">{JSON.stringify(props.folders)}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
describe('useIncludeDirsTrust', () => {
|
||||
let mockConfig: Config;
|
||||
let mockHistoryManager: UseHistoryManagerReturn;
|
||||
let mockSetCustomDialog: Mock;
|
||||
let mockWorkspaceContext: WorkspaceContext;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockWorkspaceContext = {
|
||||
addDirectory: vi.fn(),
|
||||
getDirectories: vi.fn().mockReturnValue([]),
|
||||
onDirectoriesChangedListeners: new Set(),
|
||||
onDirectoriesChanged: vi.fn(),
|
||||
notifyDirectoriesChanged: vi.fn(),
|
||||
resolveAndValidateDir: vi.fn(),
|
||||
getInitialDirectories: vi.fn(),
|
||||
setDirectories: vi.fn(),
|
||||
isPathWithinWorkspace: vi.fn(),
|
||||
fullyResolvedPath: vi.fn(),
|
||||
isPathWithinRoot: vi.fn(),
|
||||
isFileSymlink: vi.fn(),
|
||||
} as unknown as ReturnType<typeof mockConfig.getWorkspaceContext>;
|
||||
|
||||
mockConfig = {
|
||||
getPendingIncludeDirectories: vi.fn().mockReturnValue([]),
|
||||
clearPendingIncludeDirectories: vi.fn(),
|
||||
getFolderTrust: vi.fn().mockReturnValue(true),
|
||||
getWorkspaceContext: () => mockWorkspaceContext,
|
||||
getGeminiClient: vi
|
||||
.fn()
|
||||
.mockReturnValue({ addDirectoryContext: vi.fn() }),
|
||||
} as unknown as Config;
|
||||
|
||||
mockHistoryManager = {
|
||||
addItem: vi.fn(),
|
||||
history: [],
|
||||
updateItem: vi.fn(),
|
||||
clearItems: vi.fn(),
|
||||
loadHistory: vi.fn(),
|
||||
};
|
||||
mockSetCustomDialog = vi.fn();
|
||||
});
|
||||
|
||||
const renderTestHook = (isTrustedFolder: boolean | undefined) => {
|
||||
renderHook(() =>
|
||||
useIncludeDirsTrust(
|
||||
mockConfig,
|
||||
isTrustedFolder,
|
||||
mockHistoryManager,
|
||||
mockSetCustomDialog,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
it('should do nothing if isTrustedFolder is undefined', () => {
|
||||
vi.mocked(mockConfig.getPendingIncludeDirectories).mockReturnValue([
|
||||
'/foo',
|
||||
]);
|
||||
renderTestHook(undefined);
|
||||
expect(mockConfig.clearPendingIncludeDirectories).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should do nothing if there are no pending directories', () => {
|
||||
renderTestHook(true);
|
||||
expect(mockConfig.clearPendingIncludeDirectories).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('when folder trust is disabled or workspace is untrusted', () => {
|
||||
it.each([
|
||||
{ trustEnabled: false, isTrusted: true, scenario: 'trust is disabled' },
|
||||
{
|
||||
trustEnabled: true,
|
||||
isTrusted: false,
|
||||
scenario: 'workspace is untrusted',
|
||||
},
|
||||
])(
|
||||
'should add directories directly when $scenario',
|
||||
async ({ trustEnabled, isTrusted }) => {
|
||||
vi.mocked(mockConfig.getFolderTrust).mockReturnValue(trustEnabled);
|
||||
vi.mocked(mockConfig.getPendingIncludeDirectories).mockReturnValue([
|
||||
'/dir1',
|
||||
'/dir2',
|
||||
]);
|
||||
vi.mocked(mockWorkspaceContext.addDirectory).mockImplementation(
|
||||
(path) => {
|
||||
if (path === '/dir2') {
|
||||
throw new Error('Test error');
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
renderTestHook(isTrusted);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockWorkspaceContext.addDirectory).toHaveBeenCalledWith(
|
||||
'/dir1',
|
||||
);
|
||||
expect(mockWorkspaceContext.addDirectory).toHaveBeenCalledWith(
|
||||
'/dir2',
|
||||
);
|
||||
expect(mockHistoryManager.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
text: expect.stringContaining("Error adding '/dir2': Test error"),
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
expect(
|
||||
mockConfig.clearPendingIncludeDirectories,
|
||||
).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('when folder trust is enabled and workspace is trusted', () => {
|
||||
let mockIsPathTrusted: Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(mockConfig, 'getFolderTrust').mockReturnValue(true);
|
||||
mockIsPathTrusted = vi.fn();
|
||||
const mockLoadedFolders = {
|
||||
isPathTrusted: mockIsPathTrusted,
|
||||
} as unknown as LoadedTrustedFolders;
|
||||
vi.spyOn(trustedFolders, 'loadTrustedFolders').mockReturnValue(
|
||||
mockLoadedFolders,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should add trusted dirs, collect untrusted errors, and open dialog for undefined', async () => {
|
||||
const pendingDirs = ['/trusted', '/untrusted', '/undefined'];
|
||||
vi.mocked(mockConfig.getPendingIncludeDirectories).mockReturnValue(
|
||||
pendingDirs,
|
||||
);
|
||||
|
||||
mockIsPathTrusted.mockImplementation((path: string) => {
|
||||
if (path === '/trusted') return true;
|
||||
if (path === '/untrusted') return false;
|
||||
return undefined;
|
||||
});
|
||||
|
||||
renderTestHook(true);
|
||||
|
||||
// Opens dialog for undefined trust dir
|
||||
expect(mockSetCustomDialog).toHaveBeenCalledTimes(1);
|
||||
const customDialogAction = mockSetCustomDialog.mock.calls[0][0];
|
||||
expect(customDialogAction).toBeDefined();
|
||||
const dialogProps = (
|
||||
customDialogAction as React.ReactElement<MultiFolderTrustDialogProps>
|
||||
).props;
|
||||
expect(dialogProps.folders).toEqual(['/undefined']);
|
||||
expect(dialogProps.trustedDirs).toEqual(['/trusted']);
|
||||
expect(dialogProps.errors as string[]).toEqual([
|
||||
`The following directories are explicitly untrusted and cannot be added to a trusted workspace:\n- /untrusted\nPlease use the permissions command to modify their trust level.`,
|
||||
]);
|
||||
});
|
||||
|
||||
it('should only add directories and clear pending if no dialog is needed', async () => {
|
||||
const pendingDirs = ['/trusted1', '/trusted2'];
|
||||
vi.mocked(mockConfig.getPendingIncludeDirectories).mockReturnValue(
|
||||
pendingDirs,
|
||||
);
|
||||
mockIsPathTrusted.mockReturnValue(true);
|
||||
|
||||
renderTestHook(true);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockWorkspaceContext.addDirectory).toHaveBeenCalledWith(
|
||||
'/trusted1',
|
||||
);
|
||||
expect(mockWorkspaceContext.addDirectory).toHaveBeenCalledWith(
|
||||
'/trusted2',
|
||||
);
|
||||
expect(mockSetCustomDialog).not.toHaveBeenCalled();
|
||||
expect(mockConfig.clearPendingIncludeDirectories).toHaveBeenCalledTimes(
|
||||
1,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
160
packages/cli/src/ui/hooks/useIncludeDirsTrust.tsx
Normal file
160
packages/cli/src/ui/hooks/useIncludeDirsTrust.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import type { Config } from '@google/gemini-cli-core';
|
||||
import { loadTrustedFolders } from '../../config/trustedFolders.js';
|
||||
import { expandHomeDir } from '../utils/directoryUtils.js';
|
||||
import { refreshServerHierarchicalMemory } from '@google/gemini-cli-core';
|
||||
import { MultiFolderTrustDialog } from '../components/MultiFolderTrustDialog.js';
|
||||
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
|
||||
import { MessageType, type HistoryItem } from '../types.js';
|
||||
|
||||
async function finishAddingDirectories(
|
||||
config: Config,
|
||||
addItem: (itemData: Omit<HistoryItem, 'id'>, baseTimestamp: number) => number,
|
||||
added: string[],
|
||||
errors: string[],
|
||||
) {
|
||||
if (!config) {
|
||||
addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: 'Configuration is not available.',
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (config.shouldLoadMemoryFromIncludeDirectories()) {
|
||||
await refreshServerHierarchicalMemory(config);
|
||||
}
|
||||
} catch (error) {
|
||||
errors.push(`Error refreshing memory: ${(error as Error).message}`);
|
||||
}
|
||||
|
||||
if (added.length > 0) {
|
||||
const gemini = config.getGeminiClient();
|
||||
if (gemini) {
|
||||
await gemini.addDirectoryContext();
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
addItem({ type: MessageType.ERROR, text: errors.join('\n') }, Date.now());
|
||||
}
|
||||
}
|
||||
|
||||
export function useIncludeDirsTrust(
|
||||
config: Config,
|
||||
isTrustedFolder: boolean | undefined,
|
||||
historyManager: UseHistoryManagerReturn,
|
||||
setCustomDialog: (dialog: React.ReactNode | null) => void,
|
||||
) {
|
||||
const { addItem } = historyManager;
|
||||
|
||||
useEffect(() => {
|
||||
// Don't run this until the initial trust is determined.
|
||||
if (isTrustedFolder === undefined || !config) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pendingDirs = config.getPendingIncludeDirectories();
|
||||
if (pendingDirs.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Inside useIncludeDirsTrust');
|
||||
|
||||
// If folder trust is disabled, isTrustedFolder will be undefined.
|
||||
// In that case, or if the user decided not to trust the main folder,
|
||||
// we can just add the directories without checking them.
|
||||
if (config.getFolderTrust() === false || isTrustedFolder === false) {
|
||||
const added: string[] = [];
|
||||
const errors: string[] = [];
|
||||
const workspaceContext = config.getWorkspaceContext();
|
||||
for (const pathToAdd of pendingDirs) {
|
||||
try {
|
||||
workspaceContext.addDirectory(expandHomeDir(pathToAdd.trim()));
|
||||
added.push(pathToAdd.trim());
|
||||
} catch (e) {
|
||||
const error = e as Error;
|
||||
errors.push(`Error adding '${pathToAdd.trim()}': ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (added.length > 0 || errors.length > 0) {
|
||||
finishAddingDirectories(config, addItem, added, errors);
|
||||
}
|
||||
config.clearPendingIncludeDirectories();
|
||||
return;
|
||||
}
|
||||
|
||||
const trustedFolders = loadTrustedFolders();
|
||||
const untrustedDirs: string[] = [];
|
||||
const undefinedTrustDirs: string[] = [];
|
||||
const trustedDirs: string[] = [];
|
||||
const added: string[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const pathToAdd of pendingDirs) {
|
||||
const expandedPath = expandHomeDir(pathToAdd.trim());
|
||||
const isTrusted = trustedFolders.isPathTrusted(expandedPath);
|
||||
if (isTrusted === false) {
|
||||
untrustedDirs.push(pathToAdd.trim());
|
||||
} else if (isTrusted === undefined) {
|
||||
undefinedTrustDirs.push(pathToAdd.trim());
|
||||
} else {
|
||||
trustedDirs.push(pathToAdd.trim());
|
||||
}
|
||||
}
|
||||
|
||||
if (untrustedDirs.length > 0) {
|
||||
errors.push(
|
||||
`The following directories are explicitly untrusted and cannot be added to a trusted workspace:\n- ${untrustedDirs.join(
|
||||
'\n- ',
|
||||
)}\nPlease use the permissions command to modify their trust level.`,
|
||||
);
|
||||
}
|
||||
|
||||
const workspaceContext = config.getWorkspaceContext();
|
||||
for (const pathToAdd of trustedDirs) {
|
||||
try {
|
||||
workspaceContext.addDirectory(expandHomeDir(pathToAdd));
|
||||
added.push(pathToAdd);
|
||||
} catch (e) {
|
||||
const error = e as Error;
|
||||
errors.push(`Error adding '${pathToAdd}': ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (undefinedTrustDirs.length > 0) {
|
||||
console.log(
|
||||
'Creating custom dialog with undecidedDirs:',
|
||||
undefinedTrustDirs,
|
||||
);
|
||||
setCustomDialog(
|
||||
<MultiFolderTrustDialog
|
||||
folders={undefinedTrustDirs}
|
||||
onComplete={() => {
|
||||
setCustomDialog(null);
|
||||
config.clearPendingIncludeDirectories();
|
||||
}}
|
||||
trustedDirs={added}
|
||||
errors={errors}
|
||||
finishAddingDirectories={finishAddingDirectories}
|
||||
config={config}
|
||||
addItem={addItem}
|
||||
/>,
|
||||
);
|
||||
} else if (added.length > 0 || errors.length > 0) {
|
||||
finishAddingDirectories(config, addItem, added, errors);
|
||||
config.clearPendingIncludeDirectories();
|
||||
}
|
||||
}, [isTrustedFolder, config, addItem, setCustomDialog]);
|
||||
}
|
||||
@@ -48,7 +48,9 @@ export const DefaultAppLayout: React.FC = () => {
|
||||
<Notifications />
|
||||
<CopyModeWarning />
|
||||
|
||||
{uiState.dialogsVisible ? (
|
||||
{uiState.customDialog ? (
|
||||
uiState.customDialog
|
||||
) : uiState.dialogsVisible ? (
|
||||
<DialogManager
|
||||
terminalWidth={uiState.mainAreaWidth}
|
||||
addItem={uiState.historyManager.addItem}
|
||||
|
||||
@@ -27,5 +27,6 @@ export function createNonInteractiveUI(): CommandContext['ui'] {
|
||||
extensionsUpdateState: new Map(),
|
||||
dispatchExtensionStateUpdate: (_action: ExtensionUpdateAction) => {},
|
||||
addConfirmUpdateExtensionRequest: (_request) => {},
|
||||
removeComponent: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
63
packages/cli/src/ui/utils/directoryUtils.test.ts
Normal file
63
packages/cli/src/ui/utils/directoryUtils.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { vi, describe, it, expect } from 'vitest';
|
||||
import { expandHomeDir } from './directoryUtils.js';
|
||||
import type * as osActual from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const original =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...original,
|
||||
loadServerHierarchicalMemory: vi.fn().mockResolvedValue({
|
||||
memoryContent: 'mock memory',
|
||||
fileCount: 10,
|
||||
filePaths: ['/a/b/c.md'],
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const mockHomeDir =
|
||||
process.platform === 'win32' ? 'C:\\Users\\testuser' : '/home/testuser';
|
||||
|
||||
vi.mock('node:os', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof osActual>();
|
||||
return {
|
||||
...original,
|
||||
homedir: vi.fn(() => mockHomeDir),
|
||||
};
|
||||
});
|
||||
|
||||
describe('directoryUtils', () => {
|
||||
describe('expandHomeDir', () => {
|
||||
it('should expand ~ to the home directory', () => {
|
||||
expect(expandHomeDir('~')).toBe(mockHomeDir);
|
||||
});
|
||||
|
||||
it('should expand ~/path to the home directory path', () => {
|
||||
const expected = path.join(mockHomeDir, 'Documents');
|
||||
expect(expandHomeDir('~/Documents')).toBe(expected);
|
||||
});
|
||||
|
||||
it('should expand %userprofile% on Windows', () => {
|
||||
if (process.platform === 'win32') {
|
||||
const expected = path.join(mockHomeDir, 'Desktop');
|
||||
expect(expandHomeDir('%userprofile%\\Desktop')).toBe(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it('should not change a path that does not need expansion', () => {
|
||||
const regularPath = path.join('usr', 'local', 'bin');
|
||||
expect(expandHomeDir(regularPath)).toBe(regularPath);
|
||||
});
|
||||
|
||||
it('should return an empty string if input is empty', () => {
|
||||
expect(expandHomeDir('')).toBe('');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -430,7 +430,6 @@ describe('Server Config (config.ts)', () => {
|
||||
});
|
||||
|
||||
it('should initialize WorkspaceContext with includeDirectories', () => {
|
||||
const resolved = path.resolve(baseParams.targetDir);
|
||||
const includeDirectories = ['dir1', 'dir2'];
|
||||
const paramsWithIncludeDirs: ConfigParameters = {
|
||||
...baseParams,
|
||||
@@ -439,11 +438,13 @@ describe('Server Config (config.ts)', () => {
|
||||
const config = new Config(paramsWithIncludeDirs);
|
||||
const workspaceContext = config.getWorkspaceContext();
|
||||
const directories = workspaceContext.getDirectories();
|
||||
// Should include the target directory plus the included directories
|
||||
expect(directories).toHaveLength(3);
|
||||
expect(directories).toContain(resolved);
|
||||
expect(directories).toContain(path.join(resolved, 'dir1'));
|
||||
expect(directories).toContain(path.join(resolved, 'dir2'));
|
||||
|
||||
// Should include only the target directory initially
|
||||
expect(directories).toHaveLength(1);
|
||||
expect(directories).toContain(path.resolve(baseParams.targetDir));
|
||||
|
||||
// The other directories should be in the pending list
|
||||
expect(config.getPendingIncludeDirectories()).toEqual(includeDirectories);
|
||||
});
|
||||
|
||||
it('Config constructor should set telemetry to true when provided as true', () => {
|
||||
|
||||
@@ -411,6 +411,7 @@ export class Config {
|
||||
readonly fakeResponses?: string;
|
||||
readonly recordResponses?: string;
|
||||
private readonly disableYoloMode: boolean;
|
||||
private pendingIncludeDirectories: string[];
|
||||
private readonly enableHooks: boolean;
|
||||
private readonly hooks:
|
||||
| { [K in HookEventName]?: HookDefinition[] }
|
||||
@@ -425,10 +426,9 @@ export class Config {
|
||||
this.fileSystemService = new StandardFileSystemService();
|
||||
this.sandbox = params.sandbox;
|
||||
this.targetDir = path.resolve(params.targetDir);
|
||||
this.workspaceContext = new WorkspaceContext(
|
||||
this.targetDir,
|
||||
params.includeDirectories ?? [],
|
||||
);
|
||||
this.folderTrust = params.folderTrust ?? false;
|
||||
this.workspaceContext = new WorkspaceContext(this.targetDir, []);
|
||||
this.pendingIncludeDirectories = params.includeDirectories ?? [];
|
||||
this.debugMode = params.debugMode;
|
||||
this.question = params.question;
|
||||
|
||||
@@ -927,6 +927,14 @@ export class Config {
|
||||
return this.disableYoloMode || !this.isTrustedFolder();
|
||||
}
|
||||
|
||||
getPendingIncludeDirectories(): string[] {
|
||||
return this.pendingIncludeDirectories;
|
||||
}
|
||||
|
||||
clearPendingIncludeDirectories(): void {
|
||||
this.pendingIncludeDirectories = [];
|
||||
}
|
||||
|
||||
getShowMemoryUsage(): boolean {
|
||||
return this.showMemoryUsage;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user