mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-28 11:41:04 +00:00
* add http/async/function type * fix url error * resolve comment * align cc non blocking error * fix hookRunner for async * fix(hooks): update hook type validation to support http and function types - Change validated hook types from ['command', 'plugin'] to ['command', 'http', 'function'] - Add validation for HTTP hooks requiring url field - Add validation for function hooks requiring callback field - Add comprehensive test coverage for all hook type validations Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(hooks): align SSRF protection with Claude Code behavior - Allow 127.0.0.0/8 (loopback) for local dev hooks - Allow localhost hostname for local dev hooks - Allow ::1 (IPv6 loopback) for local dev hooks - Add 100.64.0.0/10 (CGNAT) to blocked ranges (RFC 6598) - Update tests to match Claude Code's ssrfGuard.ts behavior This fixes HTTP hooks failing to connect to local dev servers. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * refactor(hooks): align HTTP hook security with Claude Code behavior - Add CRLF/NUL sanitization for env var interpolation (header injection) - Implement combined abort signal (external signal + timeout) - Upgrade SSRF protection to DNS-level with ssrfGuard - Allow loopback (127.0.0.0/8, ::1) for local dev hooks - Block CGNAT (100.64.0.0/10) and IPv6 private ranges - Increase default HTTP hook timeout to 10 minutes - Fix VS Code hooks schema to support http type - Add url, headers, allowedEnvVars, async, once, statusMessage, shell fields - Note: "function" type is SDK-only (callback cannot be serialized to JSON) * feat(hooks): enhance Function Hook with messages, skillRoot, shell, and matcher support - Add MessagesProvider for automatic conversation history passing to function hooks - Add FunctionHookContext with messages, toolUseID, and signal - Add skillRoot support for skill-scoped session hooks - Add shell parameter support for command hooks (bash/powershell) - Add regex matcher support for hook pattern matching - Add statusMessage to CommandHookConfig - Change default function hook timeout from 60s to 5s - Add comprehensive unit tests for all new features Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * add session hook for skill * fix function hook parsing * refactor ui for http hook/async hook/function hook * update doc and add integration test * change telemetryn type and refactor SSRF * fix project level bug --------- Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
945 lines
30 KiB
TypeScript
945 lines
30 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import * as fs from 'node:fs';
|
|
import * as path from 'node:path';
|
|
import { homedir, platform } from 'node:os';
|
|
import * as dotenv from 'dotenv';
|
|
import process from 'node:process';
|
|
import {
|
|
FatalConfigError,
|
|
QWEN_DIR,
|
|
getErrorMessage,
|
|
Storage,
|
|
createDebugLogger,
|
|
} from '@qwen-code/qwen-code-core';
|
|
import stripJsonComments from 'strip-json-comments';
|
|
import { DefaultLight } from '../ui/themes/default-light.js';
|
|
import { DefaultDark } from '../ui/themes/default.js';
|
|
import { isWorkspaceTrusted } from './trustedFolders.js';
|
|
import { hasOwnModelProviders } from './modelProvidersScope.js';
|
|
import {
|
|
type Settings,
|
|
type MemoryImportFormat,
|
|
type MergeStrategy,
|
|
type SettingsSchema,
|
|
type SettingDefinition,
|
|
getSettingsSchema,
|
|
} from './settingsSchema.js';
|
|
import { resolveEnvVarsInObject } from '../utils/envVarResolver.js';
|
|
import { setNestedPropertySafe } from '../utils/settingsUtils.js';
|
|
import { customDeepMerge } from '../utils/deepMerge.js';
|
|
import { updateSettingsFilePreservingFormat } from '../utils/commentJson.js';
|
|
import { runMigrations, needsMigration } from './migration/index.js';
|
|
import {
|
|
V1_TO_V2_MIGRATION_MAP,
|
|
V2_CONTAINER_KEYS,
|
|
} from './migration/versions/v1-to-v2-shared.js';
|
|
import { writeWithBackupSync } from '../utils/writeWithBackup.js';
|
|
|
|
const debugLogger = createDebugLogger('SETTINGS');
|
|
|
|
function getMergeStrategyForPath(path: string[]): MergeStrategy | undefined {
|
|
let current: SettingDefinition | undefined = undefined;
|
|
let currentSchema: SettingsSchema | undefined = getSettingsSchema();
|
|
|
|
for (const key of path) {
|
|
if (!currentSchema || !currentSchema[key]) {
|
|
return undefined;
|
|
}
|
|
current = currentSchema[key];
|
|
currentSchema = current.properties;
|
|
}
|
|
|
|
return current?.mergeStrategy;
|
|
}
|
|
|
|
export type { Settings, MemoryImportFormat };
|
|
|
|
export const SETTINGS_DIRECTORY_NAME = '.qwen';
|
|
export const USER_SETTINGS_PATH = Storage.getGlobalSettingsPath();
|
|
export const USER_SETTINGS_DIR = path.dirname(USER_SETTINGS_PATH);
|
|
export const DEFAULT_EXCLUDED_ENV_VARS = ['DEBUG', 'DEBUG_MODE'];
|
|
|
|
// Settings version to track migration state
|
|
export const SETTINGS_VERSION = 3;
|
|
export const SETTINGS_VERSION_KEY = '$version';
|
|
|
|
/**
|
|
* Migrate legacy tool permission settings (tools.core / tools.allowed / tools.exclude)
|
|
* to the new permissions.allow / permissions.ask / permissions.deny format.
|
|
*
|
|
* Conversion rules:
|
|
* tools.allowed → permissions.allow (bypass confirmation)
|
|
* tools.exclude → permissions.deny (block tools)
|
|
* tools.core → permissions.allow (only listed tools enabled)
|
|
* + permissions.deny with a wildcard deny-all if needed
|
|
*
|
|
* Returns the updated settings object, or null if no migration is needed.
|
|
*/
|
|
export function migrateLegacyPermissions(
|
|
settings: Record<string, unknown>,
|
|
): Record<string, unknown> | null {
|
|
const tools = settings['tools'] as Record<string, unknown> | undefined;
|
|
if (!tools) return null;
|
|
|
|
const hasLegacy =
|
|
Array.isArray(tools['core']) ||
|
|
Array.isArray(tools['allowed']) ||
|
|
Array.isArray(tools['exclude']);
|
|
|
|
if (!hasLegacy) return null;
|
|
|
|
const result = structuredClone(settings) as Record<string, unknown>;
|
|
const resultTools = result['tools'] as Record<string, unknown>;
|
|
const permissions = (result['permissions'] as Record<string, unknown>) ?? {};
|
|
result['permissions'] = permissions;
|
|
|
|
const mergeInto = (key: string, items: string[]) => {
|
|
const existing = Array.isArray(permissions[key])
|
|
? (permissions[key] as string[])
|
|
: [];
|
|
const merged = Array.from(new Set([...existing, ...items]));
|
|
permissions[key] = merged;
|
|
};
|
|
|
|
// tools.allowed → permissions.allow
|
|
if (Array.isArray(resultTools['allowed'])) {
|
|
mergeInto('allow', resultTools['allowed'] as string[]);
|
|
delete resultTools['allowed'];
|
|
}
|
|
|
|
// tools.exclude → permissions.deny
|
|
if (Array.isArray(resultTools['exclude'])) {
|
|
mergeInto('deny', resultTools['exclude'] as string[]);
|
|
delete resultTools['exclude'];
|
|
}
|
|
|
|
// tools.core → permissions.allow (explicit enables)
|
|
// IMPORTANT: tools.core has whitelist semantics: "only these tools can run".
|
|
// To preserve this, we also add deny rules for all tools NOT in the list.
|
|
// A wildcard deny-all followed by specific allows achieves this because
|
|
// allow rules take precedence over the catch-all deny in the evaluation order:
|
|
// deny = [everything not listed], allow = [listed tools]
|
|
// However, since our priority is deny > allow, we cannot use a blanket deny.
|
|
// Instead we just migrate to allow (auto-approve) and let the coreTools
|
|
// semantics continue to work through the Config.getCoreTools() path until
|
|
// the old API is fully removed.
|
|
if (Array.isArray(resultTools['core'])) {
|
|
mergeInto('allow', resultTools['core'] as string[]);
|
|
delete resultTools['core'];
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
export function getSystemSettingsPath(): string {
|
|
if (process.env['QWEN_CODE_SYSTEM_SETTINGS_PATH']) {
|
|
return process.env['QWEN_CODE_SYSTEM_SETTINGS_PATH'];
|
|
}
|
|
if (platform() === 'darwin') {
|
|
return '/Library/Application Support/QwenCode/settings.json';
|
|
} else if (platform() === 'win32') {
|
|
return 'C:\\ProgramData\\qwen-code\\settings.json';
|
|
} else {
|
|
return '/etc/qwen-code/settings.json';
|
|
}
|
|
}
|
|
|
|
export function getSystemDefaultsPath(): string {
|
|
if (process.env['QWEN_CODE_SYSTEM_DEFAULTS_PATH']) {
|
|
return process.env['QWEN_CODE_SYSTEM_DEFAULTS_PATH'];
|
|
}
|
|
return path.join(
|
|
path.dirname(getSystemSettingsPath()),
|
|
'system-defaults.json',
|
|
);
|
|
}
|
|
|
|
export type { DnsResolutionOrder } from './settingsSchema.js';
|
|
|
|
export enum SettingScope {
|
|
User = 'User',
|
|
Workspace = 'Workspace',
|
|
System = 'System',
|
|
SystemDefaults = 'SystemDefaults',
|
|
}
|
|
|
|
export interface CheckpointingSettings {
|
|
enabled?: boolean;
|
|
}
|
|
|
|
export interface AccessibilitySettings {
|
|
enableLoadingPhrases?: boolean;
|
|
screenReader?: boolean;
|
|
}
|
|
|
|
export interface SettingsError {
|
|
message: string;
|
|
path: string;
|
|
}
|
|
|
|
export interface SettingsFile {
|
|
settings: Settings;
|
|
originalSettings: Settings;
|
|
path: string;
|
|
rawJson?: string;
|
|
}
|
|
|
|
function getSettingsFileKeyWarnings(
|
|
settings: Record<string, unknown>,
|
|
settingsFilePath: string,
|
|
): string[] {
|
|
const version = settings[SETTINGS_VERSION_KEY];
|
|
if (typeof version !== 'number' || version < SETTINGS_VERSION) {
|
|
return [];
|
|
}
|
|
|
|
const warnings: string[] = [];
|
|
const ignoredLegacyKeys = new Set<string>();
|
|
|
|
// Ignored legacy keys (V1 top-level keys that moved to a nested V2 path).
|
|
for (const [oldKey, newPath] of Object.entries(V1_TO_V2_MIGRATION_MAP)) {
|
|
if (oldKey === newPath) {
|
|
continue;
|
|
}
|
|
if (!(oldKey in settings)) {
|
|
continue;
|
|
}
|
|
|
|
const oldValue = settings[oldKey];
|
|
|
|
// If this key is a V2 container (like 'model') and it's already an object,
|
|
// it's likely already in V2 format. Don't warn.
|
|
if (
|
|
V2_CONTAINER_KEYS.has(oldKey) &&
|
|
typeof oldValue === 'object' &&
|
|
oldValue !== null &&
|
|
!Array.isArray(oldValue)
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
ignoredLegacyKeys.add(oldKey);
|
|
warnings.push(
|
|
`Warning: Legacy setting '${oldKey}' will be ignored in ${settingsFilePath}. Please use '${newPath}' instead.`,
|
|
);
|
|
}
|
|
|
|
// Unknown top-level keys — log silently to debug output.
|
|
const schemaKeys = new Set(Object.keys(getSettingsSchema()));
|
|
for (const key of Object.keys(settings)) {
|
|
if (key === SETTINGS_VERSION_KEY) {
|
|
continue;
|
|
}
|
|
if (ignoredLegacyKeys.has(key)) {
|
|
continue;
|
|
}
|
|
if (schemaKeys.has(key)) {
|
|
continue;
|
|
}
|
|
|
|
debugLogger.warn(
|
|
`Unknown setting '${key}' will be ignored in ${settingsFilePath}.`,
|
|
);
|
|
}
|
|
|
|
return warnings;
|
|
}
|
|
|
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
function hasAnyProviderEntries(modelProviders: unknown): boolean {
|
|
if (!isPlainObject(modelProviders)) {
|
|
return false;
|
|
}
|
|
|
|
return Object.values(modelProviders).some(
|
|
(providerModels) =>
|
|
Array.isArray(providerModels) && providerModels.length > 0,
|
|
);
|
|
}
|
|
|
|
function getModelProvidersOverrideWarnings(
|
|
loadedSettings: LoadedSettings,
|
|
): string[] {
|
|
// Untrusted workspaces are ignored in merge, so they cannot shadow user modelProviders.
|
|
if (!loadedSettings.isTrusted) {
|
|
return [];
|
|
}
|
|
|
|
const userOriginal = loadedSettings.user
|
|
.originalSettings as unknown as Record<string, unknown>;
|
|
const workspaceOriginal = loadedSettings.workspace
|
|
.originalSettings as unknown as Record<string, unknown>;
|
|
|
|
if (
|
|
!hasOwnModelProviders(userOriginal) ||
|
|
!hasOwnModelProviders(workspaceOriginal)
|
|
) {
|
|
return [];
|
|
}
|
|
|
|
const userModelProviders = userOriginal['modelProviders'];
|
|
const workspaceModelProviders = workspaceOriginal['modelProviders'];
|
|
const workspaceIsEmptyModelProviders =
|
|
isPlainObject(workspaceModelProviders) &&
|
|
Object.keys(workspaceModelProviders).length === 0;
|
|
|
|
if (
|
|
!workspaceIsEmptyModelProviders ||
|
|
!hasAnyProviderEntries(userModelProviders)
|
|
) {
|
|
return [];
|
|
}
|
|
|
|
return [
|
|
`Warning: '${loadedSettings.workspace.path}' defines an empty 'modelProviders' object. ` +
|
|
`This has no effect with current merge behavior, but may indicate a configuration error. ` +
|
|
`If REPLACE semantics are introduced for 'modelProviders' in the future, this would override user-level model providers in '${loadedSettings.user.path}'.`,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Collects warnings for ignored legacy and unknown settings keys,
|
|
* as well as migration warnings.
|
|
*
|
|
* For `$version: 2` settings files, we do not apply implicit migrations.
|
|
* Instead, we surface actionable, de-duplicated warnings in the terminal UI.
|
|
*/
|
|
export function getSettingsWarnings(loadedSettings: LoadedSettings): string[] {
|
|
const warningSet = new Set<string>();
|
|
|
|
// Add migration warnings first
|
|
for (const warning of loadedSettings.migrationWarnings) {
|
|
warningSet.add(`Warning: ${warning}`);
|
|
}
|
|
|
|
for (const scope of [SettingScope.User, SettingScope.Workspace]) {
|
|
const settingsFile = loadedSettings.forScope(scope);
|
|
if (settingsFile.rawJson === undefined) {
|
|
continue;
|
|
// File not present / not loaded.
|
|
}
|
|
const settingsObject = settingsFile.originalSettings as unknown as Record<
|
|
string,
|
|
unknown
|
|
>;
|
|
|
|
for (const warning of getSettingsFileKeyWarnings(
|
|
settingsObject,
|
|
settingsFile.path,
|
|
)) {
|
|
warningSet.add(warning);
|
|
}
|
|
}
|
|
|
|
for (const warning of getModelProvidersOverrideWarnings(loadedSettings)) {
|
|
warningSet.add(warning);
|
|
}
|
|
|
|
return [...warningSet];
|
|
}
|
|
|
|
function mergeSettings(
|
|
system: Settings,
|
|
systemDefaults: Settings,
|
|
user: Settings,
|
|
workspace: Settings,
|
|
isTrusted: boolean,
|
|
): Settings {
|
|
const safeWorkspace = isTrusted ? workspace : ({} as Settings);
|
|
|
|
// Settings are merged with the following precedence (last one wins for
|
|
// single values):
|
|
// 1. System Defaults
|
|
// 2. User Settings
|
|
// 3. Workspace Settings
|
|
// 4. System Settings (as overrides)
|
|
return customDeepMerge(
|
|
getMergeStrategyForPath,
|
|
{}, // Start with an empty object
|
|
systemDefaults,
|
|
user,
|
|
safeWorkspace,
|
|
system,
|
|
) as Settings;
|
|
}
|
|
|
|
export class LoadedSettings {
|
|
constructor(
|
|
system: SettingsFile,
|
|
systemDefaults: SettingsFile,
|
|
user: SettingsFile,
|
|
workspace: SettingsFile,
|
|
isTrusted: boolean,
|
|
migratedInMemorScopes: Set<SettingScope>,
|
|
migrationWarnings: string[] = [],
|
|
) {
|
|
this.system = system;
|
|
this.systemDefaults = systemDefaults;
|
|
this.user = user;
|
|
this.workspace = workspace;
|
|
this.isTrusted = isTrusted;
|
|
this.migratedInMemorScopes = migratedInMemorScopes;
|
|
this.migrationWarnings = migrationWarnings;
|
|
this._merged = this.computeMergedSettings();
|
|
}
|
|
|
|
readonly system: SettingsFile;
|
|
readonly systemDefaults: SettingsFile;
|
|
readonly user: SettingsFile;
|
|
readonly workspace: SettingsFile;
|
|
readonly isTrusted: boolean;
|
|
readonly migratedInMemorScopes: Set<SettingScope>;
|
|
readonly migrationWarnings: string[];
|
|
|
|
private _merged: Settings;
|
|
|
|
get merged(): Settings {
|
|
return this._merged;
|
|
}
|
|
|
|
private computeMergedSettings(): Settings {
|
|
return mergeSettings(
|
|
this.system.settings,
|
|
this.systemDefaults.settings,
|
|
this.user.settings,
|
|
this.workspace.settings,
|
|
this.isTrusted,
|
|
);
|
|
}
|
|
|
|
forScope(scope: SettingScope): SettingsFile {
|
|
switch (scope) {
|
|
case SettingScope.User:
|
|
return this.user;
|
|
case SettingScope.Workspace:
|
|
return this.workspace;
|
|
case SettingScope.System:
|
|
return this.system;
|
|
case SettingScope.SystemDefaults:
|
|
return this.systemDefaults;
|
|
default:
|
|
throw new Error(`Invalid scope: ${scope}`);
|
|
}
|
|
}
|
|
|
|
setValue(scope: SettingScope, key: string, value: unknown): void {
|
|
const settingsFile = this.forScope(scope);
|
|
setNestedPropertySafe(settingsFile.settings, key, value);
|
|
setNestedPropertySafe(settingsFile.originalSettings, key, value);
|
|
this._merged = this.computeMergedSettings();
|
|
saveSettings(settingsFile, createSettingsUpdate(key, value));
|
|
}
|
|
|
|
/**
|
|
* Get user-level hooks from user settings (not merged with workspace).
|
|
* These hooks should always be loaded regardless of folder trust.
|
|
*/
|
|
getUserHooks(): Record<string, unknown> | undefined {
|
|
return this.user.settings.hooks;
|
|
}
|
|
|
|
/**
|
|
* Get project-level hooks from workspace settings (not merged).
|
|
* Returns undefined if workspace is not trusted (hooks filtered out).
|
|
*/
|
|
getProjectHooks(): Record<string, unknown> | undefined {
|
|
// Only return project hooks if workspace is trusted
|
|
if (!this.isTrusted) {
|
|
return undefined;
|
|
}
|
|
return this.workspace.settings.hooks;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Creates a minimal LoadedSettings instance with empty settings.
|
|
* Used in stream-json mode where settings are ignored.
|
|
*/
|
|
export function createMinimalSettings(): LoadedSettings {
|
|
const emptySettingsFile: SettingsFile = {
|
|
path: '',
|
|
settings: {},
|
|
originalSettings: {},
|
|
rawJson: '{}',
|
|
};
|
|
return new LoadedSettings(
|
|
emptySettingsFile,
|
|
emptySettingsFile,
|
|
emptySettingsFile,
|
|
emptySettingsFile,
|
|
false,
|
|
new Set(),
|
|
[],
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
const geminiEnvPath = path.join(currentDir, QWEN_DIR, '.env');
|
|
if (fs.existsSync(geminiEnvPath) && canUseEnvFile(geminiEnvPath)) {
|
|
return geminiEnvPath;
|
|
}
|
|
|
|
const envPath = path.join(currentDir, '.env');
|
|
if (fs.existsSync(envPath) && canUseEnvFile(envPath)) {
|
|
return envPath;
|
|
}
|
|
|
|
const parentDir = path.dirname(currentDir);
|
|
if (parentDir === currentDir || !parentDir) {
|
|
// 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');
|
|
if (fs.existsSync(homeEnvPath)) {
|
|
return homeEnvPath;
|
|
}
|
|
return null;
|
|
}
|
|
currentDir = parentDir;
|
|
}
|
|
}
|
|
|
|
export function setUpCloudShellEnvironment(envFilePath: string | null): void {
|
|
// Special handling for GOOGLE_CLOUD_PROJECT in Cloud Shell:
|
|
// Because GOOGLE_CLOUD_PROJECT in Cloud Shell tracks the project
|
|
// set by the user using "gcloud config set project" we do not want to
|
|
// use its value. So, unless the user overrides GOOGLE_CLOUD_PROJECT in
|
|
// one of the .env files, we set the Cloud Shell-specific default here.
|
|
if (envFilePath && fs.existsSync(envFilePath)) {
|
|
const envFileContent = fs.readFileSync(envFilePath);
|
|
const parsedEnv = dotenv.parse(envFileContent);
|
|
if (parsedEnv['GOOGLE_CLOUD_PROJECT']) {
|
|
// .env file takes precedence in Cloud Shell
|
|
process.env['GOOGLE_CLOUD_PROJECT'] = parsedEnv['GOOGLE_CLOUD_PROJECT'];
|
|
} else {
|
|
// If not in .env, set to default and override global
|
|
process.env['GOOGLE_CLOUD_PROJECT'] = 'cloudshell-gca';
|
|
}
|
|
} else {
|
|
// If no .env file, set to default and override global
|
|
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(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) {
|
|
try {
|
|
const envFileContent = fs.readFileSync(envFilePath, 'utf-8');
|
|
const parsedEnv = dotenv.parse(envFileContent);
|
|
|
|
const excludedVars =
|
|
settings?.advanced?.excludedEnvVars || DEFAULT_EXCLUDED_ENV_VARS;
|
|
const isProjectEnvFile = !envFilePath.includes(QWEN_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;
|
|
}
|
|
|
|
// Only set if not already present in process.env (no-override)
|
|
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 })`.
|
|
}
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Loads settings from user and workspace directories.
|
|
* Project settings override user settings.
|
|
*/
|
|
export function loadSettings(
|
|
workspaceDir: string = process.cwd(),
|
|
): LoadedSettings {
|
|
let systemSettings: Settings = {};
|
|
let systemDefaultSettings: Settings = {};
|
|
let userSettings: Settings = {};
|
|
let workspaceSettings: Settings = {};
|
|
const settingsErrors: SettingsError[] = [];
|
|
const systemSettingsPath = getSystemSettingsPath();
|
|
const systemDefaultsPath = getSystemDefaultsPath();
|
|
const migratedInMemorScopes = new Set<SettingScope>();
|
|
|
|
// Resolve paths to their canonical representation to handle symlinks
|
|
const resolvedWorkspaceDir = path.resolve(workspaceDir);
|
|
const resolvedHomeDir = path.resolve(homedir());
|
|
|
|
let realWorkspaceDir = resolvedWorkspaceDir;
|
|
try {
|
|
// fs.realpathSync gets the "true" path, resolving any symlinks
|
|
realWorkspaceDir = fs.realpathSync(resolvedWorkspaceDir);
|
|
} catch (_e) {
|
|
// This is okay. The path might not exist yet, and that's a valid state.
|
|
}
|
|
|
|
// We expect homedir to always exist and be resolvable.
|
|
const realHomeDir = fs.realpathSync(resolvedHomeDir);
|
|
|
|
const workspaceSettingsPath = new Storage(
|
|
workspaceDir,
|
|
).getWorkspaceSettingsPath();
|
|
|
|
const loadAndMigrate = (
|
|
filePath: string,
|
|
scope: SettingScope,
|
|
): { settings: Settings; rawJson?: string; migrationWarnings?: string[] } => {
|
|
try {
|
|
if (fs.existsSync(filePath)) {
|
|
let content = fs.readFileSync(filePath, 'utf-8');
|
|
let rawSettings: unknown;
|
|
let recoveryWarning: string | undefined;
|
|
|
|
try {
|
|
rawSettings = JSON.parse(stripJsonComments(content));
|
|
} catch (parseError: unknown) {
|
|
// JSON parse failed — try to recover from .orig backup
|
|
const backupPath = `${filePath}.orig`;
|
|
if (fs.existsSync(backupPath)) {
|
|
debugLogger.warn(
|
|
`Settings file ${filePath} has invalid JSON (${getErrorMessage(parseError)}). Attempting recovery from backup ${backupPath}.`,
|
|
);
|
|
try {
|
|
const backupContent = fs.readFileSync(backupPath, 'utf-8');
|
|
const backupSettings = JSON.parse(
|
|
stripJsonComments(backupContent),
|
|
);
|
|
// Backup is valid — restore it
|
|
fs.writeFileSync(filePath, backupContent, 'utf-8');
|
|
content = backupContent;
|
|
rawSettings = backupSettings;
|
|
const recoveryMsg = `Settings file ${filePath} had invalid JSON and was recovered from backup ${backupPath}. Some recent settings changes may have been lost.`;
|
|
debugLogger.warn(recoveryMsg);
|
|
// Surface warning to user so they know settings were rolled back
|
|
recoveryWarning = recoveryMsg;
|
|
} catch (backupError) {
|
|
// Could be invalid JSON, read error, or write-back failure
|
|
debugLogger.warn(
|
|
`Failed to recover from backup ${backupPath}: ${getErrorMessage(backupError)}. Falling back to empty settings.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
// No valid backup available — rename the corrupted file so the app
|
|
// can start with empty settings rather than crashing.
|
|
if (!rawSettings) {
|
|
const corruptedPath = `${filePath}.corrupted.${Date.now()}`;
|
|
let warningMsg: string;
|
|
try {
|
|
fs.renameSync(filePath, corruptedPath);
|
|
warningMsg = `Settings file ${filePath} has invalid JSON and was renamed to ${corruptedPath}. Your settings have been reset. To recover, fix the JSON in ${corruptedPath} and rename it back.`;
|
|
} catch (renameError) {
|
|
// If rename fails, still proceed with empty settings
|
|
debugLogger.error(
|
|
`Failed to rename corrupted settings file: ${getErrorMessage(renameError)}`,
|
|
);
|
|
warningMsg = `Settings file ${filePath} has invalid JSON. Your settings have been reset. Please fix the JSON in ${filePath} manually.`;
|
|
}
|
|
debugLogger.warn(warningMsg);
|
|
return {
|
|
settings: {},
|
|
migrationWarnings: [warningMsg],
|
|
};
|
|
}
|
|
}
|
|
|
|
if (
|
|
typeof rawSettings !== 'object' ||
|
|
rawSettings === null ||
|
|
Array.isArray(rawSettings)
|
|
) {
|
|
settingsErrors.push({
|
|
message: 'Settings file is not a valid JSON object.',
|
|
path: filePath,
|
|
});
|
|
return { settings: {} };
|
|
}
|
|
|
|
let settingsObject = rawSettings as Record<string, unknown>;
|
|
const hasVersionKey = SETTINGS_VERSION_KEY in settingsObject;
|
|
const versionValue = settingsObject[SETTINGS_VERSION_KEY];
|
|
const hasInvalidVersion =
|
|
hasVersionKey && typeof versionValue !== 'number';
|
|
const hasLegacyNumericVersion =
|
|
typeof versionValue === 'number' && versionValue < SETTINGS_VERSION;
|
|
let migrationWarnings: string[] | undefined;
|
|
|
|
const persistSettingsObject = (warningPrefix: string) => {
|
|
try {
|
|
writeWithBackupSync(
|
|
filePath,
|
|
JSON.stringify(settingsObject, null, 2),
|
|
);
|
|
} catch (e) {
|
|
debugLogger.error(`${warningPrefix}: ${getErrorMessage(e)}`);
|
|
}
|
|
};
|
|
|
|
if (needsMigration(settingsObject)) {
|
|
const migrationResult = runMigrations(settingsObject, scope);
|
|
if (migrationResult.executedMigrations.length > 0) {
|
|
settingsObject = migrationResult.settings as Record<
|
|
string,
|
|
unknown
|
|
>;
|
|
migrationWarnings = migrationResult.warnings;
|
|
persistSettingsObject('Error migrating settings file on disk');
|
|
} else if (hasLegacyNumericVersion || hasInvalidVersion) {
|
|
// Migration was deemed needed but nothing executed. Normalize version metadata
|
|
// to avoid repeated no-op checks on startup.
|
|
settingsObject[SETTINGS_VERSION_KEY] = SETTINGS_VERSION;
|
|
debugLogger.warn(
|
|
`Settings version metadata in ${filePath} could not be migrated by any registered migration. Normalizing ${SETTINGS_VERSION_KEY} to ${SETTINGS_VERSION}.`,
|
|
);
|
|
persistSettingsObject('Error normalizing settings version on disk');
|
|
}
|
|
} else if (
|
|
!hasVersionKey ||
|
|
hasInvalidVersion ||
|
|
hasLegacyNumericVersion
|
|
) {
|
|
// No migration needed/executable, but version metadata is missing or invalid.
|
|
// Normalize it to current version to avoid repeated startup work.
|
|
settingsObject[SETTINGS_VERSION_KEY] = SETTINGS_VERSION;
|
|
persistSettingsObject('Error normalizing settings version on disk');
|
|
}
|
|
|
|
// Prepend recovery warning if settings were restored from backup
|
|
const allWarnings = [
|
|
...(recoveryWarning ? [recoveryWarning] : []),
|
|
...(migrationWarnings ?? []),
|
|
];
|
|
|
|
return {
|
|
settings: settingsObject as Settings,
|
|
rawJson: content,
|
|
migrationWarnings:
|
|
allWarnings.length > 0 ? allWarnings : migrationWarnings,
|
|
};
|
|
}
|
|
} catch (error: unknown) {
|
|
settingsErrors.push({
|
|
message: getErrorMessage(error),
|
|
path: filePath,
|
|
});
|
|
}
|
|
return { settings: {} };
|
|
};
|
|
|
|
const systemResult = loadAndMigrate(systemSettingsPath, SettingScope.System);
|
|
const systemDefaultsResult = loadAndMigrate(
|
|
systemDefaultsPath,
|
|
SettingScope.SystemDefaults,
|
|
);
|
|
const userResult = loadAndMigrate(USER_SETTINGS_PATH, SettingScope.User);
|
|
|
|
let workspaceResult: {
|
|
settings: Settings;
|
|
rawJson?: string;
|
|
migrationWarnings?: string[];
|
|
} = {
|
|
settings: {} as Settings,
|
|
rawJson: undefined,
|
|
};
|
|
if (realWorkspaceDir !== realHomeDir) {
|
|
workspaceResult = loadAndMigrate(
|
|
workspaceSettingsPath,
|
|
SettingScope.Workspace,
|
|
);
|
|
}
|
|
|
|
const systemOriginalSettings = structuredClone(systemResult.settings);
|
|
const systemDefaultsOriginalSettings = structuredClone(
|
|
systemDefaultsResult.settings,
|
|
);
|
|
const userOriginalSettings = structuredClone(userResult.settings);
|
|
const workspaceOriginalSettings = structuredClone(workspaceResult.settings);
|
|
|
|
// Environment variables for runtime use
|
|
systemSettings = resolveEnvVarsInObject(systemResult.settings);
|
|
systemDefaultSettings = resolveEnvVarsInObject(systemDefaultsResult.settings);
|
|
userSettings = resolveEnvVarsInObject(userResult.settings);
|
|
workspaceSettings = resolveEnvVarsInObject(workspaceResult.settings);
|
|
|
|
// Support legacy theme names
|
|
if (userSettings.ui?.theme === 'VS') {
|
|
userSettings.ui.theme = DefaultLight.name;
|
|
} else if (userSettings.ui?.theme === 'VS2015') {
|
|
userSettings.ui.theme = DefaultDark.name;
|
|
}
|
|
if (workspaceSettings.ui?.theme === 'VS') {
|
|
workspaceSettings.ui.theme = DefaultLight.name;
|
|
} else if (workspaceSettings.ui?.theme === 'VS2015') {
|
|
workspaceSettings.ui.theme = DefaultDark.name;
|
|
}
|
|
|
|
// For the initial trust check, we can only use user and system settings.
|
|
const initialTrustCheckSettings = customDeepMerge(
|
|
getMergeStrategyForPath,
|
|
{},
|
|
systemSettings,
|
|
userSettings,
|
|
);
|
|
const isTrusted =
|
|
isWorkspaceTrusted(initialTrustCheckSettings as Settings).isTrusted ?? true;
|
|
|
|
// Create a temporary merged settings object to pass to loadEnvironment.
|
|
const tempMergedSettings = mergeSettings(
|
|
systemSettings,
|
|
systemDefaultSettings,
|
|
userSettings,
|
|
workspaceSettings,
|
|
isTrusted,
|
|
);
|
|
|
|
// loadEnviroment depends on settings so we have to create a temp version of
|
|
// the settings to avoid a cycle
|
|
loadEnvironment(tempMergedSettings);
|
|
|
|
// Create LoadedSettings first
|
|
|
|
if (settingsErrors.length > 0) {
|
|
const errorMessages = settingsErrors.map(
|
|
(error) => `Error in ${error.path}: ${error.message}`,
|
|
);
|
|
throw new FatalConfigError(
|
|
`${errorMessages.join('\n')}\nPlease fix the configuration file(s) and try again.`,
|
|
);
|
|
}
|
|
|
|
// Collect all migration warnings from all scopes
|
|
const allMigrationWarnings: string[] = [
|
|
...(systemResult.migrationWarnings ?? []),
|
|
...(systemDefaultsResult.migrationWarnings ?? []),
|
|
...(userResult.migrationWarnings ?? []),
|
|
...(workspaceResult.migrationWarnings ?? []),
|
|
];
|
|
|
|
return new LoadedSettings(
|
|
{
|
|
path: systemSettingsPath,
|
|
settings: systemSettings,
|
|
originalSettings: systemOriginalSettings,
|
|
rawJson: systemResult.rawJson,
|
|
},
|
|
{
|
|
path: systemDefaultsPath,
|
|
settings: systemDefaultSettings,
|
|
originalSettings: systemDefaultsOriginalSettings,
|
|
rawJson: systemDefaultsResult.rawJson,
|
|
},
|
|
{
|
|
path: USER_SETTINGS_PATH,
|
|
settings: userSettings,
|
|
originalSettings: userOriginalSettings,
|
|
rawJson: userResult.rawJson,
|
|
},
|
|
{
|
|
path: workspaceSettingsPath,
|
|
settings: workspaceSettings,
|
|
originalSettings: workspaceOriginalSettings,
|
|
rawJson: workspaceResult.rawJson,
|
|
},
|
|
isTrusted,
|
|
migratedInMemorScopes,
|
|
allMigrationWarnings,
|
|
);
|
|
}
|
|
|
|
function createSettingsUpdate(
|
|
key: string,
|
|
value: unknown,
|
|
): Record<string, unknown> {
|
|
const root: Record<string, unknown> = {};
|
|
setNestedPropertySafe(root, key, value);
|
|
return root;
|
|
}
|
|
|
|
export function saveSettings(
|
|
settingsFile: SettingsFile,
|
|
updates: Record<string, unknown> = settingsFile.originalSettings as Record<
|
|
string,
|
|
unknown
|
|
>,
|
|
): void {
|
|
try {
|
|
// Ensure the directory exists
|
|
const dirPath = path.dirname(settingsFile.path);
|
|
if (!fs.existsSync(dirPath)) {
|
|
fs.mkdirSync(dirPath, { recursive: true });
|
|
}
|
|
|
|
// Use the format-preserving update function
|
|
updateSettingsFilePreservingFormat(settingsFile.path, updates);
|
|
} catch (error) {
|
|
debugLogger.error('Error saving user settings file.');
|
|
debugLogger.error(error instanceof Error ? error.message : String(error));
|
|
throw error;
|
|
}
|
|
}
|