Skip to content

Commit a5d2ca9

Browse files
authored
Merge pull request #1751 from QwenLM/feat/settings-env-field
feat(settings): add settings.env field for environment variable configuration
2 parents 36931e1 + 842ff42 commit a5d2ca9

6 files changed

Lines changed: 383 additions & 79 deletions

File tree

packages/cli/src/config/settings.test.ts

Lines changed: 266 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2685,11 +2685,274 @@ describe('Settings Loading and Merging', () => {
26852685
expect(process.env['TESTTEST']).toEqual('1234');
26862686
});
26872687

2688-
it('does not load env files from untrusted spaces', () => {
2689-
setup({ isFolderTrustEnabled: true, isWorkspaceTrustedValue: false });
2688+
it('does not load project .env files from untrusted workspaces', () => {
2689+
delete process.env['PROJECT_ENV_VAR'];
2690+
const cwdSpy = vi
2691+
.spyOn(process, 'cwd')
2692+
.mockReturnValue(MOCK_WORKSPACE_DIR);
2693+
2694+
const projectEnvPath = path.join(MOCK_WORKSPACE_DIR, '.env');
2695+
2696+
vi.mocked(isWorkspaceTrusted).mockReturnValue({
2697+
isTrusted: false,
2698+
source: 'file',
2699+
});
2700+
(mockFsExistsSync as Mock).mockImplementation((p: fs.PathLike) =>
2701+
[USER_SETTINGS_PATH, projectEnvPath].includes(p.toString()),
2702+
);
2703+
const userSettingsContent: Settings = {
2704+
ui: {
2705+
theme: 'dark',
2706+
},
2707+
security: {
2708+
folderTrust: {
2709+
enabled: true,
2710+
},
2711+
},
2712+
};
2713+
(fs.readFileSync as Mock).mockImplementation(
2714+
(p: fs.PathOrFileDescriptor) => {
2715+
if (p === USER_SETTINGS_PATH)
2716+
return JSON.stringify(userSettingsContent);
2717+
if (p === projectEnvPath) return 'PROJECT_ENV_VAR=from_project';
2718+
return '{}';
2719+
},
2720+
);
2721+
26902722
loadEnvironment(loadSettings(MOCK_WORKSPACE_DIR).merged);
26912723

2692-
expect(process.env['TESTTEST']).not.toEqual('1234');
2724+
// Project .env should NOT be loaded when workspace is untrusted
2725+
expect(process.env['PROJECT_ENV_VAR']).toBeUndefined();
2726+
cwdSpy.mockRestore();
2727+
});
2728+
2729+
describe('settings.env field', () => {
2730+
const originalEnv = { ...process.env };
2731+
2732+
beforeEach(() => {
2733+
process.env = { ...originalEnv };
2734+
delete process.env['ENV_FROM_SETTINGS'];
2735+
delete process.env['ENV_OVERRIDE_TEST'];
2736+
delete process.env['SYSTEM_ENV_VAR'];
2737+
delete process.env['MULTI_VAR_A'];
2738+
delete process.env['MULTI_VAR_B'];
2739+
delete process.env['MULTI_VAR_C'];
2740+
delete process.env['USER_ENV_VAR'];
2741+
delete process.env['WORKSPACE_ENV_VAR'];
2742+
});
2743+
2744+
afterEach(() => {
2745+
process.env = originalEnv;
2746+
});
2747+
2748+
it('should load environment variables from settings.env as fallback', () => {
2749+
const userSettingsContent: Settings = {
2750+
env: {
2751+
ENV_FROM_SETTINGS: 'settings_value',
2752+
},
2753+
};
2754+
2755+
(mockFsExistsSync as Mock).mockImplementation((p: fs.PathLike) =>
2756+
[USER_SETTINGS_PATH].includes(p.toString()),
2757+
);
2758+
(fs.readFileSync as Mock).mockImplementation(
2759+
(p: fs.PathOrFileDescriptor) => {
2760+
if (p === USER_SETTINGS_PATH)
2761+
return JSON.stringify(userSettingsContent);
2762+
return '{}';
2763+
},
2764+
);
2765+
2766+
vi.mocked(isWorkspaceTrusted).mockReturnValue({
2767+
isTrusted: true,
2768+
source: 'file',
2769+
});
2770+
2771+
// loadSettings internally calls loadEnvironment with userSettings
2772+
loadSettings(MOCK_WORKSPACE_DIR);
2773+
2774+
expect(process.env['ENV_FROM_SETTINGS']).toEqual('settings_value');
2775+
});
2776+
2777+
it('should allow .env file to override settings.env values', () => {
2778+
const geminiEnvPath = path.resolve(path.join(QWEN_DIR, '.env'));
2779+
const userSettingsContent: Settings = {
2780+
env: {
2781+
ENV_OVERRIDE_TEST: 'from_settings',
2782+
},
2783+
};
2784+
2785+
(mockFsExistsSync as Mock).mockImplementation((p: fs.PathLike) =>
2786+
[USER_SETTINGS_PATH, geminiEnvPath].includes(p.toString()),
2787+
);
2788+
(fs.readFileSync as Mock).mockImplementation(
2789+
(p: fs.PathOrFileDescriptor) => {
2790+
if (p === USER_SETTINGS_PATH)
2791+
return JSON.stringify(userSettingsContent);
2792+
if (p === geminiEnvPath) return 'ENV_OVERRIDE_TEST=from_dotenv';
2793+
return '{}';
2794+
},
2795+
);
2796+
2797+
vi.mocked(isWorkspaceTrusted).mockReturnValue({
2798+
isTrusted: true,
2799+
source: 'file',
2800+
});
2801+
2802+
// loadSettings internally calls loadEnvironment with merged settings
2803+
loadSettings(MOCK_WORKSPACE_DIR);
2804+
2805+
// .env file has higher priority than settings.env (loaded first, no-override)
2806+
expect(process.env['ENV_OVERRIDE_TEST']).toEqual('from_dotenv');
2807+
});
2808+
2809+
it('should not override existing system environment variables', () => {
2810+
process.env['SYSTEM_ENV_VAR'] = 'system_value';
2811+
2812+
const geminiEnvPath = path.resolve(path.join(QWEN_DIR, '.env'));
2813+
const userSettingsContent: Settings = {
2814+
env: {
2815+
SYSTEM_ENV_VAR: 'from_settings',
2816+
},
2817+
};
2818+
2819+
(mockFsExistsSync as Mock).mockImplementation((p: fs.PathLike) =>
2820+
[USER_SETTINGS_PATH, geminiEnvPath].includes(p.toString()),
2821+
);
2822+
(fs.readFileSync as Mock).mockImplementation(
2823+
(p: fs.PathOrFileDescriptor) => {
2824+
if (p === USER_SETTINGS_PATH)
2825+
return JSON.stringify(userSettingsContent);
2826+
if (p === geminiEnvPath) return 'SYSTEM_ENV_VAR=from_dotenv';
2827+
return '{}';
2828+
},
2829+
);
2830+
2831+
vi.mocked(isWorkspaceTrusted).mockReturnValue({
2832+
isTrusted: true,
2833+
source: 'file',
2834+
});
2835+
2836+
// loadSettings internally calls loadEnvironment with userSettings
2837+
loadSettings(MOCK_WORKSPACE_DIR);
2838+
2839+
// System environment variable should have highest priority
2840+
expect(process.env['SYSTEM_ENV_VAR']).toEqual('system_value');
2841+
});
2842+
2843+
it('should support multiple env variables in settings.env', () => {
2844+
const userSettingsContent: Settings = {
2845+
env: {
2846+
MULTI_VAR_A: 'value_a',
2847+
MULTI_VAR_B: 'value_b',
2848+
MULTI_VAR_C: 'value_c',
2849+
},
2850+
};
2851+
2852+
(mockFsExistsSync as Mock).mockImplementation((p: fs.PathLike) =>
2853+
[USER_SETTINGS_PATH].includes(p.toString()),
2854+
);
2855+
(fs.readFileSync as Mock).mockImplementation(
2856+
(p: fs.PathOrFileDescriptor) => {
2857+
if (p === USER_SETTINGS_PATH)
2858+
return JSON.stringify(userSettingsContent);
2859+
return '{}';
2860+
},
2861+
);
2862+
2863+
vi.mocked(isWorkspaceTrusted).mockReturnValue({
2864+
isTrusted: true,
2865+
source: 'file',
2866+
});
2867+
2868+
// loadSettings internally calls loadEnvironment with userSettings
2869+
loadSettings(MOCK_WORKSPACE_DIR);
2870+
2871+
expect(process.env['MULTI_VAR_A']).toEqual('value_a');
2872+
expect(process.env['MULTI_VAR_B']).toEqual('value_b');
2873+
expect(process.env['MULTI_VAR_C']).toEqual('value_c');
2874+
});
2875+
2876+
it('should load settings.env from both user and workspace settings', () => {
2877+
const workspaceSettingsContent = {
2878+
env: {
2879+
WORKSPACE_ENV_VAR: 'workspace_value',
2880+
},
2881+
};
2882+
const userSettingsContent: Settings = {
2883+
env: {
2884+
USER_ENV_VAR: 'user_value',
2885+
},
2886+
};
2887+
2888+
(mockFsExistsSync as Mock).mockImplementation((p: fs.PathLike) =>
2889+
[USER_SETTINGS_PATH, MOCK_WORKSPACE_SETTINGS_PATH].includes(
2890+
p.toString(),
2891+
),
2892+
);
2893+
(fs.readFileSync as Mock).mockImplementation(
2894+
(p: fs.PathOrFileDescriptor) => {
2895+
if (p === USER_SETTINGS_PATH)
2896+
return JSON.stringify(userSettingsContent);
2897+
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
2898+
return JSON.stringify(workspaceSettingsContent);
2899+
return '{}';
2900+
},
2901+
);
2902+
2903+
vi.mocked(isWorkspaceTrusted).mockReturnValue({
2904+
isTrusted: true,
2905+
source: 'file',
2906+
});
2907+
2908+
// loadSettings internally calls loadEnvironment with merged settings
2909+
loadSettings(MOCK_WORKSPACE_DIR);
2910+
2911+
// Both user-level and workspace-level env should be loaded
2912+
expect(process.env['USER_ENV_VAR']).toEqual('user_value');
2913+
expect(process.env['WORKSPACE_ENV_VAR']).toEqual('workspace_value');
2914+
});
2915+
2916+
it('should load user-level settings.env even when workspace is untrusted', () => {
2917+
const userSettingsContent: Settings = {
2918+
env: {
2919+
USER_ENV_VAR: 'user_value',
2920+
},
2921+
};
2922+
const workspaceSettingsContent = {
2923+
env: {
2924+
WORKSPACE_ENV_VAR: 'workspace_value',
2925+
},
2926+
};
2927+
2928+
(mockFsExistsSync as Mock).mockImplementation((p: fs.PathLike) =>
2929+
[USER_SETTINGS_PATH, MOCK_WORKSPACE_SETTINGS_PATH].includes(
2930+
p.toString(),
2931+
),
2932+
);
2933+
(fs.readFileSync as Mock).mockImplementation(
2934+
(p: fs.PathOrFileDescriptor) => {
2935+
if (p === USER_SETTINGS_PATH)
2936+
return JSON.stringify(userSettingsContent);
2937+
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
2938+
return JSON.stringify(workspaceSettingsContent);
2939+
return '{}';
2940+
},
2941+
);
2942+
2943+
// Workspace is untrusted
2944+
vi.mocked(isWorkspaceTrusted).mockReturnValue({
2945+
isTrusted: false,
2946+
source: 'file',
2947+
});
2948+
2949+
loadSettings(MOCK_WORKSPACE_DIR);
2950+
2951+
// User-level settings.env should still be loaded even when untrusted
2952+
expect(process.env['USER_ENV_VAR']).toEqual('user_value');
2953+
// Workspace-level settings.env should NOT be loaded (filtered by mergeSettings)
2954+
expect(process.env['WORKSPACE_ENV_VAR']).toBeUndefined();
2955+
});
26932956
});
26942957
});
26952958

packages/cli/src/config/settings.ts

Lines changed: 53 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -798,26 +798,48 @@ export function createMinimalSettings(): LoadedSettings {
798798
);
799799
}
800800

801-
function findEnvFile(startDir: string): string | null {
801+
/**
802+
* Finds the .env file to load, respecting workspace trust settings.
803+
*
804+
* When workspace is untrusted, only allow user-level .env files at:
805+
* - ~/.qwen/.env
806+
* - ~/.env
807+
*/
808+
function findEnvFile(settings: Settings, startDir: string): string | null {
809+
const homeDir = homedir();
810+
const isTrusted = isWorkspaceTrusted(settings).isTrusted;
811+
812+
// Pre-compute user-level .env paths for fast comparison
813+
const userLevelPaths = new Set([
814+
path.normalize(path.join(homeDir, '.env')),
815+
path.normalize(path.join(homeDir, QWEN_DIR, '.env')),
816+
]);
817+
818+
// Determine if we can use this .env file based on trust settings
819+
const canUseEnvFile = (filePath: string): boolean =>
820+
isTrusted !== false || userLevelPaths.has(path.normalize(filePath));
821+
802822
let currentDir = path.resolve(startDir);
803823
while (true) {
804-
// prefer gemini-specific .env under QWEN_DIR
824+
// Prefer gemini-specific .env under QWEN_DIR
805825
const geminiEnvPath = path.join(currentDir, QWEN_DIR, '.env');
806-
if (fs.existsSync(geminiEnvPath)) {
826+
if (fs.existsSync(geminiEnvPath) && canUseEnvFile(geminiEnvPath)) {
807827
return geminiEnvPath;
808828
}
829+
809830
const envPath = path.join(currentDir, '.env');
810-
if (fs.existsSync(envPath)) {
831+
if (fs.existsSync(envPath) && canUseEnvFile(envPath)) {
811832
return envPath;
812833
}
834+
813835
const parentDir = path.dirname(currentDir);
814836
if (parentDir === currentDir || !parentDir) {
815-
// check .env under home as fallback, again preferring gemini-specific .env
816-
const homeGeminiEnvPath = path.join(homedir(), QWEN_DIR, '.env');
837+
// At home directory - check fallback .env files
838+
const homeGeminiEnvPath = path.join(homeDir, QWEN_DIR, '.env');
817839
if (fs.existsSync(homeGeminiEnvPath)) {
818840
return homeGeminiEnvPath;
819841
}
820-
const homeEnvPath = path.join(homedir(), '.env');
842+
const homeEnvPath = path.join(homeDir, '.env');
821843
if (fs.existsSync(homeEnvPath)) {
822844
return homeEnvPath;
823845
}
@@ -848,22 +870,27 @@ export function setUpCloudShellEnvironment(envFilePath: string | null): void {
848870
process.env['GOOGLE_CLOUD_PROJECT'] = 'cloudshell-gca';
849871
}
850872
}
851-
873+
/**
874+
* Loads environment variables from .env files and settings.env.
875+
*
876+
* Priority order (highest to lowest):
877+
* 1. CLI flags
878+
* 2. process.env (system/export/inline environment variables)
879+
* 3. .env files (no-override mode)
880+
* 4. settings.env (no-override mode)
881+
* 5. defaults
882+
*/
852883
export function loadEnvironment(settings: Settings): void {
853-
const envFilePath = findEnvFile(process.cwd());
854-
855-
if (!isWorkspaceTrusted(settings).isTrusted) {
856-
return;
857-
}
884+
const envFilePath = findEnvFile(settings, process.cwd());
858885

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

891+
// Step 1: Load from .env files (higher priority than settings.env)
892+
// Only set if not already present in process.env (no-override mode)
864893
if (envFilePath) {
865-
// Manually parse and load environment variables to handle exclusions correctly.
866-
// This avoids modifying environment variables that were already set from the shell.
867894
try {
868895
const envFileContent = fs.readFileSync(envFilePath, 'utf-8');
869896
const parsedEnv = dotenv.parse(envFileContent);
@@ -879,7 +906,7 @@ export function loadEnvironment(settings: Settings): void {
879906
continue;
880907
}
881908

882-
// Load variable only if it's not already set in the environment.
909+
// Only set if not already present in process.env (no-override)
883910
if (!Object.hasOwn(process.env, key)) {
884911
process.env[key] = parsedEnv[key];
885912
}
@@ -889,6 +916,16 @@ export function loadEnvironment(settings: Settings): void {
889916
// Errors are ignored to match the behavior of `dotenv.config({ quiet: true })`.
890917
}
891918
}
919+
920+
// Step 2: Load environment variables from settings.env as fallback (lowest priority)
921+
// Only set if not already present (no-override, after .env is loaded)
922+
if (settings.env) {
923+
for (const [key, value] of Object.entries(settings.env)) {
924+
if (!Object.hasOwn(process.env, key) && typeof value === 'string') {
925+
process.env[key] = value;
926+
}
927+
}
928+
}
892929
}
893930

894931
/**

0 commit comments

Comments
 (0)