feat(extension): resolve environment variables in extension configuration (#7213)

Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
Co-authored-by: Jacob Richman <jacob314@gmail.com>
This commit is contained in:
Albert Mulà
2025-08-29 19:53:39 +02:00
committed by GitHub
parent 3529595e6c
commit ea844857a2
5 changed files with 509 additions and 43 deletions

View File

@@ -216,6 +216,100 @@ describe('loadExtensions', () => {
);
expect(loadedConfig.mcpServers?.['test-server'].cwd).toBe(expectedCwd);
});
it('should resolve environment variables in extension configuration', () => {
process.env.TEST_API_KEY = 'test-api-key-123';
process.env.TEST_DB_URL = 'postgresql://localhost:5432/testdb';
try {
const workspaceExtensionsDir = path.join(
tempWorkspaceDir,
EXTENSIONS_DIRECTORY_NAME,
);
fs.mkdirSync(workspaceExtensionsDir, { recursive: true });
const extDir = path.join(workspaceExtensionsDir, 'test-extension');
fs.mkdirSync(extDir);
// Write config to a separate file for clarity and good practices
const configPath = path.join(extDir, EXTENSIONS_CONFIG_FILENAME);
const extensionConfig = {
name: 'test-extension',
version: '1.0.0',
mcpServers: {
'test-server': {
command: 'node',
args: ['server.js'],
env: {
API_KEY: '$TEST_API_KEY',
DATABASE_URL: '${TEST_DB_URL}',
STATIC_VALUE: 'no-substitution',
},
},
},
};
fs.writeFileSync(configPath, JSON.stringify(extensionConfig));
const extensions = loadExtensions(tempWorkspaceDir);
expect(extensions).toHaveLength(1);
const extension = extensions[0];
expect(extension.config.name).toBe('test-extension');
expect(extension.config.mcpServers).toBeDefined();
const serverConfig = extension.config.mcpServers?.['test-server'];
expect(serverConfig).toBeDefined();
expect(serverConfig?.env).toBeDefined();
expect(serverConfig?.env?.API_KEY).toBe('test-api-key-123');
expect(serverConfig?.env?.DATABASE_URL).toBe(
'postgresql://localhost:5432/testdb',
);
expect(serverConfig?.env?.STATIC_VALUE).toBe('no-substitution');
} finally {
delete process.env.TEST_API_KEY;
delete process.env.TEST_DB_URL;
}
});
it('should handle missing environment variables gracefully', () => {
const workspaceExtensionsDir = path.join(
tempWorkspaceDir,
EXTENSIONS_DIRECTORY_NAME,
);
fs.mkdirSync(workspaceExtensionsDir, { recursive: true });
const extDir = path.join(workspaceExtensionsDir, 'test-extension');
fs.mkdirSync(extDir);
const extensionConfig = {
name: 'test-extension',
version: '1.0.0',
mcpServers: {
'test-server': {
command: 'node',
args: ['server.js'],
env: {
MISSING_VAR: '$UNDEFINED_ENV_VAR',
MISSING_VAR_BRACES: '${ALSO_UNDEFINED}',
},
},
},
};
fs.writeFileSync(
path.join(extDir, EXTENSIONS_CONFIG_FILENAME),
JSON.stringify(extensionConfig),
);
const extensions = loadExtensions(tempWorkspaceDir);
expect(extensions).toHaveLength(1);
const extension = extensions[0];
const serverConfig = extension.config.mcpServers!['test-server'];
expect(serverConfig.env).toBeDefined();
expect(serverConfig.env!.MISSING_VAR).toBe('$UNDEFINED_ENV_VAR');
expect(serverConfig.env!.MISSING_VAR_BRACES).toBe('${ALSO_UNDEFINED}');
});
});
describe('annotateActiveExtensions', () => {

View File

@@ -17,6 +17,7 @@ import { SettingScope, loadSettings } from '../config/settings.js';
import { getErrorMessage } from '../utils/errors.js';
import { recursivelyHydrateStrings } from './extensions/variables.js';
import { isWorkspaceTrusted } from './trustedFolders.js';
import { resolveEnvVarsInObject } from '../utils/envVarResolver.js';
export const EXTENSIONS_DIRECTORY_NAME = path.join(GEMINI_DIR, 'extensions');
@@ -184,7 +185,7 @@ export function loadExtension(extensionDir: string): Extension | null {
try {
const configContent = fs.readFileSync(configFilePath, 'utf-8');
const config = recursivelyHydrateStrings(JSON.parse(configContent), {
let config = recursivelyHydrateStrings(JSON.parse(configContent), {
extensionPath: extensionDir,
'/': path.sep,
pathSeparator: path.sep,
@@ -196,6 +197,8 @@ export function loadExtension(extensionDir: string): Extension | null {
return null;
}
config = resolveEnvVarsInObject(config);
const contextFiles = getContextFileNames(config)
.map((contextFileName) => path.join(extensionDir, contextFileName))
.filter((contextFilePath) => fs.existsSync(contextFilePath));

View File

@@ -8,6 +8,7 @@ import * as fs from 'node:fs';
import * as path from 'node:path';
import { homedir, platform } from 'node:os';
import * as dotenv from 'dotenv';
import process from 'node:process';
import {
GEMINI_CONFIG_DIR as GEMINI_DIR,
getErrorMessage,
@@ -18,6 +19,7 @@ import { DefaultLight } from '../ui/themes/default-light.js';
import { DefaultDark } from '../ui/themes/default.js';
import { isWorkspaceTrusted } from './trustedFolders.js';
import type { Settings, MemoryImportFormat } from './settingsSchema.js';
import { resolveEnvVarsInObject } from '../utils/envVarResolver.js';
import { mergeWith } from 'lodash-es';
export type { Settings, MemoryImportFormat };
@@ -462,48 +464,6 @@ 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 (
obj === null ||
obj === undefined ||
typeof obj === 'boolean' ||
typeof obj === 'number'
) {
return obj;
}
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 (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;
}
function findEnvFile(startDir: string): string | null {
let currentDir = path.resolve(startDir);
while (true) {