From 9786c4dcff0cb09ede3cb46f21314849daa35f9d Mon Sep 17 00:00:00 2001 From: shrutip90 Date: Fri, 14 Nov 2025 19:06:30 -0800 Subject: [PATCH] Check folder trust before allowing add directory (#12652) --- packages/cli/src/config/config.test.ts | 11 +- packages/cli/src/config/config.ts | 4 +- packages/cli/src/ui/AppContainer.tsx | 13 +- .../src/ui/commands/directoryCommand.test.tsx | 120 +++++++- .../cli/src/ui/commands/directoryCommand.tsx | 199 +++++++++++--- packages/cli/src/ui/commands/types.ts | 9 +- .../MultiFolderTrustDialog.test.tsx | 259 ++++++++++++++++++ .../ui/components/MultiFolderTrustDialog.tsx | 176 ++++++++++++ .../cli/src/ui/contexts/UIStateContext.tsx | 1 + .../ui/hooks/slashCommandProcessor.test.tsx | 1 + .../cli/src/ui/hooks/slashCommandProcessor.ts | 8 + .../src/ui/hooks/useIncludeDirsTrust.test.tsx | 214 +++++++++++++++ .../cli/src/ui/hooks/useIncludeDirsTrust.tsx | 160 +++++++++++ .../cli/src/ui/layouts/DefaultAppLayout.tsx | 4 +- .../src/ui/noninteractive/nonInteractiveUi.ts | 1 + .../cli/src/ui/utils/directoryUtils.test.ts | 63 +++++ packages/core/src/config/config.test.ts | 13 +- packages/core/src/config/config.ts | 16 +- 18 files changed, 1206 insertions(+), 66 deletions(-) create mode 100644 packages/cli/src/ui/components/MultiFolderTrustDialog.test.tsx create mode 100644 packages/cli/src/ui/components/MultiFolderTrustDialog.tsx create mode 100644 packages/cli/src/ui/hooks/useIncludeDirsTrust.test.tsx create mode 100644 packages/cli/src/ui/hooks/useIncludeDirsTrust.tsx create mode 100644 packages/cli/src/ui/utils/directoryUtils.test.ts diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 6f9a66b8b3..a323c2aad9 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -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, ); }); }); diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 7396728d3c..c26d7ba2c4 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -424,9 +424,7 @@ export async function loadCliConfig( const { memoryContent, fileCount, filePaths } = await loadServerHierarchicalMemory( cwd, - settings.context?.loadMemoryFromIncludeDirectories - ? includeDirectories - : [], + [], debugMode, fileService, extensionManager, diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 0e1b936b76..1d29f72a80 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -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(false); const [embeddedShellFocused, setEmbeddedShellFocused] = useState(false); const [showDebugProfiler, setShowDebugProfiler] = useState(false); + const [customDialog, setCustomDialog] = useState( + 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(null); const [isTrustedFolder, setIsTrustedFolder] = useState( - config.isTrustedFolder(), + isWorkspaceTrusted(settings.merged).isTrusted, ); const [queueErrorMessage, setQueueErrorMessage] = useState( @@ -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, diff --git a/packages/cli/src/ui/commands/directoryCommand.test.tsx b/packages/cli/src/ui/commands/directoryCommand.test.tsx index 241c84f146..d56cf5251c 100644 --- a/packages/cli/src/ui/commands/directoryCommand.test.tsx +++ b/packages/cli/src/ui/commands/directoryCommand.test.tsx @@ -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; + 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'); diff --git a/packages/cli/src/ui/commands/directoryCommand.tsx b/packages/cli/src/ui/commands/directoryCommand.tsx index 52608a330e..ccf131d1ca 100644 --- a/packages/cli/src/ui/commands/directoryCommand.tsx +++ b/packages/cli/src/ui/commands/directoryCommand.tsx @@ -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, 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: ( + + ), + }; + } + } 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; }, }, diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index 01ab27ac96..56be07acdc 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -72,6 +72,7 @@ export interface CommandContext { extensionsUpdateState: Map; 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', diff --git a/packages/cli/src/ui/components/MultiFolderTrustDialog.test.tsx b/packages/cli/src/ui/components/MultiFolderTrustDialog.test.tsx new file mode 100644 index 0000000000..ef374d4e6d --- /dev/null +++ b/packages/cli/src/ui/components/MultiFolderTrustDialog.test.tsx @@ -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) => ( +
+ )); + }); + + it('renders the dialog with the list of folders', () => { + const folders = ['/path/to/folder1', '/path/to/folder2']; + const { lastFrame } = render( + , + ); + + 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(); + + 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(); + + 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( + , + ); + + 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(); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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(); + }); +}); diff --git a/packages/cli/src/ui/components/MultiFolderTrustDialog.tsx b/packages/cli/src/ui/components/MultiFolderTrustDialog.tsx new file mode 100644 index 0000000000..1f837d71ad --- /dev/null +++ b/packages/cli/src/ui/components/MultiFolderTrustDialog.tsx @@ -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, + baseTimestamp: number, + ) => number, + added: string[], + errors: string[], + ) => Promise; + config: Config; + addItem: (itemData: Omit, baseTimestamp: number) => number; +} + +export const MultiFolderTrustDialog: React.FC = ({ + 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> = [ + { + 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 ( + + + + + Do you trust the following folders being added to this workspace? + + + {folders.map((f) => `- ${f}`).join('\n')} + + + 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. + + + + + + {submitted && ( + + Applying trust settings... + + )} + + ); +}; diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index d46e58b567..9a6f536c66 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -126,6 +126,7 @@ export interface UIState { showFullTodos: boolean; copyModeEnabled: boolean; warningMessage: string | null; + customDialog: React.ReactNode | null; } export const UIStateContext = createContext(null); diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx b/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx index 503fc7e718..595498b2e5 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx @@ -201,6 +201,7 @@ describe('useSlashCommandProcessor', () => { }, new Map(), // extensionsUpdateState true, // isConfigInitialized + vi.fn(), // setCustomDialog ), ); result = hook.result; diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index 0c78757687..3484436fa4 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -77,6 +77,7 @@ export const useSlashCommandProcessor = ( actions: SlashCommandProcessorActions, extensionsUpdateState: Map, isConfigInitialized: boolean, + setCustomDialog: (dialog: React.ReactNode | null) => void, ) => { const session = useSessionStats(); const [commands, setCommands] = useState( @@ -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, ], ); diff --git a/packages/cli/src/ui/hooks/useIncludeDirsTrust.test.tsx b/packages/cli/src/ui/hooks/useIncludeDirsTrust.test.tsx new file mode 100644 index 0000000000..6fd3e5185d --- /dev/null +++ b/packages/cli/src/ui/hooks/useIncludeDirsTrust.test.tsx @@ -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) => ( +
{JSON.stringify(props.folders)}
+ ), +})); + +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; + + 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 + ).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, + ); + }); + }); + }); +}); diff --git a/packages/cli/src/ui/hooks/useIncludeDirsTrust.tsx b/packages/cli/src/ui/hooks/useIncludeDirsTrust.tsx new file mode 100644 index 0000000000..b4a39063c2 --- /dev/null +++ b/packages/cli/src/ui/hooks/useIncludeDirsTrust.tsx @@ -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, 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( + { + 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]); +} diff --git a/packages/cli/src/ui/layouts/DefaultAppLayout.tsx b/packages/cli/src/ui/layouts/DefaultAppLayout.tsx index 5c99a78fe9..bf68aee85d 100644 --- a/packages/cli/src/ui/layouts/DefaultAppLayout.tsx +++ b/packages/cli/src/ui/layouts/DefaultAppLayout.tsx @@ -48,7 +48,9 @@ export const DefaultAppLayout: React.FC = () => { - {uiState.dialogsVisible ? ( + {uiState.customDialog ? ( + uiState.customDialog + ) : uiState.dialogsVisible ? ( {}, addConfirmUpdateExtensionRequest: (_request) => {}, + removeComponent: () => {}, }; } diff --git a/packages/cli/src/ui/utils/directoryUtils.test.ts b/packages/cli/src/ui/utils/directoryUtils.test.ts new file mode 100644 index 0000000000..e86c44d1fa --- /dev/null +++ b/packages/cli/src/ui/utils/directoryUtils.test.ts @@ -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(); + 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(); + 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(''); + }); + }); +}); diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index 7be0c0456b..3b66301319 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -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', () => { diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 84e9af8bda..9953c88741 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -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; }