Skip to content
Open
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
8 changes: 5 additions & 3 deletions docs/reference/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,11 @@ variables using `$VAR_NAME`, `${VAR_NAME}`, or `${VAR_NAME:-DEFAULT_VALUE}`
syntax. These variables will be automatically resolved when the settings are
loaded. For example, if you have an environment variable `MY_API_TOKEN`, you
could use it in `settings.json` like this: `"apiKey": "$MY_API_TOKEN"`. If you
want to provide a fallback value, use `${MY_API_TOKEN:-default-token}`.
Additionally, each extension can have its own `.env` file in its directory,
which will be loaded automatically.
want to provide a fallback value, use `${MY_API_TOKEN:-default-token}`. For
settings whose schema type is boolean, resolved values of `"true"` and `"false"`
are automatically cast to booleans (case-insensitive). Additionally, each
extension can have its own `.env` file in its directory, which will be loaded
automatically.

**Note for Enterprise Users:** For guidance on deploying and managing Gemini CLI
in a corporate environment, see the
Expand Down
97 changes: 97 additions & 0 deletions packages/cli/src/config/settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1494,6 +1494,103 @@ describe('Settings Loading and Merging', () => {
delete process.env['TEST_PORT'];
});

it('should coerce env-resolved boolean setting values from strings', () => {
process.env['GEMINI_AUTO_THEME'] = 'TRUE';
const userSettingsContent = {
ui: {
autoThemeSwitching: '${GEMINI_AUTO_THEME:-false}',
},
};

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

const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.user.settings.ui?.autoThemeSwitching).toBe(true);
expect(settings.merged.ui?.autoThemeSwitching).toBe(true);
expect(
settings.errors.some((error) =>
error.message.includes('ui.autoThemeSwitching'),
),
).toBe(false);

delete process.env['GEMINI_AUTO_THEME'];
});

it('should coerce env-resolved booleans in ref-based settings', () => {
process.env['MCP_TRUSTED'] = 'FALSE';
const userSettingsContent = {
mcpServers: {
demo: {
command: 'node',
args: ['server.js'],
trust: '${MCP_TRUSTED:-true}',
},
},
};

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

const settings = loadSettings(MOCK_WORKSPACE_DIR);
const mcpServer = settings.user.settings.mcpServers?.['demo'];
expect(mcpServer?.trust).toBe(false);
expect(settings.merged.mcpServers?.['demo']?.trust).toBe(false);

delete process.env['MCP_TRUSTED'];
});

it('should not coerce env-resolved values in string-only map settings', () => {
const userSettingsContent: TestSettings = {
mcpServers: {
demo: {
command: 'node',
args: ['server.js'],
env: {
FEATURE_FLAG: '${FEATURE_FLAG:-false}',
},
} satisfies MCPServerConfig,
},
};

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

const settings = loadSettings(MOCK_WORKSPACE_DIR);
const mcpServer = settings.user.settings.mcpServers?.['demo'];
expect(mcpServer?.env?.['FEATURE_FLAG']).toBe('false');
});

describe('when GEMINI_CLI_SYSTEM_SETTINGS_PATH is set', () => {
const MOCK_ENV_SYSTEM_SETTINGS_PATH = path.resolve(
'/mock/env/system/settings.json',
Expand Down
148 changes: 143 additions & 5 deletions packages/cli/src/config/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
type MergeStrategy,
type SettingsSchema,
type SettingDefinition,
SETTINGS_SCHEMA_DEFINITIONS,
getSettingsSchema,
} from './settingsSchema.js';

Expand Down Expand Up @@ -244,6 +245,127 @@ export function getDefaultsFromSchema(
return defaults as Settings;
}

function parseBooleanString(value: string): boolean | undefined {
const normalized = value.trim().toLowerCase();
if (normalized === 'true') {
return true;
}
if (normalized === 'false') {
return false;
}
return undefined;
}

function isObjectRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}

type SchemaNode = {
type?: string | string[];
properties?: Record<string, unknown>;
items?: unknown;
additionalProperties?: unknown;
ref?: string;
};
Comment thread
joycebeatriz marked this conversation as resolved.

function toSchemaNode(value: unknown): SchemaNode | undefined {
if (!isObjectRecord(value)) {
return undefined;
}


return value as SchemaNode;
}

function resolveSchemaNode(
schemaNode?: SchemaNode,
resolvingRefs: Set<string> = new Set(),
): SchemaNode | undefined {
if (!schemaNode?.ref) {
return schemaNode;
}

const ref = schemaNode.ref;
if (resolvingRefs.has(ref)) {
// Break potential cycles in malformed ref graphs.
const { ref: _ignoredRef, ...withoutRef } = schemaNode;
return withoutRef;
}

const referenced = toSchemaNode(SETTINGS_SCHEMA_DEFINITIONS[ref]);
if (!referenced) {
const { ref: _ignoredRef, ...withoutRef } = schemaNode;
return withoutRef;
}

const nextResolvingRefs = new Set(resolvingRefs);
nextResolvingRefs.add(ref);
const resolvedReferenced = resolveSchemaNode(referenced, nextResolvingRefs);
if (!resolvedReferenced) {
const { ref: _ignoredRef, ...withoutRef } = schemaNode;
return withoutRef;
}

const { ref: _ignoredRef, ...inlineWithoutRef } = schemaNode;
return {
...resolvedReferenced,
...inlineWithoutRef,
};
}

function coerceSettingsValueFromSchema(
value: unknown,
schemaNode?: SchemaNode,
): unknown {
const resolvedSchemaNode = resolveSchemaNode(schemaNode);
if (!resolvedSchemaNode) {
return value;
}

if (resolvedSchemaNode.type === 'boolean' && typeof value === 'string') {
return parseBooleanString(value) ?? value;
}

if (resolvedSchemaNode.type === 'array' && Array.isArray(value)) {
const itemSchema = toSchemaNode(resolvedSchemaNode.items);
return value.map((entry) =>
coerceSettingsValueFromSchema(entry, itemSchema),
);
}

if (resolvedSchemaNode.type === 'object' && isObjectRecord(value)) {
const schemaProperties = isObjectRecord(resolvedSchemaNode.properties)
? resolvedSchemaNode.properties
: undefined;
const fallbackSchema = toSchemaNode(
resolvedSchemaNode.additionalProperties,
);
const result: Record<string, unknown> = { ...value };

for (const [key, currentValue] of Object.entries(result)) {
const childSchema = toSchemaNode(schemaProperties?.[key]);
result[key] = coerceSettingsValueFromSchema(
currentValue,
childSchema ?? fallbackSchema,
);
}

return result;
}

return value;
}
Comment thread
joycebeatriz marked this conversation as resolved.

function coerceBooleanSettingsFromSchema(settings: Settings): Settings {
const rootSchema: SchemaNode = {
type: 'object',
properties: getSettingsSchema() as Record<string, unknown>,
};

// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return coerceSettingsValueFromSchema(settings, rootSchema) as Settings;
}

export function mergeSettings(
system: Settings,
systemDefaults: Settings,
Expand Down Expand Up @@ -686,8 +808,16 @@ function _doLoadSettings(workspaceDir: string): LoadedSettings {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const settingsObject = rawSettings as Record<string, unknown>;

// Resolve env vars and coerce schema-typed booleans before validating.
const resolvedForValidation = resolveEnvVarsInObject(
settingsObject as Settings,
);
const normalizedForValidation = coerceBooleanSettingsFromSchema(
resolvedForValidation,
);

// Validate settings structure with Zod
const validationResult = validateSettings(settingsObject);
const validationResult = validateSettings(normalizedForValidation);
if (!validationResult.success && validationResult.error) {
const errorMessage = formatValidationError(
validationResult.error,
Expand Down Expand Up @@ -732,10 +862,18 @@ function _doLoadSettings(workspaceDir: string): LoadedSettings {
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);
systemSettings = coerceBooleanSettingsFromSchema(
resolveEnvVarsInObject(systemResult.settings),
);
systemDefaultSettings = coerceBooleanSettingsFromSchema(
resolveEnvVarsInObject(systemDefaultsResult.settings),
);
userSettings = coerceBooleanSettingsFromSchema(
resolveEnvVarsInObject(userResult.settings),
);
workspaceSettings = coerceBooleanSettingsFromSchema(
resolveEnvVarsInObject(workspaceResult.settings),
);

// Support legacy theme names
if (userSettings.ui?.theme === 'VS') {
Expand Down