From f14d0c6a170db908261ad0ea8917b19c94c2dff2 Mon Sep 17 00:00:00 2001 From: Shreya Keshive Date: Fri, 30 Jan 2026 13:05:22 -0500 Subject: [PATCH] feat(admin): provide actionable error messages for disabled features (#17815) --- packages/cli/src/config/config.test.ts | 8 +-- packages/cli/src/config/config.ts | 3 +- packages/cli/src/deferred.test.ts | 32 +++++----- packages/cli/src/deferred.ts | 21 ++++-- .../cli/src/services/BuiltinCommandLoader.ts | 18 ++++-- .../cli/src/ui/commands/skillsCommand.test.ts | 5 +- packages/cli/src/ui/commands/skillsCommand.ts | 20 ++++-- .../ui/hooks/useApprovalModeIndicator.test.ts | 64 +++++++++++++++++++ .../src/ui/hooks/useApprovalModeIndicator.ts | 17 ++++- .../code_assist/admin/admin_controls.test.ts | 58 +++++++++++++++++ .../src/code_assist/admin/admin_controls.ts | 19 ++++++ packages/core/src/index.ts | 1 + 12 files changed, 228 insertions(+), 38 deletions(-) diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 54bb3e704a..2ca11be668 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -1255,7 +1255,7 @@ describe('Approval mode tool exclusion logic', () => { }); await expect(loadCliConfig(settings, 'test-session', argv)).rejects.toThrow( - 'Cannot start in YOLO mode since it is disabled by your admin', + 'YOLO mode is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli', ); }); @@ -2928,7 +2928,7 @@ describe('loadCliConfig disableYoloMode', () => { security: { disableYoloMode: true }, }); await expect(loadCliConfig(settings, 'test-session', argv)).rejects.toThrow( - 'Cannot start in YOLO mode since it is disabled by your admin', + 'YOLO mode is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli', ); }); }); @@ -2960,7 +2960,7 @@ describe('loadCliConfig secureModeEnabled', () => { }); await expect(loadCliConfig(settings, 'test-session', argv)).rejects.toThrow( - 'Cannot start in YOLO mode since it is disabled by your admin', + 'YOLO mode is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli', ); }); @@ -2974,7 +2974,7 @@ describe('loadCliConfig secureModeEnabled', () => { }); await expect(loadCliConfig(settings, 'test-session', argv)).rejects.toThrow( - 'Cannot start in YOLO mode since it is disabled by your admin', + 'YOLO mode is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli', ); }); diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 6d79b1d4c1..0c5063faee 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -38,6 +38,7 @@ import { type OutputFormat, coreEvents, GEMINI_MODEL_ALIAS_AUTO, + getAdminErrorMessage, } from '@google/gemini-cli-core'; import { type Settings, @@ -550,7 +551,7 @@ export async function loadCliConfig( ); } throw new FatalConfigError( - 'Cannot start in YOLO mode since it is disabled by your admin', + getAdminErrorMessage('YOLO mode', undefined /* config */), ); } } else if (approvalMode === ApprovalMode.YOLO) { diff --git a/packages/cli/src/deferred.test.ts b/packages/cli/src/deferred.test.ts index 4ea5eb791d..8b9fb87f7a 100644 --- a/packages/cli/src/deferred.test.ts +++ b/packages/cli/src/deferred.test.ts @@ -16,11 +16,10 @@ import type { ArgumentsCamelCase, CommandModule } from 'yargs'; import type { MergedSettings } from './config/settings.js'; import type { MockInstance } from 'vitest'; -const { mockRunExitCleanup, mockDebugLogger } = vi.hoisted(() => ({ +const { mockRunExitCleanup, mockCoreEvents } = vi.hoisted(() => ({ mockRunExitCleanup: vi.fn(), - mockDebugLogger: { - log: vi.fn(), - error: vi.fn(), + mockCoreEvents: { + emitFeedback: vi.fn(), }, })); @@ -28,7 +27,7 @@ vi.mock('@google/gemini-cli-core', async () => { const actual = await vi.importActual('@google/gemini-cli-core'); return { ...actual, - debugLogger: mockDebugLogger, + coreEvents: mockCoreEvents, }; }); @@ -55,8 +54,7 @@ describe('deferred', () => { describe('runDeferredCommand', () => { it('should do nothing if no deferred command is set', async () => { await runDeferredCommand(createMockSettings()); - expect(mockDebugLogger.log).not.toHaveBeenCalled(); - expect(mockDebugLogger.error).not.toHaveBeenCalled(); + expect(mockCoreEvents.emitFeedback).not.toHaveBeenCalled(); expect(mockExit).not.toHaveBeenCalled(); }); @@ -85,8 +83,9 @@ describe('deferred', () => { const settings = createMockSettings({ mcp: { enabled: false } }); await runDeferredCommand(settings); - expect(mockDebugLogger.error).toHaveBeenCalledWith( - 'Error: MCP is disabled by your admin.', + expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith( + 'error', + 'MCP is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli', ); expect(mockRunExitCleanup).toHaveBeenCalled(); expect(mockExit).toHaveBeenCalledWith(ExitCodes.FATAL_CONFIG_ERROR); @@ -102,8 +101,9 @@ describe('deferred', () => { const settings = createMockSettings({ extensions: { enabled: false } }); await runDeferredCommand(settings); - expect(mockDebugLogger.error).toHaveBeenCalledWith( - 'Error: Extensions are disabled by your admin.', + expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith( + 'error', + 'Extensions is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli', ); expect(mockRunExitCleanup).toHaveBeenCalled(); expect(mockExit).toHaveBeenCalledWith(ExitCodes.FATAL_CONFIG_ERROR); @@ -119,8 +119,9 @@ describe('deferred', () => { const settings = createMockSettings({ skills: { enabled: false } }); await runDeferredCommand(settings); - expect(mockDebugLogger.error).toHaveBeenCalledWith( - 'Error: Agent skills are disabled by your admin.', + expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith( + 'error', + 'Agent skills is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli', ); expect(mockRunExitCleanup).toHaveBeenCalled(); expect(mockExit).toHaveBeenCalledWith(ExitCodes.FATAL_CONFIG_ERROR); @@ -183,8 +184,9 @@ describe('deferred', () => { const mcpSettings = createMockSettings({ mcp: { enabled: false } }); await runDeferredCommand(mcpSettings); - expect(mockDebugLogger.error).toHaveBeenCalledWith( - 'Error: MCP is disabled by your admin.', + expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith( + 'error', + 'MCP is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli', ); }); diff --git a/packages/cli/src/deferred.ts b/packages/cli/src/deferred.ts index 73fac6d1ce..309233ba45 100644 --- a/packages/cli/src/deferred.ts +++ b/packages/cli/src/deferred.ts @@ -4,7 +4,11 @@ * SPDX-License-Identifier: Apache-2.0 */ import type { ArgumentsCamelCase, CommandModule } from 'yargs'; -import { debugLogger, ExitCodes } from '@google/gemini-cli-core'; +import { + coreEvents, + ExitCodes, + getAdminErrorMessage, +} from '@google/gemini-cli-core'; import { runExitCleanup } from './utils/cleanup.js'; import type { MergedSettings } from './config/settings.js'; import process from 'node:process'; @@ -30,7 +34,10 @@ export async function runDeferredCommand(settings: MergedSettings) { const commandName = deferredCommand.commandName; if (commandName === 'mcp' && adminSettings?.mcp?.enabled === false) { - debugLogger.error('Error: MCP is disabled by your admin.'); + coreEvents.emitFeedback( + 'error', + getAdminErrorMessage('MCP', undefined /* config */), + ); await runExitCleanup(); process.exit(ExitCodes.FATAL_CONFIG_ERROR); } @@ -39,13 +46,19 @@ export async function runDeferredCommand(settings: MergedSettings) { commandName === 'extensions' && adminSettings?.extensions?.enabled === false ) { - debugLogger.error('Error: Extensions are disabled by your admin.'); + coreEvents.emitFeedback( + 'error', + getAdminErrorMessage('Extensions', undefined /* config */), + ); await runExitCleanup(); process.exit(ExitCodes.FATAL_CONFIG_ERROR); } if (commandName === 'skills' && adminSettings?.skills?.enabled === false) { - debugLogger.error('Error: Agent skills are disabled by your admin.'); + coreEvents.emitFeedback( + 'error', + getAdminErrorMessage('Agent skills', undefined /* config */), + ); await runExitCleanup(); process.exit(ExitCodes.FATAL_CONFIG_ERROR); } diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index b72a239328..75cbe74cc2 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -12,7 +12,11 @@ import { type CommandContext, } from '../ui/commands/types.js'; import type { MessageActionReturn, Config } from '@google/gemini-cli-core'; -import { isNightly, startupProfiler } from '@google/gemini-cli-core'; +import { + isNightly, + startupProfiler, + getAdminErrorMessage, +} from '@google/gemini-cli-core'; import { aboutCommand } from '../ui/commands/aboutCommand.js'; import { agentsCommand } from '../ui/commands/agentsCommand.js'; import { authCommand } from '../ui/commands/authCommand.js'; @@ -102,7 +106,10 @@ export class BuiltinCommandLoader implements ICommandLoader { ): Promise => ({ type: 'message', messageType: 'error', - content: 'Extensions are disabled by your admin.', + content: getAdminErrorMessage( + 'Extensions', + this.config ?? undefined, + ), }), }, ] @@ -127,7 +134,7 @@ export class BuiltinCommandLoader implements ICommandLoader { ): Promise => ({ type: 'message', messageType: 'error', - content: 'MCP is disabled by your admin.', + content: getAdminErrorMessage('MCP', this.config ?? undefined), }), }, ] @@ -158,7 +165,10 @@ export class BuiltinCommandLoader implements ICommandLoader { ): Promise => ({ type: 'message', messageType: 'error', - content: 'Agent skills are disabled by your admin.', + content: getAdminErrorMessage( + 'Agent skills', + this.config ?? undefined, + ), }), }, ] diff --git a/packages/cli/src/ui/commands/skillsCommand.test.ts b/packages/cli/src/ui/commands/skillsCommand.test.ts index fb62f567b7..3a82639923 100644 --- a/packages/cli/src/ui/commands/skillsCommand.test.ts +++ b/packages/cli/src/ui/commands/skillsCommand.test.ts @@ -58,6 +58,7 @@ describe('skillsCommand', () => { (name: string) => skills.find((s) => s.name === name) ?? null, ), }), + getContentGenerator: vi.fn(), } as unknown as Config, settings: { merged: createTestMergedSettings({ skills: { disabled: [] } }), @@ -367,7 +368,7 @@ describe('skillsCommand', () => { expect(context.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ type: MessageType.ERROR, - text: 'Agent skills are disabled by your admin.', + text: 'Agent skills is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli', }), expect.any(Number), ); @@ -385,7 +386,7 @@ describe('skillsCommand', () => { expect(context.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ type: MessageType.ERROR, - text: 'Agent skills are disabled by your admin.', + text: 'Agent skills is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli', }), expect.any(Number), ); diff --git a/packages/cli/src/ui/commands/skillsCommand.ts b/packages/cli/src/ui/commands/skillsCommand.ts index 46be6d86f5..74372d2179 100644 --- a/packages/cli/src/ui/commands/skillsCommand.ts +++ b/packages/cli/src/ui/commands/skillsCommand.ts @@ -11,13 +11,15 @@ import { CommandKind, } from './types.js'; import { - MessageType, - type HistoryItemSkillsList, type HistoryItemInfo, + type HistoryItemSkillsList, + MessageType, } from '../types.js'; -import { SettingScope } from '../../config/settings.js'; -import { enableSkill, disableSkill } from '../../utils/skillSettings.js'; +import { disableSkill, enableSkill } from '../../utils/skillSettings.js'; + +import { getAdminErrorMessage } from '@google/gemini-cli-core'; import { renderSkillActionFeedback } from '../../utils/skillUtils.js'; +import { SettingScope } from '../../config/settings.js'; async function listAction( context: CommandContext, @@ -83,7 +85,10 @@ async function disableAction( context.ui.addItem( { type: MessageType.ERROR, - text: 'Agent skills are disabled by your admin.', + text: getAdminErrorMessage( + 'Agent skills', + context.services.config ?? undefined, + ), }, Date.now(), ); @@ -141,7 +146,10 @@ async function enableAction( context.ui.addItem( { type: MessageType.ERROR, - text: 'Agent skills are disabled by your admin.', + text: getAdminErrorMessage( + 'Agent skills', + context.services.config ?? undefined, + ), }, Date.now(), ); diff --git a/packages/cli/src/ui/hooks/useApprovalModeIndicator.test.ts b/packages/cli/src/ui/hooks/useApprovalModeIndicator.test.ts index 17e4a108fb..4fec4edf18 100644 --- a/packages/cli/src/ui/hooks/useApprovalModeIndicator.test.ts +++ b/packages/cli/src/ui/hooks/useApprovalModeIndicator.test.ts @@ -30,6 +30,9 @@ vi.mock('@google/gemini-cli-core', async () => { return { ...actualServerModule, Config: vi.fn(), + getAdminErrorMessage: vi.fn( + (featureName: string) => `[Mock] ${featureName} is disabled`, + ), }; }); @@ -52,6 +55,9 @@ interface MockConfigInstanceShape { getUserMemory: Mock<() => string>; getGeminiMdFileCount: Mock<() => number>; getToolRegistry: Mock<() => { discoverTools: Mock<() => void> }>; + getRemoteAdminSettings: Mock< + () => { strictModeDisabled?: boolean; mcpEnabled?: boolean } | undefined + >; } type UseKeypressHandler = (key: Key) => void; @@ -109,6 +115,9 @@ describe('useApprovalModeIndicator', () => { .mockReturnValue({ discoverTools: vi.fn() }) as Mock< () => { discoverTools: Mock<() => void> } >, + getRemoteAdminSettings: vi.fn().mockReturnValue(undefined) as Mock< + () => { strictModeDisabled?: boolean } | undefined + >, }; instanceSetApprovalModeMock.mockImplementation((value: ApprovalMode) => { instanceGetApprovalModeMock.mockReturnValue(value); @@ -517,6 +526,9 @@ describe('useApprovalModeIndicator', () => { it('should not enable YOLO mode when Ctrl+Y is pressed and add an info message', () => { mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.DEFAULT); + mockConfigInstance.getRemoteAdminSettings.mockReturnValue({ + strictModeDisabled: true, + }); const mockAddItem = vi.fn(); const { result } = renderHook(() => useApprovalModeIndicator({ @@ -544,6 +556,58 @@ describe('useApprovalModeIndicator', () => { // The mode should not change expect(result.current).toBe(ApprovalMode.DEFAULT); }); + + it('should show admin error message when YOLO mode is disabled by admin', () => { + mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.DEFAULT); + mockConfigInstance.getRemoteAdminSettings.mockReturnValue({ + mcpEnabled: true, + }); + + const mockAddItem = vi.fn(); + renderHook(() => + useApprovalModeIndicator({ + config: mockConfigInstance as unknown as ActualConfigType, + addItem: mockAddItem, + }), + ); + + act(() => { + capturedUseKeypressHandler({ name: 'y', ctrl: true } as Key); + }); + + expect(mockAddItem).toHaveBeenCalledWith( + { + type: MessageType.WARNING, + text: '[Mock] YOLO mode is disabled', + }, + expect.any(Number), + ); + }); + + it('should show default error message when admin settings are empty', () => { + mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.DEFAULT); + mockConfigInstance.getRemoteAdminSettings.mockReturnValue({}); + + const mockAddItem = vi.fn(); + renderHook(() => + useApprovalModeIndicator({ + config: mockConfigInstance as unknown as ActualConfigType, + addItem: mockAddItem, + }), + ); + + act(() => { + capturedUseKeypressHandler({ name: 'y', ctrl: true } as Key); + }); + + expect(mockAddItem).toHaveBeenCalledWith( + { + type: MessageType.WARNING, + text: 'You cannot enter YOLO mode since it is disabled in your settings.', + }, + expect.any(Number), + ); + }); }); it('should call onApprovalModeChange when switching to YOLO mode', () => { diff --git a/packages/cli/src/ui/hooks/useApprovalModeIndicator.ts b/packages/cli/src/ui/hooks/useApprovalModeIndicator.ts index dfb1420303..3208b41603 100644 --- a/packages/cli/src/ui/hooks/useApprovalModeIndicator.ts +++ b/packages/cli/src/ui/hooks/useApprovalModeIndicator.ts @@ -5,7 +5,11 @@ */ import { useState, useEffect } from 'react'; -import { ApprovalMode, type Config } from '@google/gemini-cli-core'; +import { + ApprovalMode, + type Config, + getAdminErrorMessage, +} from '@google/gemini-cli-core'; import { useKeypress } from './useKeypress.js'; import { keyMatchers, Command } from '../keyMatchers.js'; import type { HistoryItemWithoutId } from '../types.js'; @@ -41,10 +45,19 @@ export function useApprovalModeIndicator({ config.getApprovalMode() !== ApprovalMode.YOLO ) { if (addItem) { + let text = + 'You cannot enter YOLO mode since it is disabled in your settings.'; + const adminSettings = config.getRemoteAdminSettings(); + const hasSettings = + adminSettings && Object.keys(adminSettings).length > 0; + if (hasSettings && !adminSettings.strictModeDisabled) { + text = getAdminErrorMessage('YOLO mode', config); + } + addItem( { type: MessageType.WARNING, - text: 'You cannot enter YOLO mode since it is disabled in your settings.', + text, }, Date.now(), ); diff --git a/packages/core/src/code_assist/admin/admin_controls.test.ts b/packages/core/src/code_assist/admin/admin_controls.test.ts index 8664530fb0..b36daa3c9b 100644 --- a/packages/core/src/code_assist/admin/admin_controls.test.ts +++ b/packages/core/src/code_assist/admin/admin_controls.test.ts @@ -17,8 +17,15 @@ import { fetchAdminControls, sanitizeAdminSettings, stopAdminControlsPolling, + getAdminErrorMessage, } from './admin_controls.js'; import type { CodeAssistServer } from '../server.js'; +import type { Config } from '../../config/config.js'; +import { getCodeAssistServer } from '../codeAssist.js'; + +vi.mock('../codeAssist.js', () => ({ + getCodeAssistServer: vi.fn(), +})); describe('Admin Controls', () => { let mockServer: CodeAssistServer; @@ -370,6 +377,57 @@ describe('Admin Controls', () => { // The poll should not have fired again expect(mockServer.fetchAdminControls).toHaveBeenCalledTimes(1); + expect(mockServer.fetchAdminControls).toHaveBeenCalledTimes(1); + }); + }); + + describe('getAdminErrorMessage', () => { + let mockConfig: Config; + + beforeEach(() => { + mockConfig = {} as Config; + }); + + it('should include feature name and project ID when present', () => { + vi.mocked(getCodeAssistServer).mockReturnValue({ + projectId: 'test-project-123', + } as CodeAssistServer); + + const message = getAdminErrorMessage('Code Completion', mockConfig); + + expect(message).toBe( + 'Code Completion is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli?project=test-project-123', + ); + }); + + it('should include feature name but OMIT project ID when missing', () => { + vi.mocked(getCodeAssistServer).mockReturnValue({ + projectId: undefined, + } as CodeAssistServer); + + const message = getAdminErrorMessage('Chat', mockConfig); + + expect(message).toBe( + 'Chat is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli', + ); + }); + + it('should include feature name but OMIT project ID when server is undefined', () => { + vi.mocked(getCodeAssistServer).mockReturnValue(undefined); + + const message = getAdminErrorMessage('Chat', mockConfig); + + expect(message).toBe( + 'Chat is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli', + ); + }); + + it('should include feature name but OMIT project ID when config is undefined', () => { + const message = getAdminErrorMessage('Chat', undefined); + + expect(message).toBe( + 'Chat is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli', + ); }); }); }); diff --git a/packages/core/src/code_assist/admin/admin_controls.ts b/packages/core/src/code_assist/admin/admin_controls.ts index e2feaf9414..fce50b60f0 100644 --- a/packages/core/src/code_assist/admin/admin_controls.ts +++ b/packages/core/src/code_assist/admin/admin_controls.ts @@ -11,6 +11,8 @@ import { type FetchAdminControlsResponse, FetchAdminControlsResponseSchema, } from '../types.js'; +import { getCodeAssistServer } from '../codeAssist.js'; +import type { Config } from '../../config/config.js'; let pollingInterval: NodeJS.Timeout | undefined; let currentSettings: FetchAdminControlsResponse | undefined; @@ -132,3 +134,20 @@ export function stopAdminControlsPolling() { pollingInterval = undefined; } } + +/** + * Returns a standardized error message for features disabled by admin settings. + * + * @param featureName The name of the disabled feature + * @param config The application config + * @returns The formatted error message + */ +export function getAdminErrorMessage( + featureName: string, + config: Config | undefined, +): string { + const server = config ? getCodeAssistServer(config) : undefined; + const projectId = server?.projectId; + const projectParam = projectId ? `?project=${projectId}` : ''; + return `${featureName} is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli${projectParam}`; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 85d5004c4c..219e8151ab 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -50,6 +50,7 @@ export * from './code_assist/server.js'; export * from './code_assist/setup.js'; export * from './code_assist/types.js'; export * from './code_assist/telemetry.js'; +export * from './code_assist/admin/admin_controls.js'; export * from './core/apiKeyCredentialStorage.js'; // Export utilities