mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-14 05:42:54 -07:00
This commit is contained in:
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as path from 'node:path';
|
||||
import type * as osActual from 'node:os';
|
||||
|
||||
vi.mock('node:os', async (importOriginal) => {
|
||||
const actualOs = await importOriginal<typeof osActual>();
|
||||
return {
|
||||
...actualOs,
|
||||
homedir: vi.fn(() => path.resolve('/mock/home')),
|
||||
platform: vi.fn(() => 'linux'),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
homedir: vi.fn(() => path.resolve('/mock/home')),
|
||||
};
|
||||
});
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import { loadEnvironment, type Settings } from './settings.js';
|
||||
import { GEMINI_DIR, homedir as coreHomedir } from '@google/gemini-cli-core';
|
||||
|
||||
vi.mock('node:fs');
|
||||
|
||||
describe('Environment Isolation', () => {
|
||||
const mockHome = path.resolve('/mock/home');
|
||||
const mockWorkspace = path.resolve('/mock/workspace');
|
||||
const originalArgv = process.argv;
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(os.homedir).mockReturnValue(mockHome);
|
||||
vi.mocked(coreHomedir).mockReturnValue(mockHome);
|
||||
// Default to no files existing
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
process.argv = ['node', 'gemini'];
|
||||
|
||||
// Clear env vars that might leak from the host environment
|
||||
delete process.env['GEMINI_API_KEY'];
|
||||
delete process.env['OTHER_VAR'];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.argv = originalArgv;
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
it('should load local .env by default', () => {
|
||||
const workspaceEnv = path.join(mockWorkspace, '.env');
|
||||
vi.mocked(fs.existsSync).mockImplementation(
|
||||
(p) => p.toString() === workspaceEnv,
|
||||
);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue('GEMINI_API_KEY=local');
|
||||
|
||||
const settings = { advanced: { ignoreLocalEnv: false } } as Settings;
|
||||
loadEnvironment(settings, mockWorkspace, () => ({
|
||||
isTrusted: true,
|
||||
source: 'file',
|
||||
}));
|
||||
|
||||
expect(process.env['GEMINI_API_KEY']).toBe('local');
|
||||
delete process.env['GEMINI_API_KEY'];
|
||||
});
|
||||
|
||||
it('should ignore local .env when ignoreLocalEnv is true', () => {
|
||||
const workspaceEnv = path.join(mockWorkspace, '.env');
|
||||
const homeEnv = path.join(mockHome, '.env');
|
||||
|
||||
vi.mocked(fs.existsSync).mockImplementation((p) => {
|
||||
const ps = p.toString();
|
||||
return ps === workspaceEnv || ps === homeEnv;
|
||||
});
|
||||
vi.mocked(fs.readFileSync).mockImplementation((p) => {
|
||||
const ps = p.toString();
|
||||
if (ps === workspaceEnv) return 'GEMINI_API_KEY=local';
|
||||
if (ps === homeEnv) return 'GEMINI_API_KEY=home';
|
||||
return '';
|
||||
});
|
||||
|
||||
const settings = { advanced: { ignoreLocalEnv: true } } as Settings;
|
||||
loadEnvironment(settings, mockWorkspace, () => ({
|
||||
isTrusted: true,
|
||||
source: 'file',
|
||||
}));
|
||||
|
||||
// Should skip local and find home
|
||||
expect(process.env['GEMINI_API_KEY']).toBe('home');
|
||||
delete process.env['GEMINI_API_KEY'];
|
||||
});
|
||||
|
||||
it('should still load .gemini/.env even if ignoreLocalEnv is true', () => {
|
||||
const workspaceGeminiEnv = path.join(mockWorkspace, GEMINI_DIR, '.env');
|
||||
vi.mocked(fs.existsSync).mockImplementation(
|
||||
(p) => p.toString() === workspaceGeminiEnv,
|
||||
);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue('GEMINI_API_KEY=gemini-local');
|
||||
|
||||
const settings = { advanced: { ignoreLocalEnv: true } } as Settings;
|
||||
loadEnvironment(settings, mockWorkspace, () => ({
|
||||
isTrusted: true,
|
||||
source: 'file',
|
||||
}));
|
||||
|
||||
expect(process.env['GEMINI_API_KEY']).toBe('gemini-local');
|
||||
delete process.env['GEMINI_API_KEY'];
|
||||
});
|
||||
|
||||
it('should respect --ignore-env flag', () => {
|
||||
const workspaceEnv = path.join(mockWorkspace, '.env');
|
||||
vi.mocked(fs.existsSync).mockImplementation(
|
||||
(p) => p.toString() === workspaceEnv,
|
||||
);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue('GEMINI_API_KEY=local');
|
||||
|
||||
process.argv = ['node', 'gemini', '--ignore-env'];
|
||||
const settings = { advanced: { ignoreLocalEnv: false } } as Settings;
|
||||
loadEnvironment(settings, mockWorkspace, () => ({
|
||||
isTrusted: true,
|
||||
source: 'file',
|
||||
}));
|
||||
|
||||
expect(process.env['GEMINI_API_KEY']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should allow home .env even with ignoreLocalEnv true', () => {
|
||||
const homeEnv = path.join(mockHome, '.env');
|
||||
vi.mocked(fs.existsSync).mockImplementation(
|
||||
(p) => p.toString() === homeEnv,
|
||||
);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue('GEMINI_API_KEY=home');
|
||||
|
||||
const settings = { advanced: { ignoreLocalEnv: true } } as Settings;
|
||||
// Running from home dir
|
||||
loadEnvironment(settings, mockHome, () => ({
|
||||
isTrusted: true,
|
||||
source: 'file',
|
||||
}));
|
||||
|
||||
expect(process.env['GEMINI_API_KEY']).toBe('home');
|
||||
delete process.env['GEMINI_API_KEY'];
|
||||
});
|
||||
|
||||
it('should skip local .env and its parents until home when ignoreLocalEnv is true', () => {
|
||||
const deepProject = path.join(mockWorkspace, 'deep', 'dir');
|
||||
const deepEnv = path.join(deepProject, '.env');
|
||||
const parentEnv = path.join(mockWorkspace, '.env');
|
||||
const homeEnv = path.join(mockHome, '.env');
|
||||
|
||||
vi.mocked(fs.existsSync).mockImplementation((p) => {
|
||||
const ps = p.toString();
|
||||
return ps === deepEnv || ps === parentEnv || ps === homeEnv;
|
||||
});
|
||||
vi.mocked(fs.readFileSync).mockImplementation((p) => {
|
||||
const ps = p.toString();
|
||||
if (ps === deepEnv) return 'GEMINI_API_KEY=deep';
|
||||
if (ps === parentEnv) return 'GEMINI_API_KEY=parent';
|
||||
if (ps === homeEnv) return 'GEMINI_API_KEY=home';
|
||||
return '';
|
||||
});
|
||||
|
||||
const settings = { advanced: { ignoreLocalEnv: true } } as Settings;
|
||||
loadEnvironment(settings, deepProject, () => ({
|
||||
isTrusted: true,
|
||||
source: 'file',
|
||||
}));
|
||||
|
||||
expect(process.env['GEMINI_API_KEY']).toBe('home');
|
||||
delete process.env['GEMINI_API_KEY'];
|
||||
});
|
||||
|
||||
it('should respect trust whitelist even when loading from home .env', () => {
|
||||
const homeEnv = path.join(mockHome, '.env');
|
||||
vi.mocked(fs.existsSync).mockImplementation(
|
||||
(p) => p.toString() === homeEnv,
|
||||
);
|
||||
// Include one whitelisted and one non-whitelisted variable
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(
|
||||
'GEMINI_API_KEY=home\nOTHER_VAR=secret',
|
||||
);
|
||||
|
||||
const settings = { advanced: { ignoreLocalEnv: true } } as Settings;
|
||||
// Running from an UNTRUSTED workspace
|
||||
loadEnvironment(settings, mockWorkspace, () => ({
|
||||
isTrusted: false,
|
||||
source: 'file',
|
||||
}));
|
||||
|
||||
expect(process.env['GEMINI_API_KEY']).toBe('home');
|
||||
expect(process.env['OTHER_VAR']).toBeUndefined();
|
||||
delete process.env['GEMINI_API_KEY'];
|
||||
});
|
||||
|
||||
it('should prioritize --ignore-env flag even if setting is false', () => {
|
||||
const workspaceEnv = path.join(mockWorkspace, '.env');
|
||||
vi.mocked(fs.existsSync).mockImplementation(
|
||||
(p) => p.toString() === workspaceEnv,
|
||||
);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue('GEMINI_API_KEY=local');
|
||||
|
||||
process.argv = ['node', 'gemini', '--ignore-env'];
|
||||
const settings = { advanced: { ignoreLocalEnv: false } } as Settings;
|
||||
loadEnvironment(settings, mockWorkspace, () => ({
|
||||
isTrusted: true,
|
||||
source: 'file',
|
||||
}));
|
||||
|
||||
expect(process.env['GEMINI_API_KEY']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should respect both -s and --ignore-env flags simultaneously', () => {
|
||||
const workspaceEnv = path.join(mockWorkspace, '.env');
|
||||
vi.mocked(fs.existsSync).mockImplementation(
|
||||
(p) => p.toString() === workspaceEnv,
|
||||
);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue('GEMINI_API_KEY=local');
|
||||
|
||||
process.argv = ['node', 'gemini', '-s', '--ignore-env'];
|
||||
const settings = { advanced: { ignoreLocalEnv: false } } as Settings;
|
||||
loadEnvironment(settings, mockWorkspace, () => ({
|
||||
isTrusted: true,
|
||||
source: 'file',
|
||||
}));
|
||||
|
||||
expect(process.env['GEMINI_API_KEY']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -500,7 +500,11 @@ export class LoadedSettings {
|
||||
}
|
||||
}
|
||||
|
||||
function findEnvFile(startDir: string, isTrusted: boolean): string | null {
|
||||
function findEnvFile(
|
||||
startDir: string,
|
||||
isTrusted: boolean,
|
||||
ignoreLocalEnv: boolean,
|
||||
): string | null {
|
||||
let currentDir = path.resolve(startDir);
|
||||
while (true) {
|
||||
// prefer gemini-specific .env under GEMINI_DIR
|
||||
@@ -512,7 +516,9 @@ function findEnvFile(startDir: string, isTrusted: boolean): string | null {
|
||||
}
|
||||
const envPath = path.join(currentDir, '.env');
|
||||
if (fs.existsSync(envPath)) {
|
||||
return envPath;
|
||||
if (!ignoreLocalEnv || currentDir === homedir()) {
|
||||
return envPath;
|
||||
}
|
||||
}
|
||||
const parentDir = path.dirname(currentDir);
|
||||
if (parentDir === currentDir || !parentDir) {
|
||||
@@ -595,7 +601,6 @@ export function loadEnvironment(
|
||||
): void {
|
||||
const trustResult = isWorkspaceTrustedFn(settings, workspaceDir);
|
||||
const isTrusted = trustResult.isTrusted ?? false;
|
||||
const envFilePath = findEnvFile(workspaceDir, isTrusted);
|
||||
|
||||
// Check settings OR check process.argv directly since this might be called
|
||||
// before arguments are fully parsed. This is a best-effort sniffing approach
|
||||
@@ -612,6 +617,12 @@ export function loadEnvironment(
|
||||
relevantArgs.includes('-s') ||
|
||||
relevantArgs.includes('--sandbox');
|
||||
|
||||
const shouldIgnoreEnv =
|
||||
!!settings.advanced?.ignoreLocalEnv ||
|
||||
relevantArgs.includes('--ignore-env');
|
||||
|
||||
const envFilePath = findEnvFile(workspaceDir, isTrusted, shouldIgnoreEnv);
|
||||
|
||||
// Cloud Shell environment variable handling
|
||||
if (process.env['CLOUD_SHELL'] === 'true') {
|
||||
const selectedAuthType = settings.security?.auth?.selectedType;
|
||||
|
||||
@@ -2030,6 +2030,16 @@ const SETTINGS_SCHEMA = {
|
||||
items: { type: 'string' },
|
||||
mergeStrategy: MergeStrategy.UNION,
|
||||
},
|
||||
ignoreLocalEnv: {
|
||||
type: 'boolean',
|
||||
label: 'Ignore Local .env',
|
||||
category: 'Advanced',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description:
|
||||
'Whether to ignore generic .env files in the project directory.',
|
||||
showInDialog: true,
|
||||
},
|
||||
bugCommand: {
|
||||
type: 'object',
|
||||
label: 'Bug Command',
|
||||
|
||||
Reference in New Issue
Block a user