@google/gemini-cli-a2a-server 0.63.0-preview.0 → 0.64.0-nightly.20261001.gc6bccb7ec

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,64 +5,646 @@
5
5
  */
6
6
  import * as fs from 'node:fs';
7
7
  import * as path from 'node:path';
8
+ import { z } from 'zod';
8
9
  import { debugLogger, GEMINI_DIR, getErrorMessage, homedir, checkPathTrust, isHeadlessMode, } from '@google/gemini-cli-core';
9
10
  import stripJsonComments from 'strip-json-comments';
10
11
  export const USER_SETTINGS_DIR = path.join(homedir(), GEMINI_DIR);
11
12
  export const USER_SETTINGS_PATH = path.join(USER_SETTINGS_DIR, 'settings.json');
13
+ export var MergeStrategy;
14
+ (function (MergeStrategy) {
15
+ MergeStrategy["REPLACE"] = "replace";
16
+ MergeStrategy["CONCAT"] = "concat";
17
+ MergeStrategy["UNION"] = "union";
18
+ MergeStrategy["SHALLOW_MERGE"] = "shallow_merge";
19
+ })(MergeStrategy || (MergeStrategy = {}));
20
+ const MIGRATION_MAP = {
21
+ accessibility: 'ui.accessibility',
22
+ allowedTools: 'tools.allowed',
23
+ allowMCPServers: 'mcp.allowed',
24
+ autoAccept: 'tools.autoAccept',
25
+ autoConfigureMaxOldSpaceSize: 'advanced.autoConfigureMemory',
26
+ bugCommand: 'advanced.bugCommand',
27
+ chatCompression: 'model.compressionThreshold',
28
+ checkpointing: 'general.checkpointing',
29
+ coreTools: 'tools.core',
30
+ contextFileName: 'context.fileName',
31
+ customThemes: 'ui.customThemes',
32
+ customWittyPhrases: 'ui.customWittyPhrases',
33
+ debugKeystrokeLogging: 'general.debugKeystrokeLogging',
34
+ disableAutoUpdate: 'general.disableAutoUpdate',
35
+ disableUpdateNag: 'general.disableUpdateNag',
36
+ dnsResolutionOrder: 'advanced.dnsResolutionOrder',
37
+ enableHooks: 'tools.enableHooks',
38
+ enablePromptCompletion: 'general.enablePromptCompletion',
39
+ enforcedAuthType: 'security.auth.enforcedType',
40
+ excludeTools: 'tools.exclude',
41
+ excludeMCPServers: 'mcp.excluded',
42
+ excludedProjectEnvVars: 'advanced.excludedEnvVars',
43
+ experimentalSkills: 'experimental.skills',
44
+ extensionManagement: 'experimental.extensionManagement',
45
+ extensions: 'extensions',
46
+ fileFiltering: 'context.fileFiltering',
47
+ folderTrustFeature: 'security.folderTrust.featureEnabled',
48
+ folderTrust: 'security.folderTrust.enabled',
49
+ hasSeenIdeIntegrationNudge: 'ide.hasSeenNudge',
50
+ hideWindowTitle: 'ui.hideWindowTitle',
51
+ showStatusInTitle: 'ui.showStatusInTitle',
52
+ hideTips: 'ui.hideTips',
53
+ hideBanner: 'ui.hideBanner',
54
+ hideFooter: 'ui.hideFooter',
55
+ hideCWD: 'ui.footer.hideCWD',
56
+ hideSandboxStatus: 'ui.footer.hideSandboxStatus',
57
+ hideModelInfo: 'ui.footer.hideModelInfo',
58
+ hideContextSummary: 'ui.hideContextSummary',
59
+ showMemoryUsage: 'ui.showMemoryUsage',
60
+ showLineNumbers: 'ui.showLineNumbers',
61
+ showCitations: 'ui.showCitations',
62
+ ideMode: 'ide.enabled',
63
+ includeDirectories: 'context.includeDirectories',
64
+ loadMemoryFromIncludeDirectories: 'context.loadFromIncludeDirectories',
65
+ logLevel: 'logging.level',
66
+ maxSessionTurns: 'model.maxSessionTurns',
67
+ mcpServers: 'mcpServers',
68
+ mcpServerCommand: 'mcp.serverCommand',
69
+ memoryImportFormat: 'context.importFormat',
70
+ memoryDiscoveryMaxDirs: 'context.discoveryMaxDirs',
71
+ model: 'model.name',
72
+ preferredEditor: 'general.preferredEditor',
73
+ retryFetchErrors: 'general.retryFetchErrors',
74
+ sandbox: 'tools.sandbox',
75
+ selectedAuthType: 'security.auth.selectedType',
76
+ enableInteractiveShell: 'tools.shell.enableInteractiveShell',
77
+ shellPager: 'tools.shell.pager',
78
+ shellShowColor: 'tools.shell.showColor',
79
+ shellInactivityTimeout: 'tools.shell.inactivityTimeout',
80
+ skipNextSpeakerCheck: 'model.skipNextSpeakerCheck',
81
+ summarizeToolOutput: 'model.summarizeToolOutput',
82
+ telemetry: 'telemetry',
83
+ theme: 'ui.theme',
84
+ toolDiscoveryCommand: 'tools.discoveryCommand',
85
+ toolCallCommand: 'tools.callCommand',
86
+ usageStatisticsEnabled: 'privacy.usageStatisticsEnabled',
87
+ useExternalAuth: 'security.auth.useExternal',
88
+ useRipgrep: 'tools.useRipgrep',
89
+ vimMode: 'general.vimMode',
90
+ };
91
+ const KNOWN_V2_CONTAINERS = new Set(Object.values(MIGRATION_MAP).map((p) => p.split('.')[0]));
92
+ const LEGACY_V1_ONLY_KEYS = new Set([
93
+ ...Object.keys(MIGRATION_MAP).filter((k) => k !== MIGRATION_MAP[k] && !KNOWN_V2_CONTAINERS.has(k)),
94
+ 'telemetryDisabled',
95
+ ]);
96
+ const MERGE_STRATEGIES = {
97
+ mcpServers: MergeStrategy.SHALLOW_MERGE,
98
+ policyPaths: MergeStrategy.UNION,
99
+ adminPolicyPaths: MergeStrategy.UNION,
100
+ };
101
+ export function getMergeStrategyForPath(pathSegments) {
102
+ return MERGE_STRATEGIES[pathSegments.join('.')];
103
+ }
104
+ function isPlainObject(item) {
105
+ if (typeof item !== 'object' || item === null || Array.isArray(item)) {
106
+ return false;
107
+ }
108
+ const proto = Object.getPrototypeOf(item);
109
+ return proto === null || proto === Object.prototype;
110
+ }
111
+ function isUnsafeKey(key) {
112
+ return key === '__proto__' || key === 'constructor' || key === 'prototype';
113
+ }
114
+ function mergeRecursively(target, source, getStrategy, currentPath = []) {
115
+ for (const key of Object.keys(source)) {
116
+ if (isUnsafeKey(key)) {
117
+ continue;
118
+ }
119
+ const srcValue = source[key];
120
+ if (srcValue === undefined) {
121
+ continue;
122
+ }
123
+ const newPath = [...currentPath, key];
124
+ const objValue = target[key];
125
+ const mergeStrategy = getStrategy(newPath);
126
+ if (mergeStrategy === MergeStrategy.SHALLOW_MERGE && objValue && srcValue) {
127
+ const obj1 = isPlainObject(objValue) ? objValue : {};
128
+ const obj2 = isPlainObject(srcValue) ? srcValue : {};
129
+ const mergedShallow = {};
130
+ for (const [k, v] of Object.entries(obj1)) {
131
+ if (!isUnsafeKey(k))
132
+ mergedShallow[k] = v;
133
+ }
134
+ for (const [k, v] of Object.entries(obj2)) {
135
+ if (!isUnsafeKey(k))
136
+ mergedShallow[k] = v;
137
+ }
138
+ target[key] = mergedShallow;
139
+ continue;
140
+ }
141
+ if (Array.isArray(objValue)) {
142
+ const srcArray = Array.isArray(srcValue) ? srcValue : [srcValue];
143
+ if (mergeStrategy === MergeStrategy.CONCAT) {
144
+ target[key] = objValue.concat(srcArray);
145
+ continue;
146
+ }
147
+ if (mergeStrategy === MergeStrategy.UNION) {
148
+ target[key] = [...new Set(objValue.concat(srcArray))];
149
+ continue;
150
+ }
151
+ }
152
+ if (isPlainObject(objValue) && isPlainObject(srcValue)) {
153
+ mergeRecursively(objValue, srcValue, getStrategy, newPath);
154
+ }
155
+ else if (isPlainObject(srcValue)) {
156
+ const nestedTarget = {};
157
+ target[key] = nestedTarget;
158
+ mergeRecursively(nestedTarget, srcValue, getStrategy, newPath);
159
+ }
160
+ else {
161
+ target[key] = srcValue;
162
+ }
163
+ }
164
+ return target;
165
+ }
12
166
  /**
13
- * Loads settings from user and workspace directories.
14
- * Project settings override user settings if the workspace is trusted.
15
- *
16
- * How is it different to gemini-cli/cli: Returns already merged settings rather
17
- * than `LoadedSettings` (unnecessary since we are not modifying users
18
- * settings.json).
167
+ * Deeply merges configuration objects while protecting against prototype pollution.
19
168
  */
20
- export function loadSettings(workspaceDir, isTrustedOverride) {
21
- let userSettings = {};
22
- let workspaceSettings = {};
23
- const settingsErrors = [];
24
- // Load user settings
25
- try {
26
- if (fs.existsSync(USER_SETTINGS_PATH)) {
27
- const userContent = fs.readFileSync(USER_SETTINGS_PATH, 'utf-8');
169
+ export function customDeepMerge(first, ...rest) {
170
+ const getStrategy = typeof first === 'function' ? first : getMergeStrategyForPath;
171
+ const sources = typeof first === 'function' ? rest : first ? [first, ...rest] : rest;
172
+ const result = {};
173
+ for (const source of sources) {
174
+ if (source && isPlainObject(source)) {
175
+ mergeRecursively(result, source, getStrategy);
176
+ }
177
+ }
178
+ return result;
179
+ }
180
+ function setNestedProperty(obj, dotPath, value) {
181
+ const keys = dotPath.split('.');
182
+ const lastKey = keys.pop();
183
+ if (!lastKey || isUnsafeKey(lastKey))
184
+ return;
185
+ let current = obj;
186
+ for (const key of keys) {
187
+ if (isUnsafeKey(key))
188
+ return;
189
+ const existing = current[key];
190
+ if (existing === undefined) {
191
+ const nextObj = {};
192
+ current[key] = nextObj;
193
+ current = nextObj;
194
+ }
195
+ else if (isPlainObject(existing)) {
196
+ current = existing;
197
+ }
198
+ else {
199
+ return;
200
+ }
201
+ }
202
+ current[lastKey] = value;
203
+ }
204
+ function getNestedProperty(obj, dotPath) {
205
+ const keys = dotPath.split('.');
206
+ let current = obj;
207
+ for (const key of keys) {
208
+ if (isUnsafeKey(key) || !isPlainObject(current) || !(key in current)) {
209
+ return undefined;
210
+ }
211
+ current = current[key];
212
+ }
213
+ return current;
214
+ }
215
+ function migrateInvertedBoolean(container, oldKey, newKey) {
216
+ const oldVal = container[oldKey];
217
+ const newVal = container[newKey];
218
+ if (typeof oldVal === 'boolean') {
219
+ if (typeof newVal !== 'boolean') {
220
+ container[newKey] = !oldVal;
221
+ }
222
+ delete container[oldKey];
223
+ }
224
+ }
225
+ /**
226
+ * Intercepts a V1 (flat) or hybrid settings object in memory and converts
227
+ * legacy/deprecated keys into the V2 hierarchical structure.
228
+ */
229
+ export function migrateDeprecatedSettings(rawSettings) {
230
+ if (!isPlainObject(rawSettings)) {
231
+ return {};
232
+ }
233
+ const v2Settings = {};
234
+ const remainingKeys = new Set(Object.keys(rawSettings).filter((k) => !isUnsafeKey(k)));
235
+ // Normalize boolean telemetry: true/false -> { enabled: true/false }
236
+ const rawTelemetry = rawSettings['telemetry'];
237
+ if (typeof rawTelemetry === 'boolean') {
238
+ setNestedProperty(v2Settings, 'telemetry.enabled', rawTelemetry);
239
+ remainingKeys.delete('telemetry');
240
+ }
241
+ // Handle legacy telemetryDisabled -> telemetry.enabled = !telemetryDisabled
242
+ const telemetryDisabledVal = rawSettings['telemetryDisabled'];
243
+ if (typeof telemetryDisabledVal === 'boolean') {
244
+ if (getNestedProperty(v2Settings, 'telemetry.enabled') === undefined) {
245
+ setNestedProperty(v2Settings, 'telemetry.enabled', !telemetryDisabledVal);
246
+ }
247
+ remainingKeys.delete('telemetryDisabled');
248
+ }
249
+ for (const [oldKey, newPath] of Object.entries(MIGRATION_MAP)) {
250
+ // Skip keys where oldKey === newPath so V2 top-level containers are merged in the second pass
251
+ if (oldKey === newPath) {
252
+ continue;
253
+ }
254
+ if (remainingKeys.has(oldKey)) {
255
+ let val = rawSettings[oldKey];
256
+ // If a key is both a V1 key and a V2 container (e.g., 'model') and holds an object,
257
+ // preserve it as a V2 container so it is deep-merged in the carry-over pass.
258
+ if (KNOWN_V2_CONTAINERS.has(oldKey) && isPlainObject(val)) {
259
+ continue;
260
+ }
261
+ // Normalize legacy boolean checkpointing: true -> { enabled: true }
262
+ if (oldKey === 'checkpointing' && typeof val === 'boolean') {
263
+ val = { enabled: val };
264
+ }
265
+ // Normalize legacy object chatCompression: { contextPercentageThreshold: n } -> n
266
+ if (oldKey === 'chatCompression' && isPlainObject(val)) {
267
+ const threshold = val['contextPercentageThreshold'];
268
+ if (typeof threshold === 'number') {
269
+ val = threshold;
270
+ }
271
+ }
272
+ setNestedProperty(v2Settings, newPath, val);
273
+ remainingKeys.delete(oldKey);
274
+ }
275
+ }
276
+ // Carry over and deep-merge any remaining V2 containers or custom keys (V2 takes precedence over V1)
277
+ for (const key of remainingKeys) {
278
+ const newVal = rawSettings[key];
279
+ if (newVal === undefined)
280
+ continue;
281
+ const existingVal = v2Settings[key];
282
+ if (isPlainObject(existingVal) && isPlainObject(newVal)) {
283
+ v2Settings[key] = customDeepMerge((subPath) => getMergeStrategyForPath([key, ...subPath]), existingVal, newVal);
284
+ }
285
+ else {
286
+ v2Settings[key] = newVal;
287
+ }
288
+ }
289
+ // Migrate deprecated nested boolean/property settings within V2 containers
290
+ const generalObj = v2Settings['general'];
291
+ if (isPlainObject(generalObj)) {
292
+ const checkpointingVal = generalObj['checkpointing'];
293
+ if (typeof checkpointingVal === 'boolean') {
294
+ generalObj['checkpointing'] = { enabled: checkpointingVal };
295
+ }
296
+ migrateInvertedBoolean(generalObj, 'disableAutoUpdate', 'enableAutoUpdate');
297
+ migrateInvertedBoolean(generalObj, 'disableUpdateNag', 'enableAutoUpdateNotification');
298
+ }
299
+ const uiObj = v2Settings['ui'];
300
+ if (isPlainObject(uiObj)) {
301
+ const accessibilityObj = uiObj['accessibility'];
302
+ if (isPlainObject(accessibilityObj)) {
303
+ migrateInvertedBoolean(accessibilityObj, 'disableLoadingPhrases', 'enableLoadingPhrases');
304
+ const enableLP = accessibilityObj['enableLoadingPhrases'];
305
+ if (typeof enableLP === 'boolean' &&
306
+ uiObj['loadingPhrases'] === undefined) {
307
+ if (!enableLP) {
308
+ uiObj['loadingPhrases'] = 'off';
309
+ }
310
+ }
311
+ }
312
+ }
313
+ const contextObj = v2Settings['context'];
314
+ if (isPlainObject(contextObj)) {
315
+ const fileFilteringObj = contextObj['fileFiltering'];
316
+ if (isPlainObject(fileFilteringObj)) {
317
+ migrateInvertedBoolean(fileFilteringObj, 'disableFuzzySearch', 'enableFuzzySearch');
318
+ }
319
+ }
320
+ const toolsObj = v2Settings['tools'];
321
+ if (isPlainObject(toolsObj)) {
322
+ const approvalModeVal = toolsObj['approvalMode'];
323
+ if (approvalModeVal !== undefined) {
324
+ const targetGeneral = isPlainObject(v2Settings['general'])
325
+ ? v2Settings['general']
326
+ : {};
327
+ if (targetGeneral['defaultApprovalMode'] === undefined) {
328
+ targetGeneral['defaultApprovalMode'] = approvalModeVal;
329
+ }
330
+ v2Settings['general'] = targetGeneral;
331
+ delete toolsObj['approvalMode'];
332
+ }
333
+ }
334
+ return v2Settings;
335
+ }
336
+ const booleanPreprocess = z.preprocess((val) => {
337
+ if (typeof val === 'string') {
338
+ const lower = val.toLowerCase();
339
+ if (lower === 'true')
340
+ return true;
341
+ if (lower === 'false')
342
+ return false;
343
+ }
344
+ return val;
345
+ }, z.boolean());
346
+ const numberPreprocess = z.preprocess((val) => {
347
+ if (typeof val === 'string' && val.trim() !== '') {
348
+ const num = Number(val);
349
+ if (!Number.isNaN(num))
350
+ return num;
351
+ }
352
+ return val;
353
+ }, z.number());
354
+ export const settingsZodSchema = z
355
+ .object({
356
+ mcpServers: z
357
+ .record(z.string(), z.record(z.string(), z.unknown()))
358
+ .optional(),
359
+ mcp: z
360
+ .object({
361
+ serverCommand: z.string().optional(),
362
+ allowed: z.array(z.string()).optional(),
363
+ excluded: z.array(z.string()).optional(),
364
+ })
365
+ .passthrough()
366
+ .optional(),
367
+ tools: z
368
+ .object({
369
+ allowed: z.array(z.string()).optional(),
370
+ exclude: z.array(z.string()).optional(),
371
+ core: z.array(z.string()).optional(),
372
+ autoAccept: booleanPreprocess.optional(),
373
+ sandbox: z
374
+ .union([
375
+ booleanPreprocess,
376
+ z.string(),
377
+ z.record(z.string(), z.unknown()),
378
+ ])
379
+ .optional(),
380
+ enableHooks: booleanPreprocess.optional(),
381
+ discoveryCommand: z.string().optional(),
382
+ callCommand: z.string().optional(),
383
+ useRipgrep: booleanPreprocess.optional(),
384
+ shell: z
385
+ .object({
386
+ enableInteractiveShell: booleanPreprocess.optional(),
387
+ pager: z.string().optional(),
388
+ showColor: booleanPreprocess.optional(),
389
+ inactivityTimeout: numberPreprocess.optional(),
390
+ })
391
+ .passthrough()
392
+ .optional(),
393
+ })
394
+ .passthrough()
395
+ .optional(),
396
+ telemetry: z
397
+ .union([
398
+ booleanPreprocess,
399
+ z
400
+ .object({
401
+ enabled: booleanPreprocess.optional(),
402
+ target: z.string().optional(),
403
+ otlpEndpoint: z.string().optional(),
404
+ otlpProtocol: z.string().optional(),
405
+ logPrompts: booleanPreprocess.optional(),
406
+ outfile: z.string().optional(),
407
+ useCollector: booleanPreprocess.optional(),
408
+ })
409
+ .passthrough(),
410
+ ])
411
+ .optional(),
412
+ logging: z
413
+ .object({
414
+ level: z.string().optional(),
415
+ })
416
+ .passthrough()
417
+ .optional(),
418
+ ui: z
419
+ .object({
420
+ theme: z.string().optional(),
421
+ showMemoryUsage: booleanPreprocess.optional(),
422
+ showLineNumbers: booleanPreprocess.optional(),
423
+ showCitations: booleanPreprocess.optional(),
424
+ showStatusInTitle: booleanPreprocess.optional(),
425
+ hideWindowTitle: booleanPreprocess.optional(),
426
+ hideTips: booleanPreprocess.optional(),
427
+ hideBanner: booleanPreprocess.optional(),
428
+ hideFooter: booleanPreprocess.optional(),
429
+ hideContextSummary: booleanPreprocess.optional(),
430
+ loadingPhrases: z.string().optional(),
431
+ customThemes: z.record(z.string(), z.unknown()).optional(),
432
+ customWittyPhrases: z.array(z.string()).optional(),
433
+ footer: z
434
+ .object({
435
+ hideCWD: booleanPreprocess.optional(),
436
+ hideSandboxStatus: booleanPreprocess.optional(),
437
+ hideModelInfo: booleanPreprocess.optional(),
438
+ })
439
+ .passthrough()
440
+ .optional(),
441
+ accessibility: z
442
+ .object({
443
+ enableLoadingPhrases: booleanPreprocess.optional(),
444
+ screenReader: booleanPreprocess.optional(),
445
+ })
446
+ .passthrough()
447
+ .optional(),
448
+ })
449
+ .passthrough()
450
+ .optional(),
451
+ general: z
452
+ .object({
453
+ previewFeatures: booleanPreprocess.optional(),
454
+ vimMode: booleanPreprocess.optional(),
455
+ preferredEditor: z.string().optional(),
456
+ checkpointing: z
457
+ .object({
458
+ enabled: booleanPreprocess.optional(),
459
+ })
460
+ .passthrough()
461
+ .optional(),
462
+ enableAutoUpdate: booleanPreprocess.optional(),
463
+ enableAutoUpdateNotification: booleanPreprocess.optional(),
464
+ defaultApprovalMode: z.string().optional(),
465
+ debugKeystrokeLogging: booleanPreprocess.optional(),
466
+ enablePromptCompletion: booleanPreprocess.optional(),
467
+ retryFetchErrors: booleanPreprocess.optional(),
468
+ plan: z
469
+ .object({
470
+ enabled: booleanPreprocess.optional(),
471
+ })
472
+ .passthrough()
473
+ .optional(),
474
+ })
475
+ .passthrough()
476
+ .optional(),
477
+ context: z
478
+ .object({
479
+ fileName: z.union([z.string(), z.array(z.string())]).optional(),
480
+ includeDirectories: z.array(z.string()).optional(),
481
+ loadFromIncludeDirectories: booleanPreprocess.optional(),
482
+ importFormat: z.string().optional(),
483
+ discoveryMaxDirs: numberPreprocess.optional(),
484
+ fileFiltering: z
485
+ .object({
486
+ respectGitIgnore: booleanPreprocess.optional(),
487
+ respectGeminiIgnore: booleanPreprocess.optional(),
488
+ enableRecursiveFileSearch: booleanPreprocess.optional(),
489
+ enableFuzzySearch: booleanPreprocess.optional(),
490
+ customIgnoreFilePaths: z.array(z.string()).optional(),
491
+ })
492
+ .passthrough()
493
+ .optional(),
494
+ })
495
+ .passthrough()
496
+ .optional(),
497
+ security: z
498
+ .object({
499
+ folderTrust: z
500
+ .object({
501
+ enabled: booleanPreprocess.optional(),
502
+ featureEnabled: booleanPreprocess.optional(),
503
+ })
504
+ .passthrough()
505
+ .optional(),
506
+ auth: z
507
+ .object({
508
+ selectedType: z.string().optional(),
509
+ enforcedType: z.string().optional(),
510
+ useExternal: booleanPreprocess.optional(),
511
+ })
512
+ .passthrough()
513
+ .optional(),
514
+ })
515
+ .passthrough()
516
+ .optional(),
517
+ model: z
518
+ .object({
519
+ name: z.string().optional(),
520
+ maxSessionTurns: numberPreprocess.optional(),
521
+ compressionThreshold: numberPreprocess.optional(),
522
+ skipNextSpeakerCheck: booleanPreprocess.optional(),
523
+ summarizeToolOutput: z.record(z.string(), z.unknown()).optional(),
524
+ })
525
+ .passthrough()
526
+ .optional(),
527
+ ide: z
528
+ .object({
529
+ enabled: booleanPreprocess.optional(),
530
+ hasSeenNudge: booleanPreprocess.optional(),
531
+ })
532
+ .passthrough()
533
+ .optional(),
534
+ privacy: z
535
+ .object({
536
+ usageStatisticsEnabled: booleanPreprocess.optional(),
537
+ })
538
+ .passthrough()
539
+ .optional(),
540
+ advanced: z
541
+ .object({
542
+ autoConfigureMemory: booleanPreprocess.optional(),
543
+ bugCommand: z.record(z.string(), z.unknown()).optional(),
544
+ dnsResolutionOrder: z.string().optional(),
545
+ excludedEnvVars: z.array(z.string()).optional(),
546
+ ignoreLocalEnv: booleanPreprocess.optional(),
547
+ })
548
+ .passthrough()
549
+ .optional(),
550
+ experimental: z
551
+ .object({
552
+ enableAgents: booleanPreprocess.optional(),
553
+ skills: booleanPreprocess.optional(),
554
+ extensionManagement: booleanPreprocess.optional(),
555
+ worktrees: booleanPreprocess.optional(),
556
+ })
557
+ .passthrough()
558
+ .optional(),
559
+ extensions: z.record(z.string(), z.unknown()).optional(),
560
+ agents: z.record(z.string(), z.unknown()).optional(),
561
+ policyPaths: z.array(z.string()).optional(),
562
+ adminPolicyPaths: z.array(z.string()).optional(),
563
+ })
564
+ .passthrough()
565
+ .superRefine((obj, ctx) => {
566
+ for (const key of Object.keys(obj)) {
567
+ if (LEGACY_V1_ONLY_KEYS.has(key)) {
568
+ ctx.addIssue({
569
+ code: z.ZodIssueCode.custom,
570
+ path: [key],
571
+ message: `Unmigrated V1 setting '${key}' is not valid at the root of V2 settings.`,
572
+ });
573
+ }
574
+ }
575
+ });
576
+ export function validateSettings(data) {
577
+ const result = settingsZodSchema.safeParse(data);
578
+ if (result.success) {
579
+ return {
580
+ success: true,
28
581
  // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
29
- const parsedUserSettings = JSON.parse(stripJsonComments(userContent));
30
- userSettings = resolveEnvVarsInObject(parsedUserSettings);
582
+ data: result.data,
583
+ };
584
+ }
585
+ return {
586
+ success: false,
587
+ error: result.error,
588
+ };
589
+ }
590
+ function loadAndMigrateFile(filePath, settingsErrors) {
591
+ try {
592
+ if (fs.existsSync(filePath)) {
593
+ const content = fs.readFileSync(filePath, 'utf-8');
594
+ const parsed = JSON.parse(stripJsonComments(content));
595
+ if (!isPlainObject(parsed)) {
596
+ settingsErrors.push({
597
+ message: 'Settings file is not a valid JSON object.',
598
+ path: filePath,
599
+ });
600
+ return {};
601
+ }
602
+ const resolved = resolveEnvVarsInObject(parsed);
603
+ const migrated = migrateDeprecatedSettings(resolved);
604
+ const validation = validateSettings(migrated);
605
+ if (!validation.success && validation.error) {
606
+ settingsErrors.push({
607
+ message: validation.error.message,
608
+ path: filePath,
609
+ });
610
+ return migrated;
611
+ }
612
+ return validation.data ?? migrated;
31
613
  }
32
614
  }
33
615
  catch (error) {
34
616
  settingsErrors.push({
35
617
  message: getErrorMessage(error),
36
- path: USER_SETTINGS_PATH,
618
+ path: filePath,
37
619
  });
38
620
  }
621
+ return {};
622
+ }
623
+ /**
624
+ * Loads settings from user and workspace directories.
625
+ * Project settings override user settings if the workspace is trusted.
626
+ *
627
+ * How is it different to gemini-cli/cli: Returns already merged settings rather
628
+ * than `LoadedSettings` (unnecessary since we are not modifying users
629
+ * settings.json).
630
+ */
631
+ export function loadSettings(workspaceDir, isTrustedOverride) {
632
+ const settingsErrors = [];
633
+ const userSettings = loadAndMigrateFile(USER_SETTINGS_PATH, settingsErrors);
39
634
  let isTrusted = isTrustedOverride;
40
635
  if (isTrusted === undefined) {
636
+ const isFolderTrustEnabled = userSettings.security?.folderTrust?.enabled ?? true;
41
637
  const { isTrusted: trustResult } = checkPathTrust({
42
638
  path: workspaceDir,
43
- isFolderTrustEnabled: userSettings.folderTrust ?? true,
639
+ isFolderTrustEnabled,
44
640
  isHeadless: isHeadlessMode(),
45
641
  });
46
642
  isTrusted = trustResult ?? false;
47
643
  }
48
644
  const workspaceSettingsPath = path.join(workspaceDir, GEMINI_DIR, 'settings.json');
49
- // Load workspace settings only if trusted
50
- if (isTrusted) {
51
- try {
52
- if (fs.existsSync(workspaceSettingsPath)) {
53
- const projectContent = fs.readFileSync(workspaceSettingsPath, 'utf-8');
54
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
55
- const parsedWorkspaceSettings = JSON.parse(stripJsonComments(projectContent));
56
- workspaceSettings = resolveEnvVarsInObject(parsedWorkspaceSettings);
57
- }
58
- }
59
- catch (error) {
60
- settingsErrors.push({
61
- message: getErrorMessage(error),
62
- path: workspaceSettingsPath,
63
- });
64
- }
65
- }
645
+ const workspaceSettings = isTrusted
646
+ ? loadAndMigrateFile(workspaceSettingsPath, settingsErrors)
647
+ : {};
66
648
  if (settingsErrors.length > 0) {
67
649
  debugLogger.error('Errors loading settings:');
68
650
  for (const error of settingsErrors) {
@@ -70,12 +652,11 @@ export function loadSettings(workspaceDir, isTrustedOverride) {
70
652
  debugLogger.error(` Message: ${error.message}`);
71
653
  }
72
654
  }
73
- // If there are overlapping keys, the values of workspaceSettings will
74
- // override values from userSettings
75
- const mergedSettings = {
76
- ...userSettings,
77
- ...workspaceSettings,
78
- };
655
+ const mergedSettings = customDeepMerge(getMergeStrategyForPath,
656
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
657
+ userSettings,
658
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
659
+ workspaceSettings);
79
660
  // Security: ensure policyPaths and adminPolicyPaths are only loaded from trusted, user-level
80
661
  // configuration and cannot be overridden by workspace-level settings, even if the
81
662
  // workspace is trusted.
@@ -83,18 +664,27 @@ export function loadSettings(workspaceDir, isTrustedOverride) {
83
664
  mergedSettings.adminPolicyPaths = userSettings.adminPolicyPaths;
84
665
  return mergedSettings;
85
666
  }
86
- function resolveEnvVarsInString(value) {
87
- const envVarRegex = /\$(?:(\w+)|{([^}]+)})/g; // Find $VAR_NAME or ${VAR_NAME}
88
- return value.replace(envVarRegex, (match, varName1, varName2) => {
667
+ export function resolveEnvVarsInString(value) {
668
+ const envVarRegex = /\$(?:(\w+)|{([^}]+?)(?::-([^}]*))?})/g;
669
+ return value.replace(envVarRegex, (match, varName1, varName2, defaultValue) => {
89
670
  const varName = varName1 || varName2;
671
+ if (!varName) {
672
+ return match;
673
+ }
90
674
  const envValue = process?.env?.[varName];
91
675
  if (typeof envValue === 'string') {
92
676
  return envValue;
93
677
  }
678
+ if (defaultValue !== undefined) {
679
+ return defaultValue;
680
+ }
94
681
  return match;
95
682
  });
96
683
  }
97
- function resolveEnvVarsInObject(obj) {
684
+ export function resolveEnvVarsInObject(obj) {
685
+ return resolveEnvVarsInObjectInternal(obj, new WeakSet());
686
+ }
687
+ function resolveEnvVarsInObjectInternal(obj, visited) {
98
688
  if (obj === null ||
99
689
  obj === undefined ||
100
690
  typeof obj === 'boolean' ||
@@ -106,16 +696,35 @@ function resolveEnvVarsInObject(obj) {
106
696
  return resolveEnvVarsInString(obj);
107
697
  }
108
698
  if (Array.isArray(obj)) {
109
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-return
110
- return obj.map((item) => resolveEnvVarsInObject(item));
699
+ if (visited.has(obj)) {
700
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
701
+ return [...obj];
702
+ }
703
+ visited.add(obj);
704
+ const mapped = obj.map((item) => resolveEnvVarsInObjectInternal(item, visited));
705
+ visited.delete(obj);
706
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
707
+ return mapped;
111
708
  }
112
709
  if (typeof obj === 'object') {
113
- const newObj = { ...obj };
114
- for (const key in newObj) {
115
- if (Object.prototype.hasOwnProperty.call(newObj, key)) {
116
- newObj[key] = resolveEnvVarsInObject(newObj[key]);
710
+ if (!isPlainObject(obj)) {
711
+ return obj;
712
+ }
713
+ const objRef = obj;
714
+ if (visited.has(objRef)) {
715
+ return { ...obj };
716
+ }
717
+ visited.add(objRef);
718
+ const newObj = {};
719
+ const sourceRecord = obj;
720
+ for (const key of Object.keys(sourceRecord)) {
721
+ if (isUnsafeKey(key)) {
722
+ continue;
117
723
  }
724
+ newObj[key] = resolveEnvVarsInObjectInternal(sourceRecord[key], visited);
118
725
  }
726
+ visited.delete(objRef);
727
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
119
728
  return newObj;
120
729
  }
121
730
  return obj;