diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 5eaa6922a5..fbf6301857 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -196,14 +196,20 @@ export const AppContainer = (props: AppContainerProps) => { ); const [isPermissionsDialogOpen, setPermissionsDialogOpen] = useState(false); + const [permissionsDialogProps, setPermissionsDialogProps] = useState<{ + targetDirectory?: string; + } | null>(null); const openPermissionsDialog = useCallback( - () => setPermissionsDialogOpen(true), - [], - ); - const closePermissionsDialog = useCallback( - () => setPermissionsDialogOpen(false), + (props?: { targetDirectory?: string }) => { + setPermissionsDialogOpen(true); + setPermissionsDialogProps(props ?? null); + }, [], ); + const closePermissionsDialog = useCallback(() => { + setPermissionsDialogOpen(false); + setPermissionsDialogProps(null); + }, []); const toggleDebugProfiler = useCallback( () => setShowDebugProfiler((prev) => !prev), @@ -1301,6 +1307,7 @@ Logging in with Google... Please restart Gemini CLI to continue. isSettingsDialogOpen, isModelDialogOpen, isPermissionsDialogOpen, + permissionsDialogProps, slashCommands, pendingSlashCommandHistoryItems, commandContext, @@ -1384,6 +1391,7 @@ Logging in with Google... Please restart Gemini CLI to continue. isSettingsDialogOpen, isModelDialogOpen, isPermissionsDialogOpen, + permissionsDialogProps, slashCommands, pendingSlashCommandHistoryItems, commandContext, @@ -1474,6 +1482,7 @@ Logging in with Google... Please restart Gemini CLI to continue. exitPrivacyNotice, closeSettingsDialog, closeModelDialog, + openPermissionsDialog, closePermissionsDialog, setShellModeActive, vimHandleInput, @@ -1502,6 +1511,7 @@ Logging in with Google... Please restart Gemini CLI to continue. exitPrivacyNotice, closeSettingsDialog, closeModelDialog, + openPermissionsDialog, closePermissionsDialog, setShellModeActive, vimHandleInput, diff --git a/packages/cli/src/ui/commands/directoryCommand.test.tsx b/packages/cli/src/ui/commands/directoryCommand.test.tsx index cfcb00d8ec..241c84f146 100644 --- a/packages/cli/src/ui/commands/directoryCommand.test.tsx +++ b/packages/cli/src/ui/commands/directoryCommand.test.tsx @@ -5,7 +5,8 @@ */ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { directoryCommand, expandHomeDir } from './directoryCommand.js'; +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 { MessageType } from '../types.js'; diff --git a/packages/cli/src/ui/commands/directoryCommand.tsx b/packages/cli/src/ui/commands/directoryCommand.tsx index fe7076af0d..52608a330e 100644 --- a/packages/cli/src/ui/commands/directoryCommand.tsx +++ b/packages/cli/src/ui/commands/directoryCommand.tsx @@ -7,22 +7,8 @@ import type { SlashCommand, CommandContext } from './types.js'; import { CommandKind } from './types.js'; import { MessageType } from '../types.js'; -import * as os from 'node:os'; -import * as path from 'node:path'; import { refreshServerHierarchicalMemory } from '@google/gemini-cli-core'; - -export function expandHomeDir(p: string): string { - if (!p) { - return ''; - } - let expandedPath = p; - if (p.toLowerCase().startsWith('%userprofile%')) { - expandedPath = os.homedir() + p.substring('%userprofile%'.length); - } else if (p === '~' || p.startsWith('~/')) { - expandedPath = os.homedir() + p.substring(1); - } - return path.normalize(expandedPath); -} +import { expandHomeDir } from '../utils/directoryUtils.js'; export const directoryCommand: SlashCommand = { name: 'directory', diff --git a/packages/cli/src/ui/commands/permissionsCommand.test.ts b/packages/cli/src/ui/commands/permissionsCommand.test.ts index f51e7c3df4..48b08888b9 100644 --- a/packages/cli/src/ui/commands/permissionsCommand.test.ts +++ b/packages/cli/src/ui/commands/permissionsCommand.test.ts @@ -4,32 +4,113 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import * as process from 'node:process'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; import { permissionsCommand } from './permissionsCommand.js'; import { type CommandContext, CommandKind } from './types.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; +vi.mock('node:fs'); + describe('permissionsCommand', () => { let mockContext: CommandContext; beforeEach(() => { mockContext = createMockCommandContext(); + vi.mocked(fs).statSync.mockReturnValue({ + isDirectory: vi.fn(() => true), + } as unknown as fs.Stats); + }); + + afterEach(() => { + vi.restoreAllMocks(); }); it('should have the correct name and description', () => { expect(permissionsCommand.name).toBe('permissions'); - expect(permissionsCommand.description).toBe('Manage folder trust settings'); + expect(permissionsCommand.description).toBe( + 'Manage folder trust settings and other permissions', + ); }); it('should be a built-in command', () => { expect(permissionsCommand.kind).toBe(CommandKind.BUILT_IN); }); - it('should return an action to open the permissions dialog', () => { - const actionResult = permissionsCommand.action?.(mockContext, ''); + it('should have a trust subcommand', () => { + const trustCommand = permissionsCommand.subCommands?.find( + (cmd) => cmd.name === 'trust', + ); + expect(trustCommand).toBeDefined(); + expect(trustCommand?.name).toBe('trust'); + expect(trustCommand?.description).toBe( + 'Manage folder trust settings. Usage: /permissions trust []', + ); + expect(trustCommand?.kind).toBe(CommandKind.BUILT_IN); + }); + + it('should return an action to open the permissions dialog with a specified directory', () => { + const trustCommand = permissionsCommand.subCommands?.find( + (cmd) => cmd.name === 'trust', + ); + const actionResult = trustCommand?.action?.(mockContext, '/test/dir'); expect(actionResult).toEqual({ type: 'dialog', dialog: 'permissions', + props: { + targetDirectory: path.resolve('/test/dir'), + }, + }); + }); + + it('should return an action to open the permissions dialog with the current directory if no path is provided', () => { + const trustCommand = permissionsCommand.subCommands?.find( + (cmd) => cmd.name === 'trust', + ); + const actionResult = trustCommand?.action?.(mockContext, ''); + expect(actionResult).toEqual({ + type: 'dialog', + dialog: 'permissions', + props: { + targetDirectory: process.cwd(), + }, + }); + }); + + it('should return an error message if the provided path does not exist', () => { + const trustCommand = permissionsCommand.subCommands?.find( + (cmd) => cmd.name === 'trust', + ); + vi.mocked(fs).statSync.mockImplementation(() => { + throw new Error('ENOENT: no such file or directory'); + }); + const actionResult = trustCommand?.action?.( + mockContext, + '/nonexistent/dir', + ); + expect(actionResult).toEqual({ + type: 'message', + messageType: 'error', + content: `Error accessing path: ${path.resolve( + '/nonexistent/dir', + )}. ENOENT: no such file or directory`, + }); + }); + + it('should return an error message if the provided path is not a directory', () => { + const trustCommand = permissionsCommand.subCommands?.find( + (cmd) => cmd.name === 'trust', + ); + vi.mocked(fs).statSync.mockReturnValue({ + isDirectory: vi.fn(() => false), + } as unknown as fs.Stats); + const actionResult = trustCommand?.action?.(mockContext, '/file/not/dir'); + expect(actionResult).toEqual({ + type: 'message', + messageType: 'error', + content: `Path is not a directory: ${path.resolve('/file/not/dir')}`, }); }); }); diff --git a/packages/cli/src/ui/commands/permissionsCommand.ts b/packages/cli/src/ui/commands/permissionsCommand.ts index 60ef388439..5480ca0a67 100644 --- a/packages/cli/src/ui/commands/permissionsCommand.ts +++ b/packages/cli/src/ui/commands/permissionsCommand.ts @@ -4,15 +4,80 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { OpenDialogActionReturn, SlashCommand } from './types.js'; +import type { + OpenDialogActionReturn, + SlashCommand, + SlashCommandActionReturn, +} from './types.js'; import { CommandKind } from './types.js'; +import * as process from 'node:process'; +import * as path from 'node:path'; +import * as fs from 'node:fs'; +import { expandHomeDir } from '../utils/directoryUtils.js'; export const permissionsCommand: SlashCommand = { name: 'permissions', - description: 'Manage folder trust settings', + description: 'Manage folder trust settings and other permissions', kind: CommandKind.BUILT_IN, - action: (): OpenDialogActionReturn => ({ - type: 'dialog', - dialog: 'permissions', - }), + subCommands: [ + { + name: 'trust', + description: + 'Manage folder trust settings. Usage: /permissions trust []', + kind: CommandKind.BUILT_IN, + action: (context, input): SlashCommandActionReturn => { + const dirPath = input.trim(); + let targetDirectory: string; + + if (!dirPath) { + targetDirectory = process.cwd(); + } else { + targetDirectory = path.resolve(expandHomeDir(dirPath)); + } + + try { + if (!fs.statSync(targetDirectory).isDirectory()) { + return { + type: 'message', + messageType: 'error', + content: `Path is not a directory: ${targetDirectory}`, + }; + } + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + return { + type: 'message', + messageType: 'error', + content: `Error accessing path: ${targetDirectory}. ${message}`, + }; + } + + return { + type: 'dialog', + dialog: 'permissions', + props: { + targetDirectory, + }, + } as OpenDialogActionReturn; + }, + }, + ], + action: (context, input): SlashCommandActionReturn => { + const parts = input.trim().split(' '); + const subcommand = parts[0]; + + if (!subcommand) { + return { + type: 'message', + messageType: 'error', + content: `Please provide a subcommand for /permissions. Usage: /permissions trust []`, + }; + } + + return { + type: 'message', + messageType: 'error', + content: `Invalid subcommand for /permissions: ${subcommand}. Usage: /permissions trust []`, + }; + }, }; diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index 5bd77d2ddf..01ab27ac96 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -113,6 +113,7 @@ export interface MessageActionReturn { */ export interface OpenDialogActionReturn { type: 'dialog'; + props?: Record; dialog: | 'help' diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index 19ec2e276d..d81690a89a 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -205,6 +205,7 @@ export const DialogManager = ({ ); } diff --git a/packages/cli/src/ui/components/PermissionsModifyTrustDialog.tsx b/packages/cli/src/ui/components/PermissionsModifyTrustDialog.tsx index 211d405e40..816186fdb2 100644 --- a/packages/cli/src/ui/components/PermissionsModifyTrustDialog.tsx +++ b/packages/cli/src/ui/components/PermissionsModifyTrustDialog.tsx @@ -16,7 +16,11 @@ import { RadioButtonSelect } from './shared/RadioButtonSelect.js'; import { relaunchApp } from '../../utils/processUtils.js'; import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js'; -interface PermissionsModifyTrustDialogProps { +export interface PermissionsDialogProps { + targetDirectory?: string; +} + +interface PermissionsModifyTrustDialogProps extends PermissionsDialogProps { onExit: () => void; addItem: UseHistoryManagerReturn['addItem']; } @@ -24,9 +28,11 @@ interface PermissionsModifyTrustDialogProps { export function PermissionsModifyTrustDialog({ onExit, addItem, + targetDirectory, }: PermissionsModifyTrustDialogProps): React.JSX.Element { - const dirName = path.basename(process.cwd()); - const parentFolder = path.basename(path.dirname(process.cwd())); + const currentDirectory = targetDirectory ?? process.cwd(); + const dirName = path.basename(currentDirectory); + const parentFolder = path.basename(path.dirname(currentDirectory)); const TRUST_LEVEL_ITEMS = [ { @@ -54,7 +60,7 @@ export function PermissionsModifyTrustDialog({ needsRestart, updateTrustLevel, commitTrustLevelChange, - } = usePermissionsModifyTrust(onExit, addItem); + } = usePermissionsModifyTrust(onExit, addItem, currentDirectory); useKeypress( (key) => { diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx index 733df561ea..2504ee6ad1 100644 --- a/packages/cli/src/ui/contexts/UIActionsContext.tsx +++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx @@ -11,6 +11,7 @@ import { type FolderTrustChoice } from '../components/FolderTrustDialog.js'; import { type AuthType, type EditorType } from '@google/gemini-cli-core'; import { type LoadableSettingScope } from '../../config/settings.js'; import type { AuthState } from '../types.js'; +import { type PermissionsDialogProps } from '../components/PermissionsModifyTrustDialog.js'; export interface UIActions { handleThemeSelect: (themeName: string, scope: LoadableSettingScope) => void; @@ -30,6 +31,7 @@ export interface UIActions { exitPrivacyNotice: () => void; closeSettingsDialog: () => void; closeModelDialog: () => void; + openPermissionsDialog: (props?: PermissionsDialogProps) => void; closePermissionsDialog: () => void; setShellModeActive: (value: boolean) => void; vimHandleInput: (key: Key) => boolean; diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index 6fc19b2808..c96000ee98 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -58,6 +58,7 @@ export interface UIState { isSettingsDialogOpen: boolean; isModelDialogOpen: boolean; isPermissionsDialogOpen: boolean; + permissionsDialogProps: { targetDirectory?: string } | null; slashCommands: readonly SlashCommand[] | undefined; pendingSlashCommandHistoryItems: HistoryItemWithoutId[]; commandContext: CommandContext; diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index 47d9ea7f10..0c78757687 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -53,7 +53,7 @@ interface SlashCommandProcessorActions { openPrivacyNotice: () => void; openSettingsDialog: () => void; openModelDialog: () => void; - openPermissionsDialog: () => void; + openPermissionsDialog: (props?: { targetDirectory?: string }) => void; quit: (messages: HistoryItem[]) => void; setDebugMessage: (message: string) => void; toggleCorgiMode: () => void; @@ -405,7 +405,9 @@ export const useSlashCommandProcessor = ( actions.openModelDialog(); return { type: 'handled' }; case 'permissions': - actions.openPermissionsDialog(); + actions.openPermissionsDialog( + result.props as { targetDirectory?: string }, + ); return { type: 'handled' }; case 'help': return { type: 'handled' }; diff --git a/packages/cli/src/ui/hooks/usePermissionsModifyTrust.test.ts b/packages/cli/src/ui/hooks/usePermissionsModifyTrust.test.ts index bfe35bfb05..cc69d14eca 100644 --- a/packages/cli/src/ui/hooks/usePermissionsModifyTrust.test.ts +++ b/packages/cli/src/ui/hooks/usePermissionsModifyTrust.test.ts @@ -32,6 +32,15 @@ vi.mock('node:process', () => ({ cwd: mockedCwd, })); +vi.mock('node:path', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...(actual && typeof actual === 'object' ? actual : {}), + resolve: vi.fn((p) => p), + join: vi.fn((...args) => args.join('/')), + }; +}); + vi.mock('../../config/trustedFolders.js', () => ({ loadTrustedFolders: mockedLoadTrustedFolders, isWorkspaceTrusted: mockedIsWorkspaceTrusted, @@ -74,191 +83,262 @@ describe('usePermissionsModifyTrust', () => { vi.resetAllMocks(); }); - it('should initialize with the correct trust level', () => { - mockedLoadTrustedFolders.mockReturnValue({ - user: { config: { '/test/dir': TrustLevel.TRUST_FOLDER } }, - } as unknown as LoadedTrustedFolders); - mockedIsWorkspaceTrusted.mockReturnValue({ - isTrusted: true, - source: 'file', + describe('when targetDirectory is the current workspace', () => { + it('should initialize with the correct trust level', () => { + mockedLoadTrustedFolders.mockReturnValue({ + user: { config: { '/test/dir': TrustLevel.TRUST_FOLDER } }, + } as unknown as LoadedTrustedFolders); + mockedIsWorkspaceTrusted.mockReturnValue({ + isTrusted: true, + source: 'file', + }); + + const { result } = renderHook(() => + usePermissionsModifyTrust(mockOnExit, mockAddItem, mockedCwd()), + ); + + expect(result.current.currentTrustLevel).toBe(TrustLevel.TRUST_FOLDER); }); - const { result } = renderHook(() => - usePermissionsModifyTrust(mockOnExit, mockAddItem), - ); + it('should detect inherited trust from parent', () => { + mockedLoadTrustedFolders.mockReturnValue({ + user: { config: {} }, + setValue: vi.fn(), + } as unknown as LoadedTrustedFolders); + mockedIsWorkspaceTrusted.mockReturnValue({ + isTrusted: true, + source: 'file', + }); - expect(result.current.currentTrustLevel).toBe(TrustLevel.TRUST_FOLDER); + const { result } = renderHook(() => + usePermissionsModifyTrust(mockOnExit, mockAddItem, mockedCwd()), + ); + + expect(result.current.isInheritedTrustFromParent).toBe(true); + expect(result.current.isInheritedTrustFromIde).toBe(false); + }); + + it('should detect inherited trust from IDE', () => { + mockedLoadTrustedFolders.mockReturnValue({ + user: { config: {} }, // No explicit trust + } as unknown as LoadedTrustedFolders); + mockedIsWorkspaceTrusted.mockReturnValue({ + isTrusted: true, + source: 'ide', + }); + + const { result } = renderHook(() => + usePermissionsModifyTrust(mockOnExit, mockAddItem, mockedCwd()), + ); + + expect(result.current.isInheritedTrustFromIde).toBe(true); + expect(result.current.isInheritedTrustFromParent).toBe(false); + }); + + it('should set needsRestart but not save when trust changes', () => { + const mockSetValue = vi.fn(); + mockedLoadTrustedFolders.mockReturnValue({ + user: { config: {} }, + setValue: mockSetValue, + } as unknown as LoadedTrustedFolders); + + mockedIsWorkspaceTrusted + .mockReturnValueOnce({ isTrusted: false, source: 'file' }) + .mockReturnValueOnce({ isTrusted: true, source: 'file' }); + + const { result } = renderHook(() => + usePermissionsModifyTrust(mockOnExit, mockAddItem, mockedCwd()), + ); + + act(() => { + result.current.updateTrustLevel(TrustLevel.TRUST_FOLDER); + }); + + expect(result.current.needsRestart).toBe(true); + expect(mockSetValue).not.toHaveBeenCalled(); + }); + + it('should save immediately if trust does not change', () => { + const mockSetValue = vi.fn(); + mockedLoadTrustedFolders.mockReturnValue({ + user: { config: {} }, + setValue: mockSetValue, + } as unknown as LoadedTrustedFolders); + + mockedIsWorkspaceTrusted.mockReturnValue({ + isTrusted: true, + source: 'file', + }); + + const { result } = renderHook(() => + usePermissionsModifyTrust(mockOnExit, mockAddItem, mockedCwd()), + ); + + act(() => { + result.current.updateTrustLevel(TrustLevel.TRUST_PARENT); + }); + + expect(result.current.needsRestart).toBe(false); + expect(mockSetValue).toHaveBeenCalledWith( + '/test/dir', + TrustLevel.TRUST_PARENT, + ); + expect(mockOnExit).toHaveBeenCalled(); + }); + + it('should commit the pending trust level change', () => { + const mockSetValue = vi.fn(); + mockedLoadTrustedFolders.mockReturnValue({ + user: { config: {} }, + setValue: mockSetValue, + } as unknown as LoadedTrustedFolders); + + mockedIsWorkspaceTrusted + .mockReturnValueOnce({ isTrusted: false, source: 'file' }) + .mockReturnValueOnce({ isTrusted: true, source: 'file' }); + + const { result } = renderHook(() => + usePermissionsModifyTrust(mockOnExit, mockAddItem, mockedCwd()), + ); + + act(() => { + result.current.updateTrustLevel(TrustLevel.TRUST_FOLDER); + }); + + expect(result.current.needsRestart).toBe(true); + + act(() => { + result.current.commitTrustLevelChange(); + }); + + expect(mockSetValue).toHaveBeenCalledWith( + '/test/dir', + TrustLevel.TRUST_FOLDER, + ); + }); + + it('should add warning when setting DO_NOT_TRUST but still trusted by parent', () => { + mockedLoadTrustedFolders.mockReturnValue({ + user: { config: {} }, + setValue: vi.fn(), + } as unknown as LoadedTrustedFolders); + mockedIsWorkspaceTrusted.mockReturnValue({ + isTrusted: true, + source: 'file', + }); + + const { result } = renderHook(() => + usePermissionsModifyTrust(mockOnExit, mockAddItem, mockedCwd()), + ); + + act(() => { + result.current.updateTrustLevel(TrustLevel.DO_NOT_TRUST); + }); + + expect(mockAddItem).toHaveBeenCalledWith( + { + type: 'warning', + text: 'Note: This folder is still trusted because a parent folder is trusted.', + }, + expect.any(Number), + ); + }); + + it('should add warning when setting DO_NOT_TRUST but still trusted by IDE', () => { + mockedLoadTrustedFolders.mockReturnValue({ + user: { config: {} }, + setValue: vi.fn(), + } as unknown as LoadedTrustedFolders); + mockedIsWorkspaceTrusted.mockReturnValue({ + isTrusted: true, + source: 'ide', + }); + + const { result } = renderHook(() => + usePermissionsModifyTrust(mockOnExit, mockAddItem, mockedCwd()), + ); + + act(() => { + result.current.updateTrustLevel(TrustLevel.DO_NOT_TRUST); + }); + + expect(mockAddItem).toHaveBeenCalledWith( + { + type: 'warning', + text: 'Note: This folder is still trusted because the connected IDE workspace is trusted.', + }, + expect.any(Number), + ); + }); }); - it('should detect inherited trust from parent', () => { - mockedLoadTrustedFolders.mockReturnValue({ - user: { config: {} }, - setValue: vi.fn(), - } as unknown as LoadedTrustedFolders); - mockedIsWorkspaceTrusted.mockReturnValue({ - isTrusted: true, - source: 'file', + describe('when targetDirectory is not the current workspace', () => { + const otherDirectory = '/other/dir'; + + it('should not detect inherited trust', () => { + mockedLoadTrustedFolders.mockReturnValue({ + user: { config: {} }, + } as unknown as LoadedTrustedFolders); + mockedIsWorkspaceTrusted.mockReturnValue({ + isTrusted: true, + source: 'file', + }); + + const { result } = renderHook(() => + usePermissionsModifyTrust(mockOnExit, mockAddItem, otherDirectory), + ); + + expect(result.current.isInheritedTrustFromParent).toBe(false); + expect(result.current.isInheritedTrustFromIde).toBe(false); }); - const { result } = renderHook(() => - usePermissionsModifyTrust(mockOnExit, mockAddItem), - ); + it('should save immediately without needing a restart', () => { + const mockSetValue = vi.fn(); + mockedLoadTrustedFolders.mockReturnValue({ + user: { config: {} }, + setValue: mockSetValue, + } as unknown as LoadedTrustedFolders); + mockedIsWorkspaceTrusted.mockReturnValue({ + isTrusted: false, + source: 'file', + }); - expect(result.current.isInheritedTrustFromParent).toBe(true); - expect(result.current.isInheritedTrustFromIde).toBe(false); - }); + const { result } = renderHook(() => + usePermissionsModifyTrust(mockOnExit, mockAddItem, otherDirectory), + ); - it('should detect inherited trust from IDE', () => { - mockedLoadTrustedFolders.mockReturnValue({ - user: { config: {} }, // No explicit trust - } as unknown as LoadedTrustedFolders); - mockedIsWorkspaceTrusted.mockReturnValue({ - isTrusted: true, - source: 'ide', + act(() => { + result.current.updateTrustLevel(TrustLevel.TRUST_FOLDER); + }); + + expect(result.current.needsRestart).toBe(false); + expect(mockSetValue).toHaveBeenCalledWith( + otherDirectory, + TrustLevel.TRUST_FOLDER, + ); + expect(mockOnExit).toHaveBeenCalled(); }); - const { result } = renderHook(() => - usePermissionsModifyTrust(mockOnExit, mockAddItem), - ); + it('should not add a warning when setting DO_NOT_TRUST', () => { + mockedLoadTrustedFolders.mockReturnValue({ + user: { config: {} }, + setValue: vi.fn(), + } as unknown as LoadedTrustedFolders); + mockedIsWorkspaceTrusted.mockReturnValue({ + isTrusted: true, + source: 'file', + }); - expect(result.current.isInheritedTrustFromIde).toBe(true); - expect(result.current.isInheritedTrustFromParent).toBe(false); - }); + const { result } = renderHook(() => + usePermissionsModifyTrust(mockOnExit, mockAddItem, otherDirectory), + ); - it('should set needsRestart but not save when trust changes', () => { - const mockSetValue = vi.fn(); - mockedLoadTrustedFolders.mockReturnValue({ - user: { config: {} }, - setValue: mockSetValue, - } as unknown as LoadedTrustedFolders); + act(() => { + result.current.updateTrustLevel(TrustLevel.DO_NOT_TRUST); + }); - mockedIsWorkspaceTrusted - .mockReturnValueOnce({ isTrusted: false, source: 'file' }) - .mockReturnValueOnce({ isTrusted: true, source: 'file' }); - - const { result } = renderHook(() => - usePermissionsModifyTrust(mockOnExit, mockAddItem), - ); - - act(() => { - result.current.updateTrustLevel(TrustLevel.TRUST_FOLDER); + expect(mockAddItem).not.toHaveBeenCalled(); }); - - expect(result.current.needsRestart).toBe(true); - expect(mockSetValue).not.toHaveBeenCalled(); - }); - - it('should save immediately if trust does not change', () => { - const mockSetValue = vi.fn(); - mockedLoadTrustedFolders.mockReturnValue({ - user: { config: {} }, - setValue: mockSetValue, - } as unknown as LoadedTrustedFolders); - - mockedIsWorkspaceTrusted.mockReturnValue({ - isTrusted: true, - source: 'file', - }); - - const { result } = renderHook(() => - usePermissionsModifyTrust(mockOnExit, mockAddItem), - ); - - act(() => { - result.current.updateTrustLevel(TrustLevel.TRUST_PARENT); - }); - - expect(result.current.needsRestart).toBe(false); - expect(mockSetValue).toHaveBeenCalledWith( - '/test/dir', - TrustLevel.TRUST_PARENT, - ); - expect(mockOnExit).toHaveBeenCalled(); - }); - - it('should commit the pending trust level change', () => { - const mockSetValue = vi.fn(); - mockedLoadTrustedFolders.mockReturnValue({ - user: { config: {} }, - setValue: mockSetValue, - } as unknown as LoadedTrustedFolders); - - mockedIsWorkspaceTrusted - .mockReturnValueOnce({ isTrusted: false, source: 'file' }) - .mockReturnValueOnce({ isTrusted: true, source: 'file' }); - - const { result } = renderHook(() => - usePermissionsModifyTrust(mockOnExit, mockAddItem), - ); - - act(() => { - result.current.updateTrustLevel(TrustLevel.TRUST_FOLDER); - }); - - expect(result.current.needsRestart).toBe(true); - - act(() => { - result.current.commitTrustLevelChange(); - }); - - expect(mockSetValue).toHaveBeenCalledWith( - '/test/dir', - TrustLevel.TRUST_FOLDER, - ); - }); - - it('should add warning when setting DO_NOT_TRUST but still trusted by parent', () => { - mockedLoadTrustedFolders.mockReturnValue({ - user: { config: {} }, - setValue: vi.fn(), - } as unknown as LoadedTrustedFolders); - mockedIsWorkspaceTrusted.mockReturnValue({ - isTrusted: true, - source: 'file', - }); - - const { result } = renderHook(() => - usePermissionsModifyTrust(mockOnExit, mockAddItem), - ); - - act(() => { - result.current.updateTrustLevel(TrustLevel.DO_NOT_TRUST); - }); - - expect(mockAddItem).toHaveBeenCalledWith( - { - type: 'warning', - text: 'Note: This folder is still trusted because a parent folder is trusted.', - }, - expect.any(Number), - ); - }); - - it('should add warning when setting DO_NOT_TRUST but still trusted by IDE', () => { - mockedLoadTrustedFolders.mockReturnValue({ - user: { config: {} }, - setValue: vi.fn(), - } as unknown as LoadedTrustedFolders); - mockedIsWorkspaceTrusted.mockReturnValue({ - isTrusted: true, - source: 'ide', - }); - - const { result } = renderHook(() => - usePermissionsModifyTrust(mockOnExit, mockAddItem), - ); - - act(() => { - result.current.updateTrustLevel(TrustLevel.DO_NOT_TRUST); - }); - - expect(mockAddItem).toHaveBeenCalledWith( - { - type: 'warning', - text: 'Note: This folder is still trusted because the connected IDE workspace is trusted.', - }, - expect.any(Number), - ); }); it('should emit feedback when setValue throws in updateTrustLevel', () => { @@ -278,7 +358,7 @@ describe('usePermissionsModifyTrust', () => { const emitFeedbackSpy = vi.spyOn(coreEvents, 'emitFeedback'); const { result } = renderHook(() => - usePermissionsModifyTrust(mockOnExit, mockAddItem), + usePermissionsModifyTrust(mockOnExit, mockAddItem, mockedCwd()), ); act(() => { @@ -308,7 +388,7 @@ describe('usePermissionsModifyTrust', () => { const emitFeedbackSpy = vi.spyOn(coreEvents, 'emitFeedback'); const { result } = renderHook(() => - usePermissionsModifyTrust(mockOnExit, mockAddItem), + usePermissionsModifyTrust(mockOnExit, mockAddItem, mockedCwd()), ); act(() => { diff --git a/packages/cli/src/ui/hooks/usePermissionsModifyTrust.ts b/packages/cli/src/ui/hooks/usePermissionsModifyTrust.ts index 64e2011b0d..9d38613113 100644 --- a/packages/cli/src/ui/hooks/usePermissionsModifyTrust.ts +++ b/packages/cli/src/ui/hooks/usePermissionsModifyTrust.ts @@ -6,6 +6,7 @@ import { useState, useCallback } from 'react'; import * as process from 'node:process'; +import * as path from 'node:path'; import { loadTrustedFolders, TrustLevel, @@ -27,9 +28,19 @@ interface TrustState { function getInitialTrustState( settings: LoadedSettings, cwd: string, + isCurrentWorkspace: boolean, ): TrustState { const folders = loadTrustedFolders(); const explicitTrustLevel = folders.user.config[cwd]; + + if (!isCurrentWorkspace) { + return { + currentTrustLevel: explicitTrustLevel, + isInheritedTrustFromParent: false, + isInheritedTrustFromIde: false, + }; + } + const { isTrusted, source } = isWorkspaceTrusted(settings.merged); const isInheritedTrust = @@ -46,11 +57,19 @@ function getInitialTrustState( export const usePermissionsModifyTrust = ( onExit: () => void, addItem: UseHistoryManagerReturn['addItem'], + targetDirectory: string, ) => { const settings = useSettings(); - const cwd = process.cwd(); + const cwd = targetDirectory; + // Normalize paths for case-insensitive file systems (macOS/Windows) to ensure + // accurate comparison between targetDirectory and process.cwd(). + const isCurrentWorkspace = + path.resolve(targetDirectory).toLowerCase() === + path.resolve(process.cwd()).toLowerCase(); - const [initialState] = useState(() => getInitialTrustState(settings, cwd)); + const [initialState] = useState(() => + getInitialTrustState(settings, cwd, isCurrentWorkspace), + ); const [currentTrustLevel] = useState( initialState.currentTrustLevel, @@ -70,6 +89,16 @@ export const usePermissionsModifyTrust = ( const updateTrustLevel = useCallback( (trustLevel: TrustLevel) => { + // If we are not editing the current workspace, the logic is simple: + // just save the setting and exit. No restart or warnings are needed. + if (!isCurrentWorkspace) { + const folders = loadTrustedFolders(); + folders.setValue(cwd, trustLevel); + onExit(); + return; + } + + // All logic below only applies when editing the current workspace. const wasTrusted = isWorkspaceTrusted(settings.merged).isTrusted; // Create a temporary config to check the new trust status without writing @@ -113,7 +142,7 @@ export const usePermissionsModifyTrust = ( onExit(); } }, - [cwd, settings.merged, onExit, addItem], + [cwd, settings.merged, onExit, addItem, isCurrentWorkspace], ); const commitTrustLevelChange = useCallback(() => { diff --git a/packages/cli/src/ui/utils/directoryUtils.ts b/packages/cli/src/ui/utils/directoryUtils.ts new file mode 100644 index 0000000000..e389042c7c --- /dev/null +++ b/packages/cli/src/ui/utils/directoryUtils.ts @@ -0,0 +1,21 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as os from 'node:os'; +import * as path from 'node:path'; + +export function expandHomeDir(p: string): string { + if (!p) { + return ''; + } + let expandedPath = p; + if (p.toLowerCase().startsWith('%userprofile%')) { + expandedPath = os.homedir() + p.substring('%userprofile%'.length); + } else if (p === '~' || p.startsWith('~/')) { + expandedPath = os.homedir() + p.substring(1); + } + return path.normalize(expandedPath); +}