From 03ed37d0dc2b5e2077b53073517abaab3d24d9c2 Mon Sep 17 00:00:00 2001 From: Oleksandr Gotgelf Date: Sun, 3 Aug 2025 20:44:15 +0200 Subject: [PATCH] fix: exclude DEBUG and DEBUG_MODE from project .env files by default (#5289) Co-authored-by: Jacob Richman --- CONTRIBUTING.md | 2 + docs/cli/authentication.md | 2 + docs/cli/configuration.md | 14 +- docs/sandbox.md | 2 + docs/troubleshooting.md | 5 + packages/cli/src/config/settings.test.ts | 216 +++++++++++++++++++++++ packages/cli/src/config/settings.ts | 75 ++++++-- 7 files changed, 305 insertions(+), 11 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index eaa7bf0d..ff31ef8f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -242,6 +242,8 @@ To hit a breakpoint inside the sandbox container run: DEBUG=1 gemini ``` +**Note:** If you have `DEBUG=true` in a project's `.env` file, it won't affect gemini-cli due to automatic exclusion. Use `.gemini/.env` files for gemini-cli specific debug settings. + ### React DevTools To debug the CLI's React-based UI, you can use React DevTools. Ink, the library used for the CLI's interface, is compatible with React DevTools version 4.x. diff --git a/docs/cli/authentication.md b/docs/cli/authentication.md index 8e534d0b..d9adcfb1 100644 --- a/docs/cli/authentication.md +++ b/docs/cli/authentication.md @@ -91,6 +91,8 @@ The Gemini CLI requires you to authenticate with Google's AI services. On initia You can create a **`.gemini/.env`** file in your project directory or in your home directory. Creating a plain **`.env`** file also works, but `.gemini/.env` is recommended to keep Gemini variables isolated from other tools. +**Important:** Some environment variables (like `DEBUG` and `DEBUG_MODE`) are automatically excluded from project `.env` files to prevent interference with gemini-cli behavior. Use `.gemini/.env` files for gemini-cli specific variables. + Gemini CLI automatically loads environment variables from the **first** `.env` file it finds, using the following search order: 1. Starting in the **current directory** and moving upward toward `/`, for each directory it checks: diff --git a/docs/cli/configuration.md b/docs/cli/configuration.md index 695a7c53..ce9b55bc 100644 --- a/docs/cli/configuration.md +++ b/docs/cli/configuration.md @@ -240,6 +240,14 @@ In addition to a project settings file, a project's `.gemini` directory can cont } ``` +- **`excludedProjectEnvVars`** (array of strings): + - **Description:** Specifies environment variables that should be excluded from being loaded from project `.env` files. This prevents project-specific environment variables (like `DEBUG=true`) from interfering with gemini-cli behavior. Variables from `.gemini/.env` files are never excluded. + - **Default:** `["DEBUG", "DEBUG_MODE"]` + - **Example:** + ```json + "excludedProjectEnvVars": ["DEBUG", "DEBUG_MODE", "NODE_ENV"] + ``` + ### Example `settings.json`: ```json @@ -271,7 +279,8 @@ In addition to a project settings file, a project's `.gemini` directory can cont "run_shell_command": { "tokenBudget": 100 } - } + }, + "excludedProjectEnvVars": ["DEBUG", "DEBUG_MODE", "NODE_ENV"] } ``` @@ -293,6 +302,8 @@ The CLI automatically loads environment variables from an `.env` file. The loadi 2. If not found, it searches upwards in parent directories until it finds an `.env` file or reaches the project root (identified by a `.git` folder) or the home directory. 3. If still not found, it looks for `~/.env` (in the user's home directory). +**Environment Variable Exclusion:** Some environment variables (like `DEBUG` and `DEBUG_MODE`) are automatically excluded from being loaded from project `.env` files to prevent interference with gemini-cli behavior. Variables from `.gemini/.env` files are never excluded. You can customize this behavior using the `excludedProjectEnvVars` setting in your `settings.json` file. + - **`GEMINI_API_KEY`** (Required): - Your API key for the Gemini API. - **Crucial for operation.** The CLI will not function without it. @@ -332,6 +343,7 @@ The CLI automatically loads environment variables from an `.env` file. The loadi - ``: Uses a custom profile. To define a custom profile, create a file named `sandbox-macos-.sb` in your project's `.gemini/` directory (e.g., `my-project/.gemini/sandbox-macos-custom.sb`). - **`DEBUG` or `DEBUG_MODE`** (often used by underlying libraries or the CLI itself): - Set to `true` or `1` to enable verbose debug logging, which can be helpful for troubleshooting. + - **Note:** These variables are automatically excluded from project `.env` files by default to prevent interference with gemini-cli behavior. Use `.gemini/.env` files if you need to set these for gemini-cli specifically. - **`NO_COLOR`**: - Set to any value to disable all color output in the CLI. - **`CLI_TITLE`**: diff --git a/docs/sandbox.md b/docs/sandbox.md index 508a0d03..20a1a3b5 100644 --- a/docs/sandbox.md +++ b/docs/sandbox.md @@ -129,6 +129,8 @@ export SANDBOX_SET_UID_GID=false # Disable UID/GID mapping DEBUG=1 gemini -s -p "debug command" ``` +**Note:** If you have `DEBUG=true` in a project's `.env` file, it won't affect gemini-cli due to automatic exclusion. Use `.gemini/.env` files for gemini-cli specific debug settings. + ### Inspect sandbox ```bash diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index fa88e26e..8c500445 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -53,6 +53,11 @@ This guide provides solutions to common issues and debugging tips. - **Cause:** The `is-in-ci` package checks for the presence of `CI`, `CONTINUOUS_INTEGRATION`, or any environment variable with a `CI_` prefix. When any of these are found, it signals that the environment is non-interactive, which prevents the CLI from starting in its interactive mode. - **Solution:** If the `CI_` prefixed variable is not needed for the CLI to function, you can temporarily unset it for the command. e.g., `env -u CI_TOKEN gemini` +- **DEBUG mode not working from project .env file** + - **Issue:** Setting `DEBUG=true` in a project's `.env` file doesn't enable debug mode for gemini-cli. + - **Cause:** The `DEBUG` and `DEBUG_MODE` variables are automatically excluded from project `.env` files to prevent interference with gemini-cli behavior. + - **Solution:** Use a `.gemini/.env` file instead, or configure the `excludedProjectEnvVars` setting in your `settings.json` to exclude fewer variables. + ## Debugging Tips - **CLI debugging:** diff --git a/packages/cli/src/config/settings.test.ts b/packages/cli/src/config/settings.test.ts index b8ecbb62..4099e778 100644 --- a/packages/cli/src/config/settings.test.ts +++ b/packages/cli/src/config/settings.test.ts @@ -334,6 +334,86 @@ describe('Settings Loading and Merging', () => { expect(settings.merged.contextFileName).toBe('PROJECT_SPECIFIC.md'); }); + it('should handle excludedProjectEnvVars correctly when only in user settings', () => { + (mockFsExistsSync as Mock).mockImplementation( + (p: fs.PathLike) => p === USER_SETTINGS_PATH, + ); + const userSettingsContent = { + excludedProjectEnvVars: ['DEBUG', 'NODE_ENV', 'CUSTOM_VAR'], + }; + (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.merged.excludedProjectEnvVars).toEqual([ + 'DEBUG', + 'NODE_ENV', + 'CUSTOM_VAR', + ]); + }); + + it('should handle excludedProjectEnvVars correctly when only in workspace settings', () => { + (mockFsExistsSync as Mock).mockImplementation( + (p: fs.PathLike) => p === MOCK_WORKSPACE_SETTINGS_PATH, + ); + const workspaceSettingsContent = { + excludedProjectEnvVars: ['WORKSPACE_DEBUG', 'WORKSPACE_VAR'], + }; + (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.merged.excludedProjectEnvVars).toEqual([ + 'WORKSPACE_DEBUG', + 'WORKSPACE_VAR', + ]); + }); + + it('should merge excludedProjectEnvVars with workspace taking precedence over user', () => { + (mockFsExistsSync as Mock).mockReturnValue(true); + const userSettingsContent = { + excludedProjectEnvVars: ['DEBUG', 'NODE_ENV', 'USER_VAR'], + }; + const workspaceSettingsContent = { + excludedProjectEnvVars: ['WORKSPACE_DEBUG', 'WORKSPACE_VAR'], + }; + + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (p === USER_SETTINGS_PATH) + return JSON.stringify(userSettingsContent); + if (p === MOCK_WORKSPACE_SETTINGS_PATH) + return JSON.stringify(workspaceSettingsContent); + return ''; + }, + ); + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + expect(settings.user.settings.excludedProjectEnvVars).toEqual([ + 'DEBUG', + 'NODE_ENV', + 'USER_VAR', + ]); + expect(settings.workspace.settings.excludedProjectEnvVars).toEqual([ + 'WORKSPACE_DEBUG', + 'WORKSPACE_VAR', + ]); + expect(settings.merged.excludedProjectEnvVars).toEqual([ + 'WORKSPACE_DEBUG', + 'WORKSPACE_VAR', + ]); + }); + it('should default contextFileName to undefined if not in any settings file', () => { (mockFsExistsSync as Mock).mockReturnValue(true); const userSettingsContent = { theme: 'dark' }; @@ -1055,4 +1135,140 @@ describe('Settings Loading and Merging', () => { expect(loadedSettings.merged.theme).toBe('ocean'); }); }); + + describe('excludedProjectEnvVars integration', () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it('should exclude DEBUG and DEBUG_MODE from project .env files by default', () => { + // Create a workspace settings file with excludedProjectEnvVars + const workspaceSettingsContent = { + excludedProjectEnvVars: ['DEBUG', 'DEBUG_MODE'], + }; + + (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 '{}'; + }, + ); + + // Mock findEnvFile to return a project .env file + const originalFindEnvFile = ( + loadSettings as unknown as { findEnvFile: () => string } + ).findEnvFile; + (loadSettings as unknown as { findEnvFile: () => string }).findEnvFile = + () => '/mock/project/.env'; + + // Mock fs.readFileSync for .env file content + const originalReadFileSync = fs.readFileSync; + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (p === '/mock/project/.env') { + return 'DEBUG=true\nDEBUG_MODE=1\nGEMINI_API_KEY=test-key'; + } + if (p === MOCK_WORKSPACE_SETTINGS_PATH) { + return JSON.stringify(workspaceSettingsContent); + } + return '{}'; + }, + ); + + try { + // This will call loadEnvironment internally with the merged settings + const settings = loadSettings(MOCK_WORKSPACE_DIR); + + // Verify the settings were loaded correctly + expect(settings.merged.excludedProjectEnvVars).toEqual([ + 'DEBUG', + 'DEBUG_MODE', + ]); + + // Note: We can't directly test process.env changes here because the mocking + // prevents the actual file system operations, but we can verify the settings + // are correctly merged and passed to loadEnvironment + } finally { + (loadSettings as unknown as { findEnvFile: () => string }).findEnvFile = + originalFindEnvFile; + (fs.readFileSync as Mock).mockImplementation(originalReadFileSync); + } + }); + + it('should respect custom excludedProjectEnvVars from user settings', () => { + const userSettingsContent = { + excludedProjectEnvVars: ['NODE_ENV', 'DEBUG'], + }; + + (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.excludedProjectEnvVars).toEqual([ + 'NODE_ENV', + 'DEBUG', + ]); + expect(settings.merged.excludedProjectEnvVars).toEqual([ + 'NODE_ENV', + 'DEBUG', + ]); + }); + + it('should merge excludedProjectEnvVars with workspace taking precedence', () => { + const userSettingsContent = { + excludedProjectEnvVars: ['DEBUG', 'NODE_ENV', 'USER_VAR'], + }; + const workspaceSettingsContent = { + excludedProjectEnvVars: ['WORKSPACE_DEBUG', 'WORKSPACE_VAR'], + }; + + (mockFsExistsSync as Mock).mockReturnValue(true); + + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (p === USER_SETTINGS_PATH) + return JSON.stringify(userSettingsContent); + if (p === MOCK_WORKSPACE_SETTINGS_PATH) + return JSON.stringify(workspaceSettingsContent); + return '{}'; + }, + ); + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + + expect(settings.user.settings.excludedProjectEnvVars).toEqual([ + 'DEBUG', + 'NODE_ENV', + 'USER_VAR', + ]); + expect(settings.workspace.settings.excludedProjectEnvVars).toEqual([ + 'WORKSPACE_DEBUG', + 'WORKSPACE_VAR', + ]); + expect(settings.merged.excludedProjectEnvVars).toEqual([ + 'WORKSPACE_DEBUG', + 'WORKSPACE_VAR', + ]); + }); + }); }); diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index 84f996ba..05d4313f 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -24,6 +24,7 @@ import { CustomTheme } from '../ui/themes/theme.js'; export const SETTINGS_DIRECTORY_NAME = '.gemini'; export const USER_SETTINGS_DIR = path.join(homedir(), SETTINGS_DIRECTORY_NAME); export const USER_SETTINGS_PATH = path.join(USER_SETTINGS_DIR, 'settings.json'); +export const DEFAULT_EXCLUDED_ENV_VARS = ['DEBUG', 'DEBUG_MODE']; export function getSystemSettingsPath(): string { if (process.env.GEMINI_CLI_SYSTEM_SETTINGS_PATH) { @@ -38,6 +39,10 @@ export function getSystemSettingsPath(): string { } } +export function getWorkspaceSettingsPath(workspaceDir: string): string { + return path.join(workspaceDir, SETTINGS_DIRECTORY_NAME, 'settings.json'); +} + export type DnsResolutionOrder = 'ipv4first' | 'verbatim'; export enum SettingScope { @@ -115,6 +120,9 @@ export interface Settings { disableUpdateNag?: boolean; memoryDiscoveryMaxDirs?: number; + + // Environment variables to exclude from project .env files + excludedProjectEnvVars?: string[]; dnsResolutionOrder?: DnsResolutionOrder; } @@ -292,15 +300,61 @@ export function setUpCloudShellEnvironment(envFilePath: string | null): void { } } -export function loadEnvironment(): void { +export function loadEnvironment(settings?: Settings): void { const envFilePath = findEnvFile(process.cwd()); + // Cloud Shell environment variable handling if (process.env.CLOUD_SHELL === 'true') { setUpCloudShellEnvironment(envFilePath); } + // If no settings provided, try to load workspace settings for exclusions + let resolvedSettings = settings; + if (!resolvedSettings) { + const workspaceSettingsPath = getWorkspaceSettingsPath(process.cwd()); + try { + if (fs.existsSync(workspaceSettingsPath)) { + const workspaceContent = fs.readFileSync( + workspaceSettingsPath, + 'utf-8', + ); + const parsedWorkspaceSettings = JSON.parse( + stripJsonComments(workspaceContent), + ) as Settings; + resolvedSettings = resolveEnvVarsInObject(parsedWorkspaceSettings); + } + } catch (_e) { + // Ignore errors loading workspace settings + } + } + if (envFilePath) { - dotenv.config({ path: envFilePath, quiet: true }); + // Manually parse and load environment variables to handle exclusions correctly. + // This avoids modifying environment variables that were already set from the shell. + try { + const envFileContent = fs.readFileSync(envFilePath, 'utf-8'); + const parsedEnv = dotenv.parse(envFileContent); + + const excludedVars = + resolvedSettings?.excludedProjectEnvVars || DEFAULT_EXCLUDED_ENV_VARS; + const isProjectEnvFile = !envFilePath.includes(GEMINI_DIR); + + for (const key in parsedEnv) { + if (Object.hasOwn(parsedEnv, key)) { + // If it's a project .env file, skip loading excluded variables. + if (isProjectEnvFile && excludedVars.includes(key)) { + continue; + } + + // Load variable only if it's not already set in the environment. + if (!Object.hasOwn(process.env, key)) { + process.env[key] = parsedEnv[key]; + } + } + } + } catch (_e) { + // Errors are ignored to match the behavior of `dotenv.config({ quiet: true })`. + } } } @@ -309,7 +363,6 @@ export function loadEnvironment(): void { * Project settings override user settings. */ export function loadSettings(workspaceDir: string): LoadedSettings { - loadEnvironment(); let systemSettings: Settings = {}; let userSettings: Settings = {}; let workspaceSettings: Settings = {}; @@ -331,6 +384,8 @@ export function loadSettings(workspaceDir: string): LoadedSettings { // We expect homedir to always exist and be resolvable. const realHomeDir = fs.realpathSync(resolvedHomeDir); + const workspaceSettingsPath = getWorkspaceSettingsPath(workspaceDir); + // Load system settings try { if (fs.existsSync(systemSettingsPath)) { @@ -369,12 +424,6 @@ export function loadSettings(workspaceDir: string): LoadedSettings { }); } - const workspaceSettingsPath = path.join( - workspaceDir, - SETTINGS_DIRECTORY_NAME, - 'settings.json', - ); - // This comparison is now much more reliable. if (realWorkspaceDir !== realHomeDir) { // Load workspace settings @@ -402,7 +451,8 @@ export function loadSettings(workspaceDir: string): LoadedSettings { } } - return new LoadedSettings( + // Create LoadedSettings first + const loadedSettings = new LoadedSettings( { path: systemSettingsPath, settings: systemSettings, @@ -417,6 +467,11 @@ export function loadSettings(workspaceDir: string): LoadedSettings { }, settingsErrors, ); + + // Load environment with merged settings + loadEnvironment(loadedSettings.merged); + + return loadedSettings; } export function saveSettings(settingsFile: SettingsFile): void {