feat: Enable environment variable substitution in settings
This commit introduces the ability to use system environment variables within the settings files (e.g., `settings.json`). Users can now reference environment variables using the `${VAR_NAME}` syntax. This enhancement improves security and flexibility, particularly for configurations like MCP server settings, which often require sensitive tokens. Previously, to configure an MCP server, a token might be directly embedded: ```json "mcpServers": { "github": { "env": { "GITHUB_PERSONAL_ACCESS_TOKEN": "pat_abc123" } // ... } } ``` With this change, the same configuration can securely reference an environment variable: ```json "mcpServers": { "github": { "env": { "GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_PERSONAL_ACCESS_TOKEN}" } // ... } } ``` This allows users to avoid storing secrets directly in configuration files.
This commit is contained in:
parent
9ad615c2a4
commit
4e9d365407
|
@ -23,6 +23,8 @@ The Gemini CLI uses `settings.json` files for persistent configuration. There ar
|
||||||
- **Location:** `.gemini/settings.json` within your project's root directory.
|
- **Location:** `.gemini/settings.json` within your project's root directory.
|
||||||
- **Scope:** Applies only when running Gemini CLI from that specific project. Project settings override User settings.
|
- **Scope:** Applies only when running Gemini CLI from that specific project. Project settings override User settings.
|
||||||
|
|
||||||
|
**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"`.
|
||||||
|
|
||||||
### The `.gemini` Directory in Your Project
|
### The `.gemini` Directory in Your Project
|
||||||
|
|
||||||
When you create a `.gemini/settings.json` file for project-specific settings, or when the system needs to store project-specific information, this `.gemini` directory is used.
|
When you create a `.gemini/settings.json` file for project-specific settings, or when the system needs to store project-specific information, this `.gemini` directory is used.
|
||||||
|
@ -145,7 +147,14 @@ When you create a `.gemini/settings.json` file for project-specific settings, or
|
||||||
"command": "node",
|
"command": "node",
|
||||||
"args": ["mcp_server.js"],
|
"args": ["mcp_server.js"],
|
||||||
"cwd": "./mcp_tools/node"
|
"cwd": "./mcp_tools/node"
|
||||||
|
},
|
||||||
|
"myDockerServer": {
|
||||||
|
"command": "docker",
|
||||||
|
"args": ["run", "i", "--rm", "-e", "API_KEY", "ghcr.io/foo/bar"],
|
||||||
|
"env": {
|
||||||
|
"API_KEY": "$MY_API_TOKEN"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
- **`mcpServerCommand`** (string, advanced, **deprecated**):
|
- **`mcpServerCommand`** (string, advanced, **deprecated**):
|
||||||
|
|
|
@ -322,6 +322,166 @@ describe('Settings Loading and Merging', () => {
|
||||||
|
|
||||||
consoleErrorSpy.mockRestore();
|
consoleErrorSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should resolve environment variables in user settings', () => {
|
||||||
|
process.env.TEST_API_KEY = 'user_api_key_from_env';
|
||||||
|
const userSettingsContent = {
|
||||||
|
apiKey: '$TEST_API_KEY',
|
||||||
|
someUrl: 'https://test.com/${TEST_API_KEY}',
|
||||||
|
};
|
||||||
|
(mockFsExistsSync as Mock).mockImplementation(
|
||||||
|
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
|
||||||
|
);
|
||||||
|
(fs.readFileSync as Mock).mockImplementation(
|
||||||
|
(p: fs.PathOrFileDescriptor) => {
|
||||||
|
if (p === USER_SETTINGS_PATH)
|
||||||
|
return JSON.stringify(userSettingsContent);
|
||||||
|
return '{}';
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||||
|
expect(settings.user.settings.apiKey).toBe('user_api_key_from_env');
|
||||||
|
expect(settings.user.settings.someUrl).toBe(
|
||||||
|
'https://test.com/user_api_key_from_env',
|
||||||
|
);
|
||||||
|
expect(settings.merged.apiKey).toBe('user_api_key_from_env');
|
||||||
|
delete process.env.TEST_API_KEY;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should resolve environment variables in workspace settings', () => {
|
||||||
|
process.env.WORKSPACE_ENDPOINT = 'workspace_endpoint_from_env';
|
||||||
|
const workspaceSettingsContent = {
|
||||||
|
endpoint: '${WORKSPACE_ENDPOINT}/api',
|
||||||
|
nested: { value: '$WORKSPACE_ENDPOINT' },
|
||||||
|
};
|
||||||
|
(mockFsExistsSync as Mock).mockImplementation(
|
||||||
|
(p: fs.PathLike) => p === MOCK_WORKSPACE_SETTINGS_PATH,
|
||||||
|
);
|
||||||
|
(fs.readFileSync as Mock).mockImplementation(
|
||||||
|
(p: fs.PathOrFileDescriptor) => {
|
||||||
|
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
|
||||||
|
return JSON.stringify(workspaceSettingsContent);
|
||||||
|
return '{}';
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||||
|
expect(settings.workspace.settings.endpoint).toBe(
|
||||||
|
'workspace_endpoint_from_env/api',
|
||||||
|
);
|
||||||
|
expect(settings.workspace.settings.nested.value).toBe(
|
||||||
|
'workspace_endpoint_from_env',
|
||||||
|
);
|
||||||
|
expect(settings.merged.endpoint).toBe('workspace_endpoint_from_env/api');
|
||||||
|
delete process.env.WORKSPACE_ENDPOINT;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prioritize workspace env variables over user env variables if keys clash after resolution', () => {
|
||||||
|
const userSettingsContent = { configValue: '$SHARED_VAR' };
|
||||||
|
const workspaceSettingsContent = { configValue: '$SHARED_VAR' };
|
||||||
|
|
||||||
|
(mockFsExistsSync as Mock).mockReturnValue(true);
|
||||||
|
const originalSharedVar = process.env.SHARED_VAR;
|
||||||
|
// Temporarily delete to ensure a clean slate for the test's specific manipulations
|
||||||
|
delete process.env.SHARED_VAR;
|
||||||
|
|
||||||
|
(fs.readFileSync as Mock).mockImplementation(
|
||||||
|
(p: fs.PathOrFileDescriptor) => {
|
||||||
|
if (p === USER_SETTINGS_PATH) {
|
||||||
|
process.env.SHARED_VAR = 'user_value_for_user_read'; // Set for user settings read
|
||||||
|
return JSON.stringify(userSettingsContent);
|
||||||
|
}
|
||||||
|
if (p === MOCK_WORKSPACE_SETTINGS_PATH) {
|
||||||
|
process.env.SHARED_VAR = 'workspace_value_for_workspace_read'; // Set for workspace settings read
|
||||||
|
return JSON.stringify(workspaceSettingsContent);
|
||||||
|
}
|
||||||
|
return '{}';
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||||
|
|
||||||
|
expect(settings.user.settings.configValue).toBe(
|
||||||
|
'user_value_for_user_read',
|
||||||
|
);
|
||||||
|
expect(settings.workspace.settings.configValue).toBe(
|
||||||
|
'workspace_value_for_workspace_read',
|
||||||
|
);
|
||||||
|
// Merged should take workspace's resolved value
|
||||||
|
expect(settings.merged.configValue).toBe(
|
||||||
|
'workspace_value_for_workspace_read',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Restore original environment variable state
|
||||||
|
if (originalSharedVar !== undefined) {
|
||||||
|
process.env.SHARED_VAR = originalSharedVar;
|
||||||
|
} else {
|
||||||
|
delete process.env.SHARED_VAR; // Ensure it's deleted if it wasn't there before
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should leave unresolved environment variables as is', () => {
|
||||||
|
const userSettingsContent = { apiKey: '$UNDEFINED_VAR' };
|
||||||
|
(mockFsExistsSync as Mock).mockImplementation(
|
||||||
|
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
|
||||||
|
);
|
||||||
|
(fs.readFileSync as Mock).mockImplementation(
|
||||||
|
(p: fs.PathOrFileDescriptor) => {
|
||||||
|
if (p === USER_SETTINGS_PATH)
|
||||||
|
return JSON.stringify(userSettingsContent);
|
||||||
|
return '{}';
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||||
|
expect(settings.user.settings.apiKey).toBe('$UNDEFINED_VAR');
|
||||||
|
expect(settings.merged.apiKey).toBe('$UNDEFINED_VAR');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should resolve multiple environment variables in a single string', () => {
|
||||||
|
process.env.VAR_A = 'valueA';
|
||||||
|
process.env.VAR_B = 'valueB';
|
||||||
|
const userSettingsContent = { path: '/path/$VAR_A/${VAR_B}/end' };
|
||||||
|
(mockFsExistsSync as Mock).mockImplementation(
|
||||||
|
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
|
||||||
|
);
|
||||||
|
(fs.readFileSync as Mock).mockImplementation(
|
||||||
|
(p: fs.PathOrFileDescriptor) => {
|
||||||
|
if (p === USER_SETTINGS_PATH)
|
||||||
|
return JSON.stringify(userSettingsContent);
|
||||||
|
return '{}';
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||||
|
expect(settings.user.settings.path).toBe('/path/valueA/valueB/end');
|
||||||
|
delete process.env.VAR_A;
|
||||||
|
delete process.env.VAR_B;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should resolve environment variables in arrays', () => {
|
||||||
|
process.env.ITEM_1 = 'item1_env';
|
||||||
|
process.env.ITEM_2 = 'item2_env';
|
||||||
|
const userSettingsContent = { list: ['$ITEM_1', '${ITEM_2}', 'literal'] };
|
||||||
|
(mockFsExistsSync as Mock).mockImplementation(
|
||||||
|
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
|
||||||
|
);
|
||||||
|
(fs.readFileSync as Mock).mockImplementation(
|
||||||
|
(p: fs.PathOrFileDescriptor) => {
|
||||||
|
if (p === USER_SETTINGS_PATH)
|
||||||
|
return JSON.stringify(userSettingsContent);
|
||||||
|
return '{}';
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||||
|
expect(settings.user.settings.list).toEqual([
|
||||||
|
'item1_env',
|
||||||
|
'item2_env',
|
||||||
|
'literal',
|
||||||
|
]);
|
||||||
|
delete process.env.ITEM_1;
|
||||||
|
delete process.env.ITEM_2;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('LoadedSettings class', () => {
|
describe('LoadedSettings class', () => {
|
||||||
|
|
|
@ -98,6 +98,39 @@ export class LoadedSettings {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveEnvVarsInString(value: string): string {
|
||||||
|
const envVarRegex = /\$(?:(\w+)|{([^}]+)})/g; // Find $VAR_NAME or ${VAR_NAME}
|
||||||
|
return value.replace(envVarRegex, (match, varName1, varName2) => {
|
||||||
|
const varName = varName1 || varName2;
|
||||||
|
if (process && process.env && typeof process.env[varName] === 'string') {
|
||||||
|
return process.env[varName]!;
|
||||||
|
}
|
||||||
|
return match;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveEnvVarsInObject<T>(obj: T): T {
|
||||||
|
if (typeof obj === 'string') {
|
||||||
|
return resolveEnvVarsInString(obj) as unknown as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(obj)) {
|
||||||
|
return obj.map((item) => resolveEnvVarsInObject(item)) as unknown as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (obj && typeof obj === 'object') {
|
||||||
|
const newObj = { ...obj } as T;
|
||||||
|
for (const key in newObj) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(newObj, key)) {
|
||||||
|
newObj[key] = resolveEnvVarsInObject(newObj[key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return newObj;
|
||||||
|
}
|
||||||
|
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads settings from user and workspace directories.
|
* Loads settings from user and workspace directories.
|
||||||
* Project settings override user settings.
|
* Project settings override user settings.
|
||||||
|
@ -110,7 +143,10 @@ export function loadSettings(workspaceDir: string): LoadedSettings {
|
||||||
try {
|
try {
|
||||||
if (fs.existsSync(USER_SETTINGS_PATH)) {
|
if (fs.existsSync(USER_SETTINGS_PATH)) {
|
||||||
const userContent = fs.readFileSync(USER_SETTINGS_PATH, 'utf-8');
|
const userContent = fs.readFileSync(USER_SETTINGS_PATH, 'utf-8');
|
||||||
userSettings = JSON.parse(stripJsonComments(userContent)) as Settings;
|
const parsedUserSettings = JSON.parse(
|
||||||
|
stripJsonComments(userContent),
|
||||||
|
) as Settings;
|
||||||
|
userSettings = resolveEnvVarsInObject(parsedUserSettings);
|
||||||
// Support legacy theme names
|
// Support legacy theme names
|
||||||
if (userSettings.theme && userSettings.theme === 'VS') {
|
if (userSettings.theme && userSettings.theme === 'VS') {
|
||||||
userSettings.theme = DefaultLight.name;
|
userSettings.theme = DefaultLight.name;
|
||||||
|
@ -132,9 +168,10 @@ export function loadSettings(workspaceDir: string): LoadedSettings {
|
||||||
try {
|
try {
|
||||||
if (fs.existsSync(workspaceSettingsPath)) {
|
if (fs.existsSync(workspaceSettingsPath)) {
|
||||||
const projectContent = fs.readFileSync(workspaceSettingsPath, 'utf-8');
|
const projectContent = fs.readFileSync(workspaceSettingsPath, 'utf-8');
|
||||||
workspaceSettings = JSON.parse(
|
const parsedWorkspaceSettings = JSON.parse(
|
||||||
stripJsonComments(projectContent),
|
stripJsonComments(projectContent),
|
||||||
) as Settings;
|
) as Settings;
|
||||||
|
workspaceSettings = resolveEnvVarsInObject(parsedWorkspaceSettings);
|
||||||
if (workspaceSettings.theme && workspaceSettings.theme === 'VS') {
|
if (workspaceSettings.theme && workspaceSettings.theme === 'VS') {
|
||||||
workspaceSettings.theme = DefaultLight.name;
|
workspaceSettings.theme = DefaultLight.name;
|
||||||
} else if (
|
} else if (
|
||||||
|
|
Loading…
Reference in New Issue