Skip to content

Commit da50a1e

Browse files
Add system-wide settings config for administrators (google-gemini#3498)
Co-authored-by: Jack Wotherspoon <jackwoth@google.com>
1 parent 063481f commit da50a1e

File tree

9 files changed

+292
-31
lines changed

9 files changed

+292
-31
lines changed

docs/cli/configuration.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,19 +9,23 @@ Configuration is applied in the following order of precedence (lower numbers are
99
1. **Default values:** Hardcoded defaults within the application.
1010
2. **User settings file:** Global settings for the current user.
1111
3. **Project settings file:** Project-specific settings.
12-
4. **Environment variables:** System-wide or session-specific variables, potentially loaded from `.env` files.
13-
5. **Command-line arguments:** Values passed when launching the CLI.
12+
4. **System settings file:** System-wide settings.
13+
5. **Environment variables:** System-wide or session-specific variables, potentially loaded from `.env` files.
14+
6. **Command-line arguments:** Values passed when launching the CLI.
1415

15-
## The user settings file and project settings file
16+
## Settings files
1617

17-
Gemini CLI uses `settings.json` files for persistent configuration. There are two locations for these files:
18+
Gemini CLI uses `settings.json` files for persistent configuration. There are three locations for these files:
1819

1920
- **User settings file:**
2021
- **Location:** `~/.gemini/settings.json` (where `~` is your home directory).
2122
- **Scope:** Applies to all Gemini CLI sessions for the current user.
2223
- **Project settings file:**
2324
- **Location:** `.gemini/settings.json` within your project's root directory.
2425
- **Scope:** Applies only when running Gemini CLI from that specific project. Project settings override user settings.
26+
- **System settings file:**
27+
- **Location:** `/etc/gemini-cli/settings.json` (Linux), `C:\ProgramData\gemini-cli\settings.json` (Windows) or `/Library/Application Support/GeminiCli/settings.json` (macOS).
28+
- **Scope:** Applies to all Gemini CLI sessions on the system, for all users. System settings override user and project settings. May be useful for system administrators at enterprises to have controls over users' Gemini CLI setups.
2529

2630
**Note on environment variables in settings:** String values within your `settings.json` files can reference environment variables using either `$VAR_NAME` or `${VAR_NAME}` syntax. These variables will be automatically resolved when the settings are loaded. For example, if you have an environment variable `MY_API_TOKEN`, you could use it in `settings.json` like this: `"apiKey": "$MY_API_TOKEN"`.
2731

packages/cli/src/config/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,7 @@ async function parseArguments(): Promise<CliArgs> {
168168
type: 'boolean',
169169
description: 'List all available extensions and exit.',
170170
})
171+
171172
.version(await getCliVersion()) // This will enable the --version flag based on package.json
172173
.alias('v', 'version')
173174
.help()

packages/cli/src/config/settings.test.ts

Lines changed: 164 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ vi.mock('os', async (importOriginal) => {
1313
return {
1414
...actualOs,
1515
homedir: vi.fn(() => '/mock/home/user'),
16+
platform: vi.fn(() => 'linux'),
1617
};
1718
});
1819

@@ -45,6 +46,7 @@ import stripJsonComments from 'strip-json-comments'; // Will be mocked separatel
4546
import {
4647
loadSettings,
4748
USER_SETTINGS_PATH, // This IS the mocked path.
49+
SYSTEM_SETTINGS_PATH,
4850
SETTINGS_DIRECTORY_NAME, // This is from the original module, but used by the mock.
4951
SettingScope,
5052
} from './settings.js';
@@ -90,12 +92,41 @@ describe('Settings Loading and Merging', () => {
9092
describe('loadSettings', () => {
9193
it('should load empty settings if no files exist', () => {
9294
const settings = loadSettings(MOCK_WORKSPACE_DIR);
95+
expect(settings.system.settings).toEqual({});
9396
expect(settings.user.settings).toEqual({});
9497
expect(settings.workspace.settings).toEqual({});
9598
expect(settings.merged).toEqual({});
9699
expect(settings.errors.length).toBe(0);
97100
});
98101

102+
it('should load system settings if only system file exists', () => {
103+
(mockFsExistsSync as Mock).mockImplementation(
104+
(p: fs.PathLike) => p === SYSTEM_SETTINGS_PATH,
105+
);
106+
const systemSettingsContent = {
107+
theme: 'system-default',
108+
sandbox: false,
109+
};
110+
(fs.readFileSync as Mock).mockImplementation(
111+
(p: fs.PathOrFileDescriptor) => {
112+
if (p === SYSTEM_SETTINGS_PATH)
113+
return JSON.stringify(systemSettingsContent);
114+
return '{}';
115+
},
116+
);
117+
118+
const settings = loadSettings(MOCK_WORKSPACE_DIR);
119+
120+
expect(fs.readFileSync).toHaveBeenCalledWith(
121+
SYSTEM_SETTINGS_PATH,
122+
'utf-8',
123+
);
124+
expect(settings.system.settings).toEqual(systemSettingsContent);
125+
expect(settings.user.settings).toEqual({});
126+
expect(settings.workspace.settings).toEqual({});
127+
expect(settings.merged).toEqual(systemSettingsContent);
128+
});
129+
99130
it('should load user settings if only user file exists', () => {
100131
const expectedUserSettingsPath = USER_SETTINGS_PATH; // Use the path actually resolved by the (mocked) module
101132

@@ -187,6 +218,50 @@ describe('Settings Loading and Merging', () => {
187218
});
188219
});
189220

221+
it('should merge system, user and workspace settings, with system taking precedence over workspace, and workspace over user', () => {
222+
(mockFsExistsSync as Mock).mockReturnValue(true);
223+
const systemSettingsContent = {
224+
theme: 'system-theme',
225+
sandbox: false,
226+
telemetry: { enabled: false },
227+
};
228+
const userSettingsContent = {
229+
theme: 'dark',
230+
sandbox: true,
231+
contextFileName: 'USER_CONTEXT.md',
232+
};
233+
const workspaceSettingsContent = {
234+
sandbox: false,
235+
coreTools: ['tool1'],
236+
contextFileName: 'WORKSPACE_CONTEXT.md',
237+
};
238+
239+
(fs.readFileSync as Mock).mockImplementation(
240+
(p: fs.PathOrFileDescriptor) => {
241+
if (p === SYSTEM_SETTINGS_PATH)
242+
return JSON.stringify(systemSettingsContent);
243+
if (p === USER_SETTINGS_PATH)
244+
return JSON.stringify(userSettingsContent);
245+
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
246+
return JSON.stringify(workspaceSettingsContent);
247+
return '';
248+
},
249+
);
250+
251+
const settings = loadSettings(MOCK_WORKSPACE_DIR);
252+
253+
expect(settings.system.settings).toEqual(systemSettingsContent);
254+
expect(settings.user.settings).toEqual(userSettingsContent);
255+
expect(settings.workspace.settings).toEqual(workspaceSettingsContent);
256+
expect(settings.merged).toEqual({
257+
theme: 'system-theme',
258+
sandbox: false,
259+
telemetry: { enabled: false },
260+
coreTools: ['tool1'],
261+
contextFileName: 'WORKSPACE_CONTEXT.md',
262+
});
263+
});
264+
190265
it('should handle contextFileName correctly when only in user settings', () => {
191266
(mockFsExistsSync as Mock).mockImplementation(
192267
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
@@ -409,6 +484,50 @@ describe('Settings Loading and Merging', () => {
409484
delete process.env.WORKSPACE_ENDPOINT;
410485
});
411486

487+
it('should prioritize user env variables over workspace env variables if keys clash after resolution', () => {
488+
const userSettingsContent = { configValue: '$SHARED_VAR' };
489+
const workspaceSettingsContent = { configValue: '$SHARED_VAR' };
490+
491+
(mockFsExistsSync as Mock).mockReturnValue(true);
492+
const originalSharedVar = process.env.SHARED_VAR;
493+
// Temporarily delete to ensure a clean slate for the test's specific manipulations
494+
delete process.env.SHARED_VAR;
495+
496+
(fs.readFileSync as Mock).mockImplementation(
497+
(p: fs.PathOrFileDescriptor) => {
498+
if (p === USER_SETTINGS_PATH) {
499+
process.env.SHARED_VAR = 'user_value_for_user_read'; // Set for user settings read
500+
return JSON.stringify(userSettingsContent);
501+
}
502+
if (p === MOCK_WORKSPACE_SETTINGS_PATH) {
503+
process.env.SHARED_VAR = 'workspace_value_for_workspace_read'; // Set for workspace settings read
504+
return JSON.stringify(workspaceSettingsContent);
505+
}
506+
return '{}';
507+
},
508+
);
509+
510+
const settings = loadSettings(MOCK_WORKSPACE_DIR);
511+
512+
expect(settings.user.settings.configValue).toBe(
513+
'user_value_for_user_read',
514+
);
515+
expect(settings.workspace.settings.configValue).toBe(
516+
'workspace_value_for_workspace_read',
517+
);
518+
// Merged should take workspace's resolved value
519+
expect(settings.merged.configValue).toBe(
520+
'workspace_value_for_workspace_read',
521+
);
522+
523+
// Restore original environment variable state
524+
if (originalSharedVar !== undefined) {
525+
process.env.SHARED_VAR = originalSharedVar;
526+
} else {
527+
delete process.env.SHARED_VAR; // Ensure it's deleted if it wasn't there before
528+
}
529+
});
530+
412531
it('should prioritize workspace env variables over user env variables if keys clash after resolution', () => {
413532
const userSettingsContent = { configValue: '$SHARED_VAR' };
414533
const workspaceSettingsContent = { configValue: '$SHARED_VAR' };
@@ -453,6 +572,48 @@ describe('Settings Loading and Merging', () => {
453572
}
454573
});
455574

575+
it('should prioritize system env variables over workspace env variables if keys clash after resolution', () => {
576+
const workspaceSettingsContent = { configValue: '$SHARED_VAR' };
577+
const systemSettingsContent = { configValue: '$SHARED_VAR' };
578+
579+
(mockFsExistsSync as Mock).mockReturnValue(true);
580+
const originalSharedVar = process.env.SHARED_VAR;
581+
// Temporarily delete to ensure a clean slate for the test's specific manipulations
582+
delete process.env.SHARED_VAR;
583+
584+
(fs.readFileSync as Mock).mockImplementation(
585+
(p: fs.PathOrFileDescriptor) => {
586+
if (p === SYSTEM_SETTINGS_PATH) {
587+
process.env.SHARED_VAR = 'system_value_for_system_read'; // Set for system settings read
588+
return JSON.stringify(systemSettingsContent);
589+
}
590+
if (p === MOCK_WORKSPACE_SETTINGS_PATH) {
591+
process.env.SHARED_VAR = 'workspace_value_for_workspace_read'; // Set for workspace settings read
592+
return JSON.stringify(workspaceSettingsContent);
593+
}
594+
return '{}';
595+
},
596+
);
597+
598+
const settings = loadSettings(MOCK_WORKSPACE_DIR);
599+
600+
expect(settings.system.settings.configValue).toBe(
601+
'system_value_for_system_read',
602+
);
603+
expect(settings.workspace.settings.configValue).toBe(
604+
'workspace_value_for_workspace_read',
605+
);
606+
// Merged should take workspace's resolved value
607+
expect(settings.merged.configValue).toBe('system_value_for_system_read');
608+
609+
// Restore original environment variable state
610+
if (originalSharedVar !== undefined) {
611+
process.env.SHARED_VAR = originalSharedVar;
612+
} else {
613+
delete process.env.SHARED_VAR; // Ensure it's deleted if it wasn't there before
614+
}
615+
});
616+
456617
it('should leave unresolved environment variables as is', () => {
457618
const userSettingsContent = { apiKey: '$UNDEFINED_VAR' };
458619
(mockFsExistsSync as Mock).mockImplementation(
@@ -624,10 +785,10 @@ describe('Settings Loading and Merging', () => {
624785
'utf-8',
625786
);
626787

627-
// Workspace theme overrides user theme
628-
loadedSettings.setValue(SettingScope.Workspace, 'theme', 'ocean');
788+
// System theme overrides user and workspace themes
789+
loadedSettings.setValue(SettingScope.System, 'theme', 'ocean');
629790

630-
expect(loadedSettings.workspace.settings.theme).toBe('ocean');
791+
expect(loadedSettings.system.settings.theme).toBe('ocean');
631792
expect(loadedSettings.merged.theme).toBe('ocean');
632793
});
633794
});

packages/cli/src/config/settings.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
import * as fs from 'fs';
88
import * as path from 'path';
9-
import { homedir } from 'os';
9+
import { homedir, platform } from 'os';
1010
import * as dotenv from 'dotenv';
1111
import {
1212
MCPServerConfig,
@@ -24,9 +24,22 @@ export const SETTINGS_DIRECTORY_NAME = '.gemini';
2424
export const USER_SETTINGS_DIR = path.join(homedir(), SETTINGS_DIRECTORY_NAME);
2525
export const USER_SETTINGS_PATH = path.join(USER_SETTINGS_DIR, 'settings.json');
2626

27+
function getSystemSettingsPath(): string {
28+
if (platform() === 'darwin') {
29+
return '/Library/Application Support/GeminiCli/settings.json';
30+
} else if (platform() === 'win32') {
31+
return 'C:\\ProgramData\\gemini-cli\\settings.json';
32+
} else {
33+
return '/etc/gemini-cli/settings.json';
34+
}
35+
}
36+
37+
export const SYSTEM_SETTINGS_PATH = getSystemSettingsPath();
38+
2739
export enum SettingScope {
2840
User = 'User',
2941
Workspace = 'Workspace',
42+
System = 'System',
3043
}
3144

3245
export interface CheckpointingSettings {
@@ -81,16 +94,19 @@ export interface SettingsFile {
8194
}
8295
export class LoadedSettings {
8396
constructor(
97+
system: SettingsFile,
8498
user: SettingsFile,
8599
workspace: SettingsFile,
86100
errors: SettingsError[],
87101
) {
102+
this.system = system;
88103
this.user = user;
89104
this.workspace = workspace;
90105
this.errors = errors;
91106
this._merged = this.computeMergedSettings();
92107
}
93108

109+
readonly system: SettingsFile;
94110
readonly user: SettingsFile;
95111
readonly workspace: SettingsFile;
96112
readonly errors: SettingsError[];
@@ -105,6 +121,7 @@ export class LoadedSettings {
105121
return {
106122
...this.user.settings,
107123
...this.workspace.settings,
124+
...this.system.settings,
108125
};
109126
}
110127

@@ -114,6 +131,8 @@ export class LoadedSettings {
114131
return this.user;
115132
case SettingScope.Workspace:
116133
return this.workspace;
134+
case SettingScope.System:
135+
return this.system;
117136
default:
118137
throw new Error(`Invalid scope: ${scope}`);
119138
}
@@ -243,10 +262,27 @@ export function loadEnvironment(): void {
243262
*/
244263
export function loadSettings(workspaceDir: string): LoadedSettings {
245264
loadEnvironment();
265+
let systemSettings: Settings = {};
246266
let userSettings: Settings = {};
247267
let workspaceSettings: Settings = {};
248268
const settingsErrors: SettingsError[] = [];
249269

270+
// Load system settings
271+
try {
272+
if (fs.existsSync(SYSTEM_SETTINGS_PATH)) {
273+
const systemContent = fs.readFileSync(SYSTEM_SETTINGS_PATH, 'utf-8');
274+
const parsedSystemSettings = JSON.parse(
275+
stripJsonComments(systemContent),
276+
) as Settings;
277+
systemSettings = resolveEnvVarsInObject(parsedSystemSettings);
278+
}
279+
} catch (error: unknown) {
280+
settingsErrors.push({
281+
message: getErrorMessage(error),
282+
path: SYSTEM_SETTINGS_PATH,
283+
});
284+
}
285+
250286
// Load user settings
251287
try {
252288
if (fs.existsSync(USER_SETTINGS_PATH)) {
@@ -300,6 +336,10 @@ export function loadSettings(workspaceDir: string): LoadedSettings {
300336
}
301337

302338
return new LoadedSettings(
339+
{
340+
path: SYSTEM_SETTINGS_PATH,
341+
settings: systemSettings,
342+
},
303343
{
304344
path: USER_SETTINGS_PATH,
305345
settings: userSettings,

packages/cli/src/gemini.test.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,12 @@ describe('gemini.tsx main function', () => {
112112
path: '/workspace/.gemini/settings.json',
113113
settings: {},
114114
};
115+
const systemSettingsFile: SettingsFile = {
116+
path: '/system/settings.json',
117+
settings: {},
118+
};
115119
const mockLoadedSettings = new LoadedSettings(
120+
systemSettingsFile,
116121
userSettingsFile,
117122
workspaceSettingsFile,
118123
[settingsError],

0 commit comments

Comments
 (0)