diff --git a/packages/cli/src/config/settings.test.ts b/packages/cli/src/config/settings.test.ts index df3fdbe9ea7..4c07d08b382 100644 --- a/packages/cli/src/config/settings.test.ts +++ b/packages/cli/src/config/settings.test.ts @@ -2476,6 +2476,187 @@ describe('Settings Loading and Merging', () => { }); }); + describe('LoadedSettings and remote admin settings', () => { + it('should prioritize remote admin settings over file-based admin settings', () => { + (mockFsExistsSync as Mock).mockReturnValue(true); + const systemSettingsContent = { + admin: { + // These should be ignored + secureModeEnabled: true, + mcp: { enabled: false }, + extensions: { enabled: false }, + }, + // A non-admin setting to ensure it's still processed + ui: { theme: 'system-theme' }, + }; + + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (p === getSystemSettingsPath()) { + return JSON.stringify(systemSettingsContent); + } + return '{}'; + }, + ); + + const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR); + + // 1. Verify that on initial load, file-based admin settings are ignored + // and schema defaults are used instead. + expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(false); // default: false + expect(loadedSettings.merged.admin?.mcp?.enabled).toBe(true); // default: true + expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(true); // default: true + expect(loadedSettings.merged.ui?.theme).toBe('system-theme'); // non-admin setting should be loaded + + // 2. Now, set remote admin settings. + loadedSettings.setRemoteAdminSettings({ + secureModeEnabled: true, + mcpSetting: { mcpEnabled: false }, + cliFeatureSetting: { extensionsSetting: { extensionsEnabled: false } }, + }); + + // 3. Verify that remote admin settings take precedence. + expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(true); + expect(loadedSettings.merged.admin?.mcp?.enabled).toBe(false); + expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(false); + // non-admin setting should remain unchanged + expect(loadedSettings.merged.ui?.theme).toBe('system-theme'); + }); + + it('should set remote admin settings and recompute merged settings', () => { + (mockFsExistsSync as Mock).mockReturnValue(true); + const systemSettingsContent = { + admin: { + secureModeEnabled: false, + mcp: { enabled: false }, + extensions: { enabled: false }, + }, + ui: { theme: 'initial-theme' }, + }; + + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (p === getSystemSettingsPath()) { + return JSON.stringify(systemSettingsContent); + } + return '{}'; + }, + ); + + const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR); + // Ensure initial state from defaults (as file-based admin settings are ignored) + expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(false); + expect(loadedSettings.merged.admin?.mcp?.enabled).toBe(true); + expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(true); + expect(loadedSettings.merged.ui?.theme).toBe('initial-theme'); + + const newRemoteSettings = { + secureModeEnabled: true, + mcpSetting: { mcpEnabled: false }, + cliFeatureSetting: { extensionsSetting: { extensionsEnabled: false } }, + }; + + loadedSettings.setRemoteAdminSettings(newRemoteSettings); + + // Verify that remote admin settings are applied + expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(true); + expect(loadedSettings.merged.admin?.mcp?.enabled).toBe(false); + expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(false); + // Non-admin settings should remain untouched + expect(loadedSettings.merged.ui?.theme).toBe('initial-theme'); + + // Verify that calling setRemoteAdminSettings with partial data overwrites previous remote settings + // and missing properties revert to schema defaults. + loadedSettings.setRemoteAdminSettings({ secureModeEnabled: false }); + expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(false); + expect(loadedSettings.merged.admin?.mcp?.enabled).toBe(true); // Reverts to default: true + expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(true); // Reverts to default: true + }); + + it('should correctly handle undefined remote admin settings', () => { + (mockFsExistsSync as Mock).mockReturnValue(true); + const systemSettingsContent = { + ui: { theme: 'initial-theme' }, + }; + + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (p === getSystemSettingsPath()) { + return JSON.stringify(systemSettingsContent); + } + return '{}'; + }, + ); + + const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR); + // Should have default admin settings + expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(false); + expect(loadedSettings.merged.admin?.mcp?.enabled).toBe(true); + expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(true); + + loadedSettings.setRemoteAdminSettings({}); // Set empty remote settings + + // Admin settings should revert to defaults because there are no remote overrides + expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(false); + expect(loadedSettings.merged.admin?.mcp?.enabled).toBe(true); + expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(true); + }); + + it('should correctly handle missing properties in remote admin settings', () => { + (mockFsExistsSync as Mock).mockReturnValue(true); + const systemSettingsContent = { + admin: { + secureModeEnabled: true, + }, + }; + + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (p === getSystemSettingsPath()) { + return JSON.stringify(systemSettingsContent); + } + return '{}'; + }, + ); + + const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR); + // Ensure initial state from defaults (as file-based admin settings are ignored) + expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(false); + expect(loadedSettings.merged.admin?.mcp?.enabled).toBe(true); + expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(true); + + // Set remote settings with only secureModeEnabled + loadedSettings.setRemoteAdminSettings({ + secureModeEnabled: true, + }); + + // Verify secureModeEnabled is updated, others remain defaults + expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(true); + expect(loadedSettings.merged.admin?.mcp?.enabled).toBe(true); + expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(true); + + // Set remote settings with only mcpSetting.mcpEnabled + loadedSettings.setRemoteAdminSettings({ + mcpSetting: { mcpEnabled: false }, + }); + + // Verify mcpEnabled is updated, others remain defaults (secureModeEnabled reverts to default:false) + expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(false); + expect(loadedSettings.merged.admin?.mcp?.enabled).toBe(false); + expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(true); + + // Set remote settings with only cliFeatureSetting.extensionsSetting.extensionsEnabled + loadedSettings.setRemoteAdminSettings({ + cliFeatureSetting: { extensionsSetting: { extensionsEnabled: false } }, + }); + + // Verify extensionsEnabled is updated, others remain defaults + expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(false); + expect(loadedSettings.merged.admin?.mcp?.enabled).toBe(true); + expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(false); + }); + }); + describe('getDefaultsFromSchema', () => { it('should extract defaults from a schema', () => { const mockSchema = { diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index 1389430f297..43bdc1a6272 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -17,6 +17,7 @@ import { Storage, coreEvents, homedir, + type GeminiCodeAssistSetting, } from '@google/gemini-cli-core'; import stripJsonComments from 'strip-json-comments'; import { DefaultLight } from '../ui/themes/default-light.js'; @@ -499,19 +500,37 @@ export class LoadedSettings { readonly errors: SettingsError[]; private _merged: Settings; + private _remoteAdminSettings: Partial | undefined; get merged(): Settings { return this._merged; } private computeMergedSettings(): Settings { - return mergeSettings( + const merged = mergeSettings( this.system.settings, this.systemDefaults.settings, this.user.settings, this.workspace.settings, this.isTrusted, ); + + // Remote admin settings always take precedence and file-based admin settings + // are ignored. + const adminSettingSchema = getSettingsSchema().admin; + if (adminSettingSchema?.properties) { + const adminSchema = adminSettingSchema.properties; + const adminDefaults = getDefaultsFromSchema(adminSchema); + + // The final admin settings are the defaults overridden by remote settings. + // Any admin settings from files are ignored. + merged.admin = customDeepMerge( + (path: string[]) => getMergeStrategyForPath(['admin', ...path]), + adminDefaults, + this._remoteAdminSettings?.admin ?? {}, + ) as Settings['admin']; + } + return merged; } forScope(scope: LoadableSettingScope): SettingsFile { @@ -537,6 +556,31 @@ export class LoadedSettings { saveSettings(settingsFile); coreEvents.emitSettingsChanged(); } + + setRemoteAdminSettings(remoteSettings: GeminiCodeAssistSetting): void { + const admin: Settings['admin'] = {}; + + if (remoteSettings.secureModeEnabled !== undefined) { + admin.secureModeEnabled = remoteSettings.secureModeEnabled; + } + + if (remoteSettings.mcpSetting?.mcpEnabled !== undefined) { + admin.mcp = { enabled: remoteSettings.mcpSetting.mcpEnabled }; + } + + if ( + remoteSettings.cliFeatureSetting?.extensionsSetting?.extensionsEnabled !== + undefined + ) { + admin.extensions = { + enabled: + remoteSettings.cliFeatureSetting.extensionsSetting.extensionsEnabled, + }; + } + + this._remoteAdminSettings = { admin }; + this._merged = this.computeMergedSettings(); + } } function findEnvFile(startDir: string): string | null { diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index f8bfa55383c..950705bfcac 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -230,91 +230,6 @@ describe('gemini.tsx main function', () => { vi.restoreAllMocks(); }); - it('verifies that we dont load the config before relaunchAppInChildProcess', async () => { - const processExitSpy = vi - .spyOn(process, 'exit') - .mockImplementation((code) => { - throw new MockProcessExitError(code); - }); - const { relaunchAppInChildProcess } = await import('./utils/relaunch.js'); - const { loadCliConfig } = await import('./config/config.js'); - const { loadSettings } = await import('./config/settings.js'); - const { loadSandboxConfig } = await import('./config/sandboxConfig.js'); - vi.mocked(loadSandboxConfig).mockResolvedValue(undefined); - - const callOrder: string[] = []; - vi.mocked(relaunchAppInChildProcess).mockImplementation(async () => { - callOrder.push('relaunch'); - }); - vi.mocked(loadCliConfig).mockImplementation(async () => { - callOrder.push('loadCliConfig'); - return { - isInteractive: () => false, - getQuestion: () => '', - getSandbox: () => false, - getDebugMode: () => false, - getListExtensions: () => false, - getListSessions: () => false, - getDeleteSession: () => undefined, - getMcpServers: () => ({}), - getMcpClientManager: vi.fn(), - initialize: vi.fn(), - getIdeMode: () => false, - getExperimentalZedIntegration: () => false, - getScreenReader: () => false, - getGeminiMdFileCount: () => 0, - getProjectRoot: () => '/', - getPolicyEngine: vi.fn(), - getMessageBus: () => ({ - subscribe: vi.fn(), - }), - getEnableHooks: () => false, - getHookSystem: () => undefined, - getToolRegistry: vi.fn(), - getContentGeneratorConfig: vi.fn(), - getModel: () => 'gemini-pro', - getEmbeddingModel: () => 'embedding-001', - getApprovalMode: () => 'default', - getCoreTools: () => [], - getTelemetryEnabled: () => false, - getTelemetryLogPromptsEnabled: () => false, - getFileFilteringRespectGitIgnore: () => true, - getOutputFormat: () => 'text', - getExtensions: () => [], - getUsageStatisticsEnabled: () => false, - refreshAuth: vi.fn(), - setTerminalBackground: vi.fn(), - } as unknown as Config; - }); - vi.mocked(loadSettings).mockReturnValue({ - errors: [], - merged: { - advanced: { autoConfigureMemory: true }, - security: { auth: {} }, - ui: {}, - }, - workspace: { settings: {} }, - setValue: vi.fn(), - forScope: () => ({ settings: {}, originalSettings: {}, path: '' }), - } as never); - try { - await main(); - } catch (e) { - // Mocked process exit throws an error. - if (!(e instanceof MockProcessExitError)) throw e; - } - - // It is critical that we call relaunch before loadCliConfig to avoid - // loading config in the outer process when we are going to relaunch. - // By ensuring we don't load the config we also ensure we don't trigger any - // operations that might require loading the config such as such as - // initializing mcp servers. - // For the sandbox case we still have to load a partial cli config. - // we can authorize outside the sandbox. - expect(callOrder).toEqual(['relaunch', 'loadCliConfig']); - processExitSpy.mockRestore(); - }); - it('should log unhandled promise rejections and open debug console on first error', async () => { const processExitSpy = vi .spyOn(process, 'exit') @@ -519,6 +434,7 @@ describe('gemini.tsx main function kitty protocol', () => { getOutputFormat: () => 'text', getExtensions: () => [], getUsageStatisticsEnabled: () => false, + getRemoteAdminSettings: () => undefined, setTerminalBackground: vi.fn(), } as unknown as Config); vi.mocked(loadSettings).mockReturnValue({ @@ -621,6 +537,7 @@ describe('gemini.tsx main function kitty protocol', () => { getScreenReader: () => false, getGeminiMdFileCount: () => 0, getProjectRoot: () => '/', + getRemoteAdminSettings: () => undefined, setTerminalBackground: vi.fn(), } as unknown as Config; @@ -706,6 +623,7 @@ describe('gemini.tsx main function kitty protocol', () => { getGeminiMdFileCount: () => 0, getProjectRoot: () => '/', refreshAuth: vi.fn(), + getRemoteAdminSettings: () => undefined, setTerminalBackground: vi.fn(), } as unknown as Config; @@ -790,6 +708,7 @@ describe('gemini.tsx main function kitty protocol', () => { getUsageStatisticsEnabled: () => false, refreshAuth: vi.fn(), setTerminalBackground: vi.fn(), + getRemoteAdminSettings: () => undefined, } as any); // eslint-disable-line @typescript-eslint/no-explicit-any vi.spyOn(themeManager, 'setActiveTheme').mockReturnValue(false); @@ -872,6 +791,7 @@ describe('gemini.tsx main function kitty protocol', () => { getFileFilteringRespectGitIgnore: () => true, getOutputFormat: () => 'text', getUsageStatisticsEnabled: () => false, + getRemoteAdminSettings: () => undefined, setTerminalBackground: vi.fn(), } as any); // eslint-disable-line @typescript-eslint/no-explicit-any @@ -953,6 +873,7 @@ describe('gemini.tsx main function kitty protocol', () => { getFileFilteringRespectGitIgnore: () => true, getOutputFormat: () => 'text', getUsageStatisticsEnabled: () => false, + getRemoteAdminSettings: () => undefined, setTerminalBackground: vi.fn(), } as any); // eslint-disable-line @typescript-eslint/no-explicit-any @@ -1030,6 +951,7 @@ describe('gemini.tsx main function kitty protocol', () => { getUsageStatisticsEnabled: () => false, refreshAuth: vi.fn(), setTerminalBackground: vi.fn(), + getRemoteAdminSettings: () => undefined, } as any); // eslint-disable-line @typescript-eslint/no-explicit-any vi.mock('./utils/readStdin.js', () => ({ @@ -1191,6 +1113,7 @@ describe('gemini.tsx main function exit codes', () => { getOutputFormat: () => 'text', getExtensions: () => [], getUsageStatisticsEnabled: () => false, + getRemoteAdminSettings: () => undefined, setTerminalBackground: vi.fn(), } as unknown as Config); vi.mocked(loadSettings).mockReturnValue({ @@ -1257,6 +1180,7 @@ describe('gemini.tsx main function exit codes', () => { getExtensions: () => [], getUsageStatisticsEnabled: () => false, setTerminalBackground: vi.fn(), + getRemoteAdminSettings: () => undefined, } as unknown as Config); vi.mocked(loadSettings).mockReturnValue({ merged: { security: { auth: {} }, ui: {} }, diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index d75f509dd2e..5dac29630bd 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -375,6 +375,51 @@ export async function main() { } } + const partialConfig = await loadCliConfig(settings.merged, sessionId, argv, { + projectHooks: settings.workspace.settings.hooks, + }); + + // Refresh auth to fetch remote admin settings from CCPA and before entering + // the sandbox because the sandbox will interfere with the Oauth2 web + // redirect. + if ( + settings.merged.security?.auth?.selectedType && + !settings.merged.security?.auth?.useExternal + ) { + try { + if (partialConfig.isInteractive()) { + const err = validateAuthMethod( + settings.merged.security.auth.selectedType, + ); + if (err) { + throw new Error(err); + } + + await partialConfig.refreshAuth( + settings.merged.security.auth.selectedType, + ); + } else { + const authType = await validateNonInteractiveAuth( + settings.merged.security?.auth?.selectedType, + settings.merged.security?.auth?.useExternal, + partialConfig, + settings, + ); + await partialConfig.refreshAuth(authType); + } + } catch (err) { + debugLogger.error('Error authenticating:', err); + await runExitCleanup(); + process.exit(ExitCodes.FATAL_AUTHENTICATION_ERROR); + } + } + + const remoteAdminSettings = partialConfig.getRemoteAdminSettings(); + // Set remote admin settings if returned from CCPA. + if (remoteAdminSettings) { + settings.setRemoteAdminSettings(remoteAdminSettings); + } + // hop into sandbox if we are outside and sandboxing is enabled if (!process.env['SANDBOX']) { const memoryArgs = settings.merged.advanced?.autoConfigureMemory @@ -388,45 +433,6 @@ export async function main() { // another way to decouple refreshAuth from requiring a config. if (sandboxConfig) { - const partialConfig = await loadCliConfig( - settings.merged, - sessionId, - argv, - { projectHooks: settings.workspace.settings.hooks }, - ); - - if ( - settings.merged.security?.auth?.selectedType && - !settings.merged.security?.auth?.useExternal - ) { - try { - if (partialConfig.isInteractive()) { - // Validate authentication here because the sandbox will interfere with the Oauth2 web redirect. - const err = validateAuthMethod( - settings.merged.security.auth.selectedType, - ); - if (err) { - throw new Error(err); - } - - await partialConfig.refreshAuth( - settings.merged.security.auth.selectedType, - ); - } else { - const authType = await validateNonInteractiveAuth( - settings.merged.security?.auth?.selectedType, - settings.merged.security?.auth?.useExternal, - partialConfig, - settings, - ); - await partialConfig.refreshAuth(authType); - } - } catch (err) { - debugLogger.error('Error authenticating:', err); - await runExitCleanup(); - process.exit(ExitCodes.FATAL_AUTHENTICATION_ERROR); - } - } let stdinData = ''; if (!process.stdin.isTTY) { stdinData = await readStdin(); diff --git a/packages/cli/src/gemini_cleanup.test.tsx b/packages/cli/src/gemini_cleanup.test.tsx index e198d3e887e..ec1341a7689 100644 --- a/packages/cli/src/gemini_cleanup.test.tsx +++ b/packages/cli/src/gemini_cleanup.test.tsx @@ -215,6 +215,7 @@ describe('gemini.tsx main function cleanup', () => { getUsageStatisticsEnabled: vi.fn(() => false), setTerminalBackground: vi.fn(), refreshAuth: vi.fn(), + getRemoteAdminSettings: vi.fn(() => undefined), } as any); // eslint-disable-line @typescript-eslint/no-explicit-any try {