feat: apply remote admin settings (no-op) (#16106)

This commit is contained in:
Shreya Keshive
2026-01-09 17:04:57 -05:00
committed by GitHub
parent e04a5f0cb0
commit d74bf9ef2f
5 changed files with 281 additions and 125 deletions

View File

@@ -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 = {

View File

@@ -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<Settings> | 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 {

View File

@@ -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: {} },

View File

@@ -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();

View File

@@ -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 {