diff --git a/packages/cli/src/config/extension-manager.ts b/packages/cli/src/config/extension-manager.ts index 179959d83e..365bc7da44 100644 --- a/packages/cli/src/config/extension-manager.ts +++ b/packages/cli/src/config/extension-manager.ts @@ -530,6 +530,7 @@ Would you like to attempt to install via "git clone" instead?`, return this.loadedExtensions; } for (const subdir of fs.readdirSync(extensionsDir)) { + if (subdir === '.env') continue; const extensionDir = path.join(extensionsDir, subdir); await this.loadExtension(extensionDir); } diff --git a/packages/cli/src/config/extension.test.ts b/packages/cli/src/config/extension.test.ts index 0148fc7729..02a000b4c9 100644 --- a/packages/cli/src/config/extension.test.ts +++ b/packages/cli/src/config/extension.test.ts @@ -280,6 +280,17 @@ describe('extension tests', () => { ]); }); + it('should ignore .env directory in extensions folder', async () => { + // Create a .env directory + const envDir = path.join(userExtensionsDir, '.env'); + fs.mkdirSync(envDir); + + const extensions = await extensionManager.loadExtensions(); + expect(extensions).toEqual([]); + const { debugLogger } = await import('@google/gemini-cli-core'); + expect(debugLogger.error).not.toHaveBeenCalled(); + }); + it('should annotate disabled extensions', async () => { createExtension({ extensionsDir: userExtensionsDir, diff --git a/packages/cli/src/config/extensions/extensionSettings.test.ts b/packages/cli/src/config/extensions/extensionSettings.test.ts index ef066977a1..d37ab291db 100644 --- a/packages/cli/src/config/extensions/extensionSettings.test.ts +++ b/packages/cli/src/config/extensions/extensionSettings.test.ts @@ -468,6 +468,32 @@ describe('extensionSettings', () => { expect(mockIsAvailable).toHaveBeenCalled(); expect(mockListSecrets).not.toHaveBeenCalled(); }); + + it('should throw error if .env is a directory when prompting for settings', async () => { + const config: ExtensionConfig = { + name: 'test-ext', + version: '1.0.0', + settings: [{ name: 's1', description: 'd1', envVar: 'VAR1' }], + }; + const envFilePath = path.join(extensionDir, '.env'); + if (fs.existsSync(envFilePath)) { + fs.unlinkSync(envFilePath); + } + fs.mkdirSync(envFilePath); + mockRequestSetting.mockResolvedValue('new-value'); + + await expect( + maybePromptForSettings( + config, + '12345', + mockRequestSetting, + undefined, + undefined, + ), + ).rejects.toThrow( + `Cannot update user-scoped settings because "${envFilePath}" is a directory.`, + ); + }); }); describe('promptForSetting', () => { @@ -590,6 +616,23 @@ describe('extensionSettings', () => { SENSITIVE_VAR: 'workspace-secret', }); }); + + it('should ignore .env if it is a directory', async () => { + const userEnvPath = path.join(extensionDir, EXTENSION_SETTINGS_FILENAME); + if (fs.existsSync(userEnvPath)) { + fs.unlinkSync(userEnvPath); + } + fs.mkdirSync(userEnvPath); + + const contents = await getScopedEnvContents( + config, + extensionId, + ExtensionSettingScope.USER, + tempWorkspaceDir, + ); + + expect(contents).toEqual({}); + }); }); describe('getEnvContents (merged)', () => { @@ -890,5 +933,27 @@ describe('extensionSettings', () => { const actualContent = await fsPromises.readFile(expectedEnvPath, 'utf-8'); expect(actualContent).toContain('VAR1="value with \\"quotes\\""'); }); + + it('should throw error if .env is a directory when updating a non-sensitive setting', async () => { + const envFilePath = path.join(extensionDir, '.env'); + if (fs.existsSync(envFilePath)) { + fs.unlinkSync(envFilePath); + } + fs.mkdirSync(envFilePath); + mockRequestSetting.mockResolvedValue('new-value'); + + await expect( + updateSetting( + config, + '12345', + 'VAR1', + mockRequestSetting, + ExtensionSettingScope.USER, + tempWorkspaceDir, + ), + ).rejects.toThrow( + `Cannot update user-scoped settings because "${envFilePath}" is a directory.`, + ); + }); }); }); diff --git a/packages/cli/src/config/extensions/extensionSettings.ts b/packages/cli/src/config/extensions/extensionSettings.ts index 06e4f49db4..c1c9ad33bc 100644 --- a/packages/cli/src/config/extensions/extensionSettings.ts +++ b/packages/cli/src/config/extensions/extensionSettings.ts @@ -124,6 +124,15 @@ export async function maybePromptForSettings( const envContent = formatEnvContent(nonSensitiveSettings); + if ( + fsSync.existsSync(envFilePath) && + fsSync.statSync(envFilePath).isDirectory() + ) { + throw new Error( + `Cannot update ${scope}-scoped settings because "${envFilePath}" is a directory.`, + ); + } + await fs.writeFile(envFilePath, envContent); } @@ -172,7 +181,7 @@ export async function getScopedEnvContents( ); const envFilePath = getEnvFilePath(extensionName, scope, workspaceDir); let customEnv: Record = {}; - if (fsSync.existsSync(envFilePath)) { + if (fsSync.existsSync(envFilePath) && fsSync.statSync(envFilePath).isFile()) { const envFile = fsSync.readFileSync(envFilePath, 'utf-8'); customEnv = dotenv.parse(envFile); } @@ -258,6 +267,16 @@ export async function updateSetting( // For non-sensitive settings, we need to read the existing .env file, // update the value, and write it back, preserving any other values. const envFilePath = getEnvFilePath(extensionName, scope, workspaceDir); + + if ( + fsSync.existsSync(envFilePath) && + fsSync.statSync(envFilePath).isDirectory() + ) { + throw new Error( + `Cannot update ${scope}-scoped settings because "${envFilePath}" is a directory.`, + ); + } + let envContent = ''; if (fsSync.existsSync(envFilePath)) { envContent = await fs.readFile(envFilePath, 'utf-8'); @@ -323,7 +342,7 @@ async function clearSettings( envFilePath: string, keychain: KeychainTokenStorage, ) { - if (fsSync.existsSync(envFilePath)) { + if (fsSync.existsSync(envFilePath) && fsSync.statSync(envFilePath).isFile()) { await fs.writeFile(envFilePath, ''); } if (!(await keychain.isAvailable())) {