mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-24 22:55:13 +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({
|
return Promise.resolve({
|
||||||
memoryContent: extensionPaths.join(',') || '',
|
memoryContent: extensionPaths.join(',') || '',
|
||||||
fileCount: extensionPaths?.length || 0,
|
fileCount: extensionPaths?.length || 0,
|
||||||
|
filePaths: extensionPaths,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -1547,11 +1548,13 @@ describe('loadCliConfig with includeDirectories', () => {
|
|||||||
path.join(os.homedir(), 'settings', 'path2'),
|
path.join(os.homedir(), 'settings', 'path2'),
|
||||||
path.join(mockCwd, 'settings', 'path3'),
|
path.join(mockCwd, 'settings', 'path3'),
|
||||||
];
|
];
|
||||||
expect(config.getWorkspaceContext().getDirectories()).toEqual(
|
const directories = config.getWorkspaceContext().getDirectories();
|
||||||
expect.arrayContaining(expected),
|
expect(directories).toEqual([mockCwd]);
|
||||||
|
expect(config.getPendingIncludeDirectories()).toEqual(
|
||||||
|
expect.arrayContaining(expected.filter((dir) => dir !== mockCwd)),
|
||||||
);
|
);
|
||||||
expect(config.getWorkspaceContext().getDirectories()).toHaveLength(
|
expect(config.getPendingIncludeDirectories()).toHaveLength(
|
||||||
expected.length,
|
expected.length - 1,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -424,9 +424,7 @@ export async function loadCliConfig(
|
|||||||
const { memoryContent, fileCount, filePaths } =
|
const { memoryContent, fileCount, filePaths } =
|
||||||
await loadServerHierarchicalMemory(
|
await loadServerHierarchicalMemory(
|
||||||
cwd,
|
cwd,
|
||||||
settings.context?.loadMemoryFromIncludeDirectories
|
[],
|
||||||
? includeDirectories
|
|
||||||
: [],
|
|
||||||
debugMode,
|
debugMode,
|
||||||
fileService,
|
fileService,
|
||||||
extensionManager,
|
extensionManager,
|
||||||
|
|||||||
@@ -105,6 +105,8 @@ import { ShellFocusContext } from './contexts/ShellFocusContext.js';
|
|||||||
import { useSessionResume } from './hooks/useSessionResume.js';
|
import { useSessionResume } from './hooks/useSessionResume.js';
|
||||||
import { type ExtensionManager } from '../config/extension-manager.js';
|
import { type ExtensionManager } from '../config/extension-manager.js';
|
||||||
import { requestConsentInteractive } from '../config/extensions/consent.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 { disableMouseEvents, enableMouseEvents } from './utils/mouse.js';
|
||||||
import { useAlternateBuffer } from './hooks/useAlternateBuffer.js';
|
import { useAlternateBuffer } from './hooks/useAlternateBuffer.js';
|
||||||
import { useSettings } from './contexts/SettingsContext.js';
|
import { useSettings } from './contexts/SettingsContext.js';
|
||||||
@@ -161,6 +163,9 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
const [isProcessing, setIsProcessing] = useState<boolean>(false);
|
const [isProcessing, setIsProcessing] = useState<boolean>(false);
|
||||||
const [embeddedShellFocused, setEmbeddedShellFocused] = useState(false);
|
const [embeddedShellFocused, setEmbeddedShellFocused] = useState(false);
|
||||||
const [showDebugProfiler, setShowDebugProfiler] = useState(false);
|
const [showDebugProfiler, setShowDebugProfiler] = useState(false);
|
||||||
|
const [customDialog, setCustomDialog] = useState<React.ReactNode | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
const [copyModeEnabled, setCopyModeEnabled] = useState(false);
|
const [copyModeEnabled, setCopyModeEnabled] = useState(false);
|
||||||
|
|
||||||
const [shellModeActive, setShellModeActive] = useState(false);
|
const [shellModeActive, setShellModeActive] = useState(false);
|
||||||
@@ -169,7 +174,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
const [historyRemountKey, setHistoryRemountKey] = useState(0);
|
const [historyRemountKey, setHistoryRemountKey] = useState(0);
|
||||||
const [updateInfo, setUpdateInfo] = useState<UpdateObject | null>(null);
|
const [updateInfo, setUpdateInfo] = useState<UpdateObject | null>(null);
|
||||||
const [isTrustedFolder, setIsTrustedFolder] = useState<boolean | undefined>(
|
const [isTrustedFolder, setIsTrustedFolder] = useState<boolean | undefined>(
|
||||||
config.isTrustedFolder(),
|
isWorkspaceTrusted(settings.merged).isTrusted,
|
||||||
);
|
);
|
||||||
|
|
||||||
const [queueErrorMessage, setQueueErrorMessage] = useState<string | null>(
|
const [queueErrorMessage, setQueueErrorMessage] = useState<string | null>(
|
||||||
@@ -591,6 +596,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
|
|||||||
slashCommandActions,
|
slashCommandActions,
|
||||||
extensionsUpdateStateInternal,
|
extensionsUpdateStateInternal,
|
||||||
isConfigInitialized,
|
isConfigInitialized,
|
||||||
|
setCustomDialog,
|
||||||
);
|
);
|
||||||
|
|
||||||
const performMemoryRefresh = useCallback(async () => {
|
const performMemoryRefresh = useCallback(async () => {
|
||||||
@@ -908,6 +914,8 @@ Logging in with Google... Please restart Gemini CLI to continue.
|
|||||||
} = useIdeTrustListener();
|
} = useIdeTrustListener();
|
||||||
const isInitialMount = useRef(true);
|
const isInitialMount = useRef(true);
|
||||||
|
|
||||||
|
useIncludeDirsTrust(config, isTrustedFolder, historyManager, setCustomDialog);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let timeoutId: NodeJS.Timeout;
|
let timeoutId: NodeJS.Timeout;
|
||||||
|
|
||||||
@@ -1263,6 +1271,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
|
|||||||
isFolderTrustDialogOpen ||
|
isFolderTrustDialogOpen ||
|
||||||
!!shellConfirmationRequest ||
|
!!shellConfirmationRequest ||
|
||||||
!!confirmationRequest ||
|
!!confirmationRequest ||
|
||||||
|
!!customDialog ||
|
||||||
confirmUpdateExtensionRequests.length > 0 ||
|
confirmUpdateExtensionRequests.length > 0 ||
|
||||||
!!loopDetectionConfirmationRequest ||
|
!!loopDetectionConfirmationRequest ||
|
||||||
isThemeDialogOpen ||
|
isThemeDialogOpen ||
|
||||||
@@ -1382,6 +1391,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
|
|||||||
activePtyId,
|
activePtyId,
|
||||||
embeddedShellFocused,
|
embeddedShellFocused,
|
||||||
showDebugProfiler,
|
showDebugProfiler,
|
||||||
|
customDialog,
|
||||||
copyModeEnabled,
|
copyModeEnabled,
|
||||||
warningMessage,
|
warningMessage,
|
||||||
}),
|
}),
|
||||||
@@ -1467,6 +1477,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
|
|||||||
historyManager,
|
historyManager,
|
||||||
embeddedShellFocused,
|
embeddedShellFocused,
|
||||||
showDebugProfiler,
|
showDebugProfiler,
|
||||||
|
customDialog,
|
||||||
apiKeyDefaultValue,
|
apiKeyDefaultValue,
|
||||||
authState,
|
authState,
|
||||||
copyModeEnabled,
|
copyModeEnabled,
|
||||||
|
|||||||
@@ -4,14 +4,18 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* 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 { directoryCommand } from './directoryCommand.js';
|
||||||
import { expandHomeDir } from '../utils/directoryUtils.js';
|
import { expandHomeDir } from '../utils/directoryUtils.js';
|
||||||
import type { Config, WorkspaceContext } from '@google/gemini-cli-core';
|
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 { MessageType } from '../types.js';
|
||||||
import * as os from 'node:os';
|
import * as os from 'node:os';
|
||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
|
import * as trustedFolders from '../../config/trustedFolders.js';
|
||||||
|
import type { LoadedTrustedFolders } from '../../config/trustedFolders.js';
|
||||||
|
|
||||||
describe('directoryCommand', () => {
|
describe('directoryCommand', () => {
|
||||||
let mockContext: CommandContext;
|
let mockContext: CommandContext;
|
||||||
@@ -83,6 +87,18 @@ describe('directoryCommand', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('add', () => {
|
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', () => {
|
it('should show an error if no path is provided', () => {
|
||||||
if (!addCommand?.action) throw new Error('No action');
|
if (!addCommand?.action) throw new Error('No action');
|
||||||
addCommand.action(mockContext, '');
|
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 () => {
|
it('should handle a mix of successful and failed additions', async () => {
|
||||||
const validPath = path.normalize('/home/user/valid-project');
|
const validPath = path.normalize('/home/user/valid-project');
|
||||||
const invalidPath = path.normalize('/home/user/invalid-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', () => {
|
it('should correctly expand a Windows-style home directory path', () => {
|
||||||
const windowsPath = '%userprofile%\\Documents';
|
const windowsPath = '%userprofile%\\Documents';
|
||||||
const expectedPath = path.win32.join(os.homedir(), 'Documents');
|
const expectedPath = path.win32.join(os.homedir(), 'Documents');
|
||||||
|
|||||||
@@ -4,11 +4,69 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* 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 type { SlashCommand, CommandContext } from './types.js';
|
||||||
import { CommandKind } 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 { refreshServerHierarchicalMemory } from '@google/gemini-cli-core';
|
||||||
import { expandHomeDir } from '../utils/directoryUtils.js';
|
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 = {
|
export const directoryCommand: SlashCommand = {
|
||||||
name: 'directory',
|
name: 'directory',
|
||||||
@@ -24,7 +82,7 @@ export const directoryCommand: SlashCommand = {
|
|||||||
action: async (context: CommandContext, args: string) => {
|
action: async (context: CommandContext, args: string) => {
|
||||||
const {
|
const {
|
||||||
ui: { addItem },
|
ui: { addItem },
|
||||||
services: { config },
|
services: { config, settings },
|
||||||
} = context;
|
} = context;
|
||||||
const [...rest] = args.split(' ');
|
const [...rest] = args.split(' ');
|
||||||
|
|
||||||
@@ -39,7 +97,14 @@ export const directoryCommand: SlashCommand = {
|
|||||||
return;
|
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
|
const pathsToAdd = rest
|
||||||
.join(' ')
|
.join(' ')
|
||||||
@@ -56,63 +121,109 @@ export const directoryCommand: SlashCommand = {
|
|||||||
return;
|
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 added: string[] = [];
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
|
const alreadyAdded: string[] = [];
|
||||||
|
|
||||||
|
const workspaceContext = config.getWorkspaceContext();
|
||||||
|
const currentWorkspaceDirs = workspaceContext.getDirectories();
|
||||||
|
const pathsToProcess: string[] = [];
|
||||||
|
|
||||||
for (const pathToAdd of pathsToAdd) {
|
for (const pathToAdd of pathsToAdd) {
|
||||||
try {
|
const expandedPath = expandHomeDir(pathToAdd.trim());
|
||||||
workspaceContext.addDirectory(expandHomeDir(pathToAdd.trim()));
|
if (currentWorkspaceDirs.includes(expandedPath)) {
|
||||||
added.push(pathToAdd.trim());
|
alreadyAdded.push(pathToAdd.trim());
|
||||||
} catch (e) {
|
} else {
|
||||||
const error = e as Error;
|
pathsToProcess.push(pathToAdd.trim());
|
||||||
errors.push(`Error adding '${pathToAdd.trim()}': ${error.message}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
if (alreadyAdded.length > 0) {
|
||||||
if (config.shouldLoadMemoryFromIncludeDirectories()) {
|
|
||||||
await refreshServerHierarchicalMemory(config);
|
|
||||||
}
|
|
||||||
addItem(
|
addItem(
|
||||||
{
|
{
|
||||||
type: MessageType.INFO,
|
type: MessageType.INFO,
|
||||||
text: `Successfully added GEMINI.md files from the following directories if there are:\n- ${added.join('\n- ')}`,
|
text: `The following directories are already in the workspace:\n- ${alreadyAdded.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(),
|
Date.now(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (errors.length > 0) {
|
if (pathsToProcess.length === 0) {
|
||||||
addItem(
|
return;
|
||||||
{ type: MessageType.ERROR, text: errors.join('\n') },
|
|
||||||
Date.now(),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
return;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ export interface CommandContext {
|
|||||||
extensionsUpdateState: Map<string, ExtensionUpdateStatus>;
|
extensionsUpdateState: Map<string, ExtensionUpdateStatus>;
|
||||||
dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void;
|
dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void;
|
||||||
addConfirmUpdateExtensionRequest: (value: ConfirmationRequest) => void;
|
addConfirmUpdateExtensionRequest: (value: ConfirmationRequest) => void;
|
||||||
|
removeComponent: () => void;
|
||||||
};
|
};
|
||||||
// Session-specific data
|
// Session-specific data
|
||||||
session: {
|
session: {
|
||||||
@@ -169,6 +170,11 @@ export interface ConfirmActionReturn {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface OpenCustomDialogActionReturn {
|
||||||
|
type: 'custom_dialog';
|
||||||
|
component: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
export type SlashCommandActionReturn =
|
export type SlashCommandActionReturn =
|
||||||
| ToolActionReturn
|
| ToolActionReturn
|
||||||
| MessageActionReturn
|
| MessageActionReturn
|
||||||
@@ -177,7 +183,8 @@ export type SlashCommandActionReturn =
|
|||||||
| LoadHistoryActionReturn
|
| LoadHistoryActionReturn
|
||||||
| SubmitPromptActionReturn
|
| SubmitPromptActionReturn
|
||||||
| ConfirmShellCommandsActionReturn
|
| ConfirmShellCommandsActionReturn
|
||||||
| ConfirmActionReturn;
|
| ConfirmActionReturn
|
||||||
|
| OpenCustomDialogActionReturn;
|
||||||
|
|
||||||
export enum CommandKind {
|
export enum CommandKind {
|
||||||
BUILT_IN = 'built-in',
|
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;
|
showFullTodos: boolean;
|
||||||
copyModeEnabled: boolean;
|
copyModeEnabled: boolean;
|
||||||
warningMessage: string | null;
|
warningMessage: string | null;
|
||||||
|
customDialog: React.ReactNode | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const UIStateContext = createContext<UIState | null>(null);
|
export const UIStateContext = createContext<UIState | null>(null);
|
||||||
|
|||||||
@@ -201,6 +201,7 @@ describe('useSlashCommandProcessor', () => {
|
|||||||
},
|
},
|
||||||
new Map(), // extensionsUpdateState
|
new Map(), // extensionsUpdateState
|
||||||
true, // isConfigInitialized
|
true, // isConfigInitialized
|
||||||
|
vi.fn(), // setCustomDialog
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
result = hook.result;
|
result = hook.result;
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ export const useSlashCommandProcessor = (
|
|||||||
actions: SlashCommandProcessorActions,
|
actions: SlashCommandProcessorActions,
|
||||||
extensionsUpdateState: Map<string, ExtensionUpdateStatus>,
|
extensionsUpdateState: Map<string, ExtensionUpdateStatus>,
|
||||||
isConfigInitialized: boolean,
|
isConfigInitialized: boolean,
|
||||||
|
setCustomDialog: (dialog: React.ReactNode | null) => void,
|
||||||
) => {
|
) => {
|
||||||
const session = useSessionStats();
|
const session = useSessionStats();
|
||||||
const [commands, setCommands] = useState<readonly SlashCommand[] | undefined>(
|
const [commands, setCommands] = useState<readonly SlashCommand[] | undefined>(
|
||||||
@@ -215,6 +216,7 @@ export const useSlashCommandProcessor = (
|
|||||||
dispatchExtensionStateUpdate: actions.dispatchExtensionStateUpdate,
|
dispatchExtensionStateUpdate: actions.dispatchExtensionStateUpdate,
|
||||||
addConfirmUpdateExtensionRequest:
|
addConfirmUpdateExtensionRequest:
|
||||||
actions.addConfirmUpdateExtensionRequest,
|
actions.addConfirmUpdateExtensionRequest,
|
||||||
|
removeComponent: () => setCustomDialog(null),
|
||||||
},
|
},
|
||||||
session: {
|
session: {
|
||||||
stats: session.stats,
|
stats: session.stats,
|
||||||
@@ -239,6 +241,7 @@ export const useSlashCommandProcessor = (
|
|||||||
sessionShellAllowlist,
|
sessionShellAllowlist,
|
||||||
reloadCommands,
|
reloadCommands,
|
||||||
extensionsUpdateState,
|
extensionsUpdateState,
|
||||||
|
setCustomDialog,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -505,6 +508,10 @@ export const useSlashCommandProcessor = (
|
|||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
case 'custom_dialog': {
|
||||||
|
setCustomDialog(result.component);
|
||||||
|
return { type: 'handled' };
|
||||||
|
}
|
||||||
default: {
|
default: {
|
||||||
const unhandled: never = result;
|
const unhandled: never = result;
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -578,6 +585,7 @@ export const useSlashCommandProcessor = (
|
|||||||
setSessionShellAllowlist,
|
setSessionShellAllowlist,
|
||||||
setIsProcessing,
|
setIsProcessing,
|
||||||
setConfirmationRequest,
|
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 />
|
<Notifications />
|
||||||
<CopyModeWarning />
|
<CopyModeWarning />
|
||||||
|
|
||||||
{uiState.dialogsVisible ? (
|
{uiState.customDialog ? (
|
||||||
|
uiState.customDialog
|
||||||
|
) : uiState.dialogsVisible ? (
|
||||||
<DialogManager
|
<DialogManager
|
||||||
terminalWidth={uiState.mainAreaWidth}
|
terminalWidth={uiState.mainAreaWidth}
|
||||||
addItem={uiState.historyManager.addItem}
|
addItem={uiState.historyManager.addItem}
|
||||||
|
|||||||
@@ -27,5 +27,6 @@ export function createNonInteractiveUI(): CommandContext['ui'] {
|
|||||||
extensionsUpdateState: new Map(),
|
extensionsUpdateState: new Map(),
|
||||||
dispatchExtensionStateUpdate: (_action: ExtensionUpdateAction) => {},
|
dispatchExtensionStateUpdate: (_action: ExtensionUpdateAction) => {},
|
||||||
addConfirmUpdateExtensionRequest: (_request) => {},
|
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', () => {
|
it('should initialize WorkspaceContext with includeDirectories', () => {
|
||||||
const resolved = path.resolve(baseParams.targetDir);
|
|
||||||
const includeDirectories = ['dir1', 'dir2'];
|
const includeDirectories = ['dir1', 'dir2'];
|
||||||
const paramsWithIncludeDirs: ConfigParameters = {
|
const paramsWithIncludeDirs: ConfigParameters = {
|
||||||
...baseParams,
|
...baseParams,
|
||||||
@@ -439,11 +438,13 @@ describe('Server Config (config.ts)', () => {
|
|||||||
const config = new Config(paramsWithIncludeDirs);
|
const config = new Config(paramsWithIncludeDirs);
|
||||||
const workspaceContext = config.getWorkspaceContext();
|
const workspaceContext = config.getWorkspaceContext();
|
||||||
const directories = workspaceContext.getDirectories();
|
const directories = workspaceContext.getDirectories();
|
||||||
// Should include the target directory plus the included directories
|
|
||||||
expect(directories).toHaveLength(3);
|
// Should include only the target directory initially
|
||||||
expect(directories).toContain(resolved);
|
expect(directories).toHaveLength(1);
|
||||||
expect(directories).toContain(path.join(resolved, 'dir1'));
|
expect(directories).toContain(path.resolve(baseParams.targetDir));
|
||||||
expect(directories).toContain(path.join(resolved, 'dir2'));
|
|
||||||
|
// 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', () => {
|
it('Config constructor should set telemetry to true when provided as true', () => {
|
||||||
|
|||||||
@@ -411,6 +411,7 @@ export class Config {
|
|||||||
readonly fakeResponses?: string;
|
readonly fakeResponses?: string;
|
||||||
readonly recordResponses?: string;
|
readonly recordResponses?: string;
|
||||||
private readonly disableYoloMode: boolean;
|
private readonly disableYoloMode: boolean;
|
||||||
|
private pendingIncludeDirectories: string[];
|
||||||
private readonly enableHooks: boolean;
|
private readonly enableHooks: boolean;
|
||||||
private readonly hooks:
|
private readonly hooks:
|
||||||
| { [K in HookEventName]?: HookDefinition[] }
|
| { [K in HookEventName]?: HookDefinition[] }
|
||||||
@@ -425,10 +426,9 @@ export class Config {
|
|||||||
this.fileSystemService = new StandardFileSystemService();
|
this.fileSystemService = new StandardFileSystemService();
|
||||||
this.sandbox = params.sandbox;
|
this.sandbox = params.sandbox;
|
||||||
this.targetDir = path.resolve(params.targetDir);
|
this.targetDir = path.resolve(params.targetDir);
|
||||||
this.workspaceContext = new WorkspaceContext(
|
this.folderTrust = params.folderTrust ?? false;
|
||||||
this.targetDir,
|
this.workspaceContext = new WorkspaceContext(this.targetDir, []);
|
||||||
params.includeDirectories ?? [],
|
this.pendingIncludeDirectories = params.includeDirectories ?? [];
|
||||||
);
|
|
||||||
this.debugMode = params.debugMode;
|
this.debugMode = params.debugMode;
|
||||||
this.question = params.question;
|
this.question = params.question;
|
||||||
|
|
||||||
@@ -927,6 +927,14 @@ export class Config {
|
|||||||
return this.disableYoloMode || !this.isTrustedFolder();
|
return this.disableYoloMode || !this.isTrustedFolder();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getPendingIncludeDirectories(): string[] {
|
||||||
|
return this.pendingIncludeDirectories;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearPendingIncludeDirectories(): void {
|
||||||
|
this.pendingIncludeDirectories = [];
|
||||||
|
}
|
||||||
|
|
||||||
getShowMemoryUsage(): boolean {
|
getShowMemoryUsage(): boolean {
|
||||||
return this.showMemoryUsage;
|
return this.showMemoryUsage;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user