Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
269 changes: 266 additions & 3 deletions packages/cli/src/config/settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2685,11 +2685,274 @@ describe('Settings Loading and Merging', () => {
expect(process.env['TESTTEST']).toEqual('1234');
});

it('does not load env files from untrusted spaces', () => {
setup({ isFolderTrustEnabled: true, isWorkspaceTrustedValue: false });
it('does not load project .env files from untrusted workspaces', () => {
delete process.env['PROJECT_ENV_VAR'];
const cwdSpy = vi
.spyOn(process, 'cwd')
.mockReturnValue(MOCK_WORKSPACE_DIR);

const projectEnvPath = path.join(MOCK_WORKSPACE_DIR, '.env');

vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: false,
source: 'file',
});
(mockFsExistsSync as Mock).mockImplementation((p: fs.PathLike) =>
[USER_SETTINGS_PATH, projectEnvPath].includes(p.toString()),
);
const userSettingsContent: Settings = {
ui: {
theme: 'dark',
},
security: {
folderTrust: {
enabled: true,
},
},
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(userSettingsContent);
if (p === projectEnvPath) return 'PROJECT_ENV_VAR=from_project';
return '{}';
},
);

loadEnvironment(loadSettings(MOCK_WORKSPACE_DIR).merged);

expect(process.env['TESTTEST']).not.toEqual('1234');
// Project .env should NOT be loaded when workspace is untrusted
expect(process.env['PROJECT_ENV_VAR']).toBeUndefined();
cwdSpy.mockRestore();
});

describe('settings.env field', () => {
const originalEnv = { ...process.env };

beforeEach(() => {
process.env = { ...originalEnv };
delete process.env['ENV_FROM_SETTINGS'];
delete process.env['ENV_OVERRIDE_TEST'];
delete process.env['SYSTEM_ENV_VAR'];
delete process.env['MULTI_VAR_A'];
delete process.env['MULTI_VAR_B'];
delete process.env['MULTI_VAR_C'];
delete process.env['USER_ENV_VAR'];
delete process.env['WORKSPACE_ENV_VAR'];
});

afterEach(() => {
process.env = originalEnv;
});

it('should load environment variables from settings.env as fallback', () => {
const userSettingsContent: Settings = {
env: {
ENV_FROM_SETTINGS: 'settings_value',
},
};

(mockFsExistsSync as Mock).mockImplementation((p: fs.PathLike) =>
[USER_SETTINGS_PATH].includes(p.toString()),
);
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(userSettingsContent);
return '{}';
},
);

vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: true,
source: 'file',
});

// loadSettings internally calls loadEnvironment with userSettings
loadSettings(MOCK_WORKSPACE_DIR);

expect(process.env['ENV_FROM_SETTINGS']).toEqual('settings_value');
});

it('should allow .env file to override settings.env values', () => {
const geminiEnvPath = path.resolve(path.join(QWEN_DIR, '.env'));
const userSettingsContent: Settings = {
env: {
ENV_OVERRIDE_TEST: 'from_settings',
},
};

(mockFsExistsSync as Mock).mockImplementation((p: fs.PathLike) =>
[USER_SETTINGS_PATH, geminiEnvPath].includes(p.toString()),
);
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(userSettingsContent);
if (p === geminiEnvPath) return 'ENV_OVERRIDE_TEST=from_dotenv';
return '{}';
},
);

vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: true,
source: 'file',
});

// loadSettings internally calls loadEnvironment with merged settings
loadSettings(MOCK_WORKSPACE_DIR);

// .env file has higher priority than settings.env (loaded first, no-override)
expect(process.env['ENV_OVERRIDE_TEST']).toEqual('from_dotenv');
});

it('should not override existing system environment variables', () => {
process.env['SYSTEM_ENV_VAR'] = 'system_value';

const geminiEnvPath = path.resolve(path.join(QWEN_DIR, '.env'));
const userSettingsContent: Settings = {
env: {
SYSTEM_ENV_VAR: 'from_settings',
},
};

(mockFsExistsSync as Mock).mockImplementation((p: fs.PathLike) =>
[USER_SETTINGS_PATH, geminiEnvPath].includes(p.toString()),
);
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(userSettingsContent);
if (p === geminiEnvPath) return 'SYSTEM_ENV_VAR=from_dotenv';
return '{}';
},
);

vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: true,
source: 'file',
});

// loadSettings internally calls loadEnvironment with userSettings
loadSettings(MOCK_WORKSPACE_DIR);

// System environment variable should have highest priority
expect(process.env['SYSTEM_ENV_VAR']).toEqual('system_value');
});

it('should support multiple env variables in settings.env', () => {
const userSettingsContent: Settings = {
env: {
MULTI_VAR_A: 'value_a',
MULTI_VAR_B: 'value_b',
MULTI_VAR_C: 'value_c',
},
};

(mockFsExistsSync as Mock).mockImplementation((p: fs.PathLike) =>
[USER_SETTINGS_PATH].includes(p.toString()),
);
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(userSettingsContent);
return '{}';
},
);

vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: true,
source: 'file',
});

// loadSettings internally calls loadEnvironment with userSettings
loadSettings(MOCK_WORKSPACE_DIR);

expect(process.env['MULTI_VAR_A']).toEqual('value_a');
expect(process.env['MULTI_VAR_B']).toEqual('value_b');
expect(process.env['MULTI_VAR_C']).toEqual('value_c');
});

it('should load settings.env from both user and workspace settings', () => {
const workspaceSettingsContent = {
env: {
WORKSPACE_ENV_VAR: 'workspace_value',
},
};
const userSettingsContent: Settings = {
env: {
USER_ENV_VAR: 'user_value',
},
};

(mockFsExistsSync as Mock).mockImplementation((p: fs.PathLike) =>
[USER_SETTINGS_PATH, MOCK_WORKSPACE_SETTINGS_PATH].includes(
p.toString(),
),
);
(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 '{}';
},
);

vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: true,
source: 'file',
});

// loadSettings internally calls loadEnvironment with merged settings
loadSettings(MOCK_WORKSPACE_DIR);

// Both user-level and workspace-level env should be loaded
expect(process.env['USER_ENV_VAR']).toEqual('user_value');
expect(process.env['WORKSPACE_ENV_VAR']).toEqual('workspace_value');
});

it('should load user-level settings.env even when workspace is untrusted', () => {
const userSettingsContent: Settings = {
env: {
USER_ENV_VAR: 'user_value',
},
};
const workspaceSettingsContent = {
env: {
WORKSPACE_ENV_VAR: 'workspace_value',
},
};

(mockFsExistsSync as Mock).mockImplementation((p: fs.PathLike) =>
[USER_SETTINGS_PATH, MOCK_WORKSPACE_SETTINGS_PATH].includes(
p.toString(),
),
);
(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 '{}';
},
);

// Workspace is untrusted
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: false,
source: 'file',
});

loadSettings(MOCK_WORKSPACE_DIR);

// User-level settings.env should still be loaded even when untrusted
expect(process.env['USER_ENV_VAR']).toEqual('user_value');
// Workspace-level settings.env should NOT be loaded (filtered by mergeSettings)
expect(process.env['WORKSPACE_ENV_VAR']).toBeUndefined();
});
});
});

Expand Down
69 changes: 53 additions & 16 deletions packages/cli/src/config/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -798,26 +798,48 @@ export function createMinimalSettings(): LoadedSettings {
);
}

function findEnvFile(startDir: string): string | null {
/**
* Finds the .env file to load, respecting workspace trust settings.
*
* When workspace is untrusted, only allow user-level .env files at:
* - ~/.qwen/.env
* - ~/.env
*/
function findEnvFile(settings: Settings, startDir: string): string | null {
const homeDir = homedir();
const isTrusted = isWorkspaceTrusted(settings).isTrusted;

// Pre-compute user-level .env paths for fast comparison
const userLevelPaths = new Set([
path.normalize(path.join(homeDir, '.env')),
path.normalize(path.join(homeDir, QWEN_DIR, '.env')),
]);

// Determine if we can use this .env file based on trust settings
const canUseEnvFile = (filePath: string): boolean =>
isTrusted !== false || userLevelPaths.has(path.normalize(filePath));

let currentDir = path.resolve(startDir);
while (true) {
// prefer gemini-specific .env under QWEN_DIR
// Prefer gemini-specific .env under QWEN_DIR
const geminiEnvPath = path.join(currentDir, QWEN_DIR, '.env');
if (fs.existsSync(geminiEnvPath)) {
if (fs.existsSync(geminiEnvPath) && canUseEnvFile(geminiEnvPath)) {
return geminiEnvPath;
}

const envPath = path.join(currentDir, '.env');
if (fs.existsSync(envPath)) {
if (fs.existsSync(envPath) && canUseEnvFile(envPath)) {
return envPath;
}

const parentDir = path.dirname(currentDir);
if (parentDir === currentDir || !parentDir) {
// check .env under home as fallback, again preferring gemini-specific .env
const homeGeminiEnvPath = path.join(homedir(), QWEN_DIR, '.env');
// At home directory - check fallback .env files
const homeGeminiEnvPath = path.join(homeDir, QWEN_DIR, '.env');
if (fs.existsSync(homeGeminiEnvPath)) {
return homeGeminiEnvPath;
}
const homeEnvPath = path.join(homedir(), '.env');
const homeEnvPath = path.join(homeDir, '.env');
if (fs.existsSync(homeEnvPath)) {
return homeEnvPath;
}
Expand Down Expand Up @@ -848,22 +870,27 @@ export function setUpCloudShellEnvironment(envFilePath: string | null): void {
process.env['GOOGLE_CLOUD_PROJECT'] = 'cloudshell-gca';
}
}

/**
* Loads environment variables from .env files and settings.env.
*
* Priority order (highest to lowest):
* 1. CLI flags
* 2. process.env (system/export/inline environment variables)
* 3. .env files (no-override mode)
* 4. settings.env (no-override mode)
* 5. defaults
*/
export function loadEnvironment(settings: Settings): void {
const envFilePath = findEnvFile(process.cwd());

if (!isWorkspaceTrusted(settings).isTrusted) {
return;
}
const envFilePath = findEnvFile(settings, process.cwd());

// Cloud Shell environment variable handling
if (process.env['CLOUD_SHELL'] === 'true') {
setUpCloudShellEnvironment(envFilePath);
}

// Step 1: Load from .env files (higher priority than settings.env)
// Only set if not already present in process.env (no-override mode)
if (envFilePath) {
// 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);
Expand All @@ -879,7 +906,7 @@ export function loadEnvironment(settings: Settings): void {
continue;
}

// Load variable only if it's not already set in the environment.
// Only set if not already present in process.env (no-override)
if (!Object.hasOwn(process.env, key)) {
process.env[key] = parsedEnv[key];
}
Expand All @@ -889,6 +916,16 @@ export function loadEnvironment(settings: Settings): void {
// Errors are ignored to match the behavior of `dotenv.config({ quiet: true })`.
}
}

// Step 2: Load environment variables from settings.env as fallback (lowest priority)
// Only set if not already present (no-override, after .env is loaded)
if (settings.env) {
for (const [key, value] of Object.entries(settings.env)) {
if (!Object.hasOwn(process.env, key) && typeof value === 'string') {
process.env[key] = value;
}
}
}
}

/**
Expand Down
Loading