From 4e9d365407564e0f440bf4645607aa47a1d16bca Mon Sep 17 00:00:00 2001 From: jerop Date: Fri, 6 Jun 2025 13:54:59 +0000 Subject: [PATCH] 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. --- docs/cli/configuration.md | 11 +- packages/cli/src/config/settings.test.ts | 160 +++++++++++++++++++++++ packages/cli/src/config/settings.ts | 41 +++++- 3 files changed, 209 insertions(+), 3 deletions(-) diff --git a/docs/cli/configuration.md b/docs/cli/configuration.md index 4503ed81..89efe21d 100644 --- a/docs/cli/configuration.md +++ b/docs/cli/configuration.md @@ -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. - **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 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", "args": ["mcp_server.js"], "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**): diff --git a/packages/cli/src/config/settings.test.ts b/packages/cli/src/config/settings.test.ts index c6943dbe..350c5d33 100644 --- a/packages/cli/src/config/settings.test.ts +++ b/packages/cli/src/config/settings.test.ts @@ -322,6 +322,166 @@ describe('Settings Loading and Merging', () => { 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', () => { diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index db5dabb6..8205a018 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -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(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. * Project settings override user settings. @@ -110,7 +143,10 @@ export function loadSettings(workspaceDir: string): LoadedSettings { try { if (fs.existsSync(USER_SETTINGS_PATH)) { 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 if (userSettings.theme && userSettings.theme === 'VS') { userSettings.theme = DefaultLight.name; @@ -132,9 +168,10 @@ export function loadSettings(workspaceDir: string): LoadedSettings { try { if (fs.existsSync(workspaceSettingsPath)) { const projectContent = fs.readFileSync(workspaceSettingsPath, 'utf-8'); - workspaceSettings = JSON.parse( + const parsedWorkspaceSettings = JSON.parse( stripJsonComments(projectContent), ) as Settings; + workspaceSettings = resolveEnvVarsInObject(parsedWorkspaceSettings); if (workspaceSettings.theme && workspaceSettings.theme === 'VS') { workspaceSettings.theme = DefaultLight.name; } else if (