Skip to content

Commit ea84485

Browse files
skal88scidominojacob314
authored
feat(extension): resolve environment variables in extension configuration (google-gemini#7213)
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com> Co-authored-by: Jacob Richman <jacob314@gmail.com>
1 parent 3529595 commit ea84485

File tree

5 files changed

+509
-43
lines changed

5 files changed

+509
-43
lines changed

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

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,100 @@ describe('loadExtensions', () => {
216216
);
217217
expect(loadedConfig.mcpServers?.['test-server'].cwd).toBe(expectedCwd);
218218
});
219+
220+
it('should resolve environment variables in extension configuration', () => {
221+
process.env.TEST_API_KEY = 'test-api-key-123';
222+
process.env.TEST_DB_URL = 'postgresql://localhost:5432/testdb';
223+
224+
try {
225+
const workspaceExtensionsDir = path.join(
226+
tempWorkspaceDir,
227+
EXTENSIONS_DIRECTORY_NAME,
228+
);
229+
fs.mkdirSync(workspaceExtensionsDir, { recursive: true });
230+
231+
const extDir = path.join(workspaceExtensionsDir, 'test-extension');
232+
fs.mkdirSync(extDir);
233+
234+
// Write config to a separate file for clarity and good practices
235+
const configPath = path.join(extDir, EXTENSIONS_CONFIG_FILENAME);
236+
const extensionConfig = {
237+
name: 'test-extension',
238+
version: '1.0.0',
239+
mcpServers: {
240+
'test-server': {
241+
command: 'node',
242+
args: ['server.js'],
243+
env: {
244+
API_KEY: '$TEST_API_KEY',
245+
DATABASE_URL: '${TEST_DB_URL}',
246+
STATIC_VALUE: 'no-substitution',
247+
},
248+
},
249+
},
250+
};
251+
fs.writeFileSync(configPath, JSON.stringify(extensionConfig));
252+
253+
const extensions = loadExtensions(tempWorkspaceDir);
254+
255+
expect(extensions).toHaveLength(1);
256+
const extension = extensions[0];
257+
expect(extension.config.name).toBe('test-extension');
258+
expect(extension.config.mcpServers).toBeDefined();
259+
260+
const serverConfig = extension.config.mcpServers?.['test-server'];
261+
expect(serverConfig).toBeDefined();
262+
expect(serverConfig?.env).toBeDefined();
263+
expect(serverConfig?.env?.API_KEY).toBe('test-api-key-123');
264+
expect(serverConfig?.env?.DATABASE_URL).toBe(
265+
'postgresql://localhost:5432/testdb',
266+
);
267+
expect(serverConfig?.env?.STATIC_VALUE).toBe('no-substitution');
268+
} finally {
269+
delete process.env.TEST_API_KEY;
270+
delete process.env.TEST_DB_URL;
271+
}
272+
});
273+
274+
it('should handle missing environment variables gracefully', () => {
275+
const workspaceExtensionsDir = path.join(
276+
tempWorkspaceDir,
277+
EXTENSIONS_DIRECTORY_NAME,
278+
);
279+
fs.mkdirSync(workspaceExtensionsDir, { recursive: true });
280+
281+
const extDir = path.join(workspaceExtensionsDir, 'test-extension');
282+
fs.mkdirSync(extDir);
283+
284+
const extensionConfig = {
285+
name: 'test-extension',
286+
version: '1.0.0',
287+
mcpServers: {
288+
'test-server': {
289+
command: 'node',
290+
args: ['server.js'],
291+
env: {
292+
MISSING_VAR: '$UNDEFINED_ENV_VAR',
293+
MISSING_VAR_BRACES: '${ALSO_UNDEFINED}',
294+
},
295+
},
296+
},
297+
};
298+
299+
fs.writeFileSync(
300+
path.join(extDir, EXTENSIONS_CONFIG_FILENAME),
301+
JSON.stringify(extensionConfig),
302+
);
303+
304+
const extensions = loadExtensions(tempWorkspaceDir);
305+
306+
expect(extensions).toHaveLength(1);
307+
const extension = extensions[0];
308+
const serverConfig = extension.config.mcpServers!['test-server'];
309+
expect(serverConfig.env).toBeDefined();
310+
expect(serverConfig.env!.MISSING_VAR).toBe('$UNDEFINED_ENV_VAR');
311+
expect(serverConfig.env!.MISSING_VAR_BRACES).toBe('${ALSO_UNDEFINED}');
312+
});
219313
});
220314

221315
describe('annotateActiveExtensions', () => {

packages/cli/src/config/extension.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { SettingScope, loadSettings } from '../config/settings.js';
1717
import { getErrorMessage } from '../utils/errors.js';
1818
import { recursivelyHydrateStrings } from './extensions/variables.js';
1919
import { isWorkspaceTrusted } from './trustedFolders.js';
20+
import { resolveEnvVarsInObject } from '../utils/envVarResolver.js';
2021

2122
export const EXTENSIONS_DIRECTORY_NAME = path.join(GEMINI_DIR, 'extensions');
2223

@@ -184,7 +185,7 @@ export function loadExtension(extensionDir: string): Extension | null {
184185

185186
try {
186187
const configContent = fs.readFileSync(configFilePath, 'utf-8');
187-
const config = recursivelyHydrateStrings(JSON.parse(configContent), {
188+
let config = recursivelyHydrateStrings(JSON.parse(configContent), {
188189
extensionPath: extensionDir,
189190
'/': path.sep,
190191
pathSeparator: path.sep,
@@ -196,6 +197,8 @@ export function loadExtension(extensionDir: string): Extension | null {
196197
return null;
197198
}
198199

200+
config = resolveEnvVarsInObject(config);
201+
199202
const contextFiles = getContextFileNames(config)
200203
.map((contextFileName) => path.join(extensionDir, contextFileName))
201204
.filter((contextFilePath) => fs.existsSync(contextFilePath));

packages/cli/src/config/settings.ts

Lines changed: 2 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import * as fs from 'node:fs';
88
import * as path from 'node:path';
99
import { homedir, platform } from 'node:os';
1010
import * as dotenv from 'dotenv';
11+
import process from 'node:process';
1112
import {
1213
GEMINI_CONFIG_DIR as GEMINI_DIR,
1314
getErrorMessage,
@@ -18,6 +19,7 @@ import { DefaultLight } from '../ui/themes/default-light.js';
1819
import { DefaultDark } from '../ui/themes/default.js';
1920
import { isWorkspaceTrusted } from './trustedFolders.js';
2021
import type { Settings, MemoryImportFormat } from './settingsSchema.js';
22+
import { resolveEnvVarsInObject } from '../utils/envVarResolver.js';
2123
import { mergeWith } from 'lodash-es';
2224

2325
export type { Settings, MemoryImportFormat };
@@ -462,48 +464,6 @@ export class LoadedSettings {
462464
}
463465
}
464466

465-
function resolveEnvVarsInString(value: string): string {
466-
const envVarRegex = /\$(?:(\w+)|{([^}]+)})/g; // Find $VAR_NAME or ${VAR_NAME}
467-
return value.replace(envVarRegex, (match, varName1, varName2) => {
468-
const varName = varName1 || varName2;
469-
if (process && process.env && typeof process.env[varName] === 'string') {
470-
return process.env[varName]!;
471-
}
472-
return match;
473-
});
474-
}
475-
476-
function resolveEnvVarsInObject<T>(obj: T): T {
477-
if (
478-
obj === null ||
479-
obj === undefined ||
480-
typeof obj === 'boolean' ||
481-
typeof obj === 'number'
482-
) {
483-
return obj;
484-
}
485-
486-
if (typeof obj === 'string') {
487-
return resolveEnvVarsInString(obj) as unknown as T;
488-
}
489-
490-
if (Array.isArray(obj)) {
491-
return obj.map((item) => resolveEnvVarsInObject(item)) as unknown as T;
492-
}
493-
494-
if (typeof obj === 'object') {
495-
const newObj = { ...obj } as T;
496-
for (const key in newObj) {
497-
if (Object.prototype.hasOwnProperty.call(newObj, key)) {
498-
newObj[key] = resolveEnvVarsInObject(newObj[key]);
499-
}
500-
}
501-
return newObj;
502-
}
503-
504-
return obj;
505-
}
506-
507467
function findEnvFile(startDir: string): string | null {
508468
let currentDir = path.resolve(startDir);
509469
while (true) {

0 commit comments

Comments
 (0)