Skip to content

Commit 5ea5107

Browse files
authored
refactor(cli): fix settings merging so that settings using the new json format take priority over ones using the old format (#15116)
1 parent 2995af6 commit 5ea5107

File tree

5 files changed

+276
-3
lines changed

5 files changed

+276
-3
lines changed

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

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,37 @@ describe('Settings Loading and Merging', () => {
371371
expect((settings.merged as TestSettings)['allowedTools']).toBeUndefined();
372372
});
373373

374+
it('should allow V2 settings to override V1 settings when both are present (zombie setting fix)', () => {
375+
(mockFsExistsSync as Mock).mockImplementation(
376+
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
377+
);
378+
const mixedSettingsContent = {
379+
// V1 setting (migrates to ui.accessibility.screenReader = true)
380+
accessibility: {
381+
screenReader: true,
382+
},
383+
// V2 setting (explicitly set to false)
384+
ui: {
385+
accessibility: {
386+
screenReader: false,
387+
},
388+
},
389+
};
390+
391+
(fs.readFileSync as Mock).mockImplementation(
392+
(p: fs.PathOrFileDescriptor) => {
393+
if (p === USER_SETTINGS_PATH)
394+
return JSON.stringify(mixedSettingsContent);
395+
return '{}';
396+
},
397+
);
398+
399+
const settings = loadSettings(MOCK_WORKSPACE_DIR);
400+
401+
// We expect the V2 setting (false) to win, NOT the migrated V1 setting (true)
402+
expect(settings.merged.ui?.accessibility?.screenReader).toBe(false);
403+
});
404+
374405
it('should correctly merge and migrate legacy array properties from multiple scopes', () => {
375406
(mockFsExistsSync as Mock).mockReturnValue(true);
376407
const legacyUserSettings = {

packages/cli/src/config/settings.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,19 @@ function migrateSettingsToV2(
302302

303303
for (const [oldKey, newPath] of Object.entries(MIGRATION_MAP)) {
304304
if (flatKeys.has(oldKey)) {
305+
// If the key exists and is a V2 container (like 'model'), and the value is an object,
306+
// it is likely already migrated or partially migrated. We should not move it
307+
// to the mapped V2 path (e.g. 'model' -> 'model.name').
308+
// Instead, let it fall through to the "Carry over" section to be merged.
309+
if (
310+
KNOWN_V2_CONTAINERS.has(oldKey) &&
311+
typeof flatSettings[oldKey] === 'object' &&
312+
flatSettings[oldKey] !== null &&
313+
!Array.isArray(flatSettings[oldKey])
314+
) {
315+
continue;
316+
}
317+
305318
setNestedProperty(v2Settings, newPath, flatSettings[oldKey]);
306319
flatKeys.delete(oldKey);
307320
}
@@ -331,8 +344,8 @@ function migrateSettingsToV2(
331344
v2Settings[remainingKey] = customDeepMerge(
332345
pathAwareGetStrategy,
333346
{},
334-
newValue as MergeableObject,
335347
existingValue as MergeableObject,
348+
newValue as MergeableObject,
336349
);
337350
} else {
338351
v2Settings[remainingKey] = newValue;
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
/// <reference types="vitest/globals" />
8+
9+
// Mock 'os' first.
10+
import * as osActual from 'node:os';
11+
12+
vi.mock('os', async (importOriginal) => {
13+
const actualOs = await importOriginal<typeof osActual>();
14+
return {
15+
...actualOs,
16+
homedir: vi.fn(() => '/mock/home/user'),
17+
platform: vi.fn(() => 'linux'),
18+
};
19+
});
20+
21+
// Mock './settings.js' to ensure it uses the mocked 'os.homedir()' for its internal constants.
22+
vi.mock('./settings.js', async (importActual) => {
23+
const originalModule = await importActual<typeof import('./settings.js')>();
24+
return {
25+
__esModule: true,
26+
...originalModule,
27+
};
28+
});
29+
30+
// Mock trustedFolders
31+
vi.mock('./trustedFolders.js', () => ({
32+
isWorkspaceTrusted: vi
33+
.fn()
34+
.mockReturnValue({ isTrusted: true, source: 'file' }),
35+
}));
36+
37+
import {
38+
describe,
39+
it,
40+
expect,
41+
vi,
42+
beforeEach,
43+
afterEach,
44+
type Mocked,
45+
type Mock,
46+
} from 'vitest';
47+
import * as fs from 'node:fs';
48+
import stripJsonComments from 'strip-json-comments';
49+
import { isWorkspaceTrusted } from './trustedFolders.js';
50+
51+
import { loadSettings, USER_SETTINGS_PATH } from './settings.js';
52+
53+
const MOCK_WORKSPACE_DIR = '/mock/workspace';
54+
55+
vi.mock('fs', async (importOriginal) => {
56+
const actualFs = await importOriginal<typeof fs>();
57+
return {
58+
...actualFs,
59+
existsSync: vi.fn(),
60+
readFileSync: vi.fn(),
61+
writeFileSync: vi.fn(),
62+
mkdirSync: vi.fn(),
63+
renameSync: vi.fn(),
64+
realpathSync: (p: string) => p,
65+
};
66+
});
67+
68+
vi.mock('./extension.js');
69+
70+
const mockCoreEvents = vi.hoisted(() => ({
71+
emitFeedback: vi.fn(),
72+
}));
73+
74+
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
75+
const actual =
76+
await importOriginal<typeof import('@google/gemini-cli-core')>();
77+
return {
78+
...actual,
79+
coreEvents: mockCoreEvents,
80+
};
81+
});
82+
83+
vi.mock('../utils/commentJson.js', () => ({
84+
updateSettingsFilePreservingFormat: vi.fn(),
85+
}));
86+
87+
vi.mock('strip-json-comments', () => ({
88+
default: vi.fn((content) => content),
89+
}));
90+
91+
describe('Settings Repro', () => {
92+
let mockFsExistsSync: Mocked<typeof fs.existsSync>;
93+
let mockStripJsonComments: Mocked<typeof stripJsonComments>;
94+
let mockFsMkdirSync: Mocked<typeof fs.mkdirSync>;
95+
96+
beforeEach(() => {
97+
vi.resetAllMocks();
98+
99+
mockFsExistsSync = vi.mocked(fs.existsSync);
100+
mockFsMkdirSync = vi.mocked(fs.mkdirSync);
101+
mockStripJsonComments = vi.mocked(stripJsonComments);
102+
103+
vi.mocked(osActual.homedir).mockReturnValue('/mock/home/user');
104+
(mockStripJsonComments as unknown as Mock).mockImplementation(
105+
(jsonString: string) => jsonString,
106+
);
107+
(mockFsExistsSync as Mock).mockReturnValue(false);
108+
(fs.readFileSync as Mock).mockReturnValue('{}');
109+
(mockFsMkdirSync as Mock).mockImplementation(() => undefined);
110+
vi.mocked(isWorkspaceTrusted).mockReturnValue({
111+
isTrusted: true,
112+
source: 'file',
113+
});
114+
});
115+
116+
afterEach(() => {
117+
vi.restoreAllMocks();
118+
});
119+
120+
it('should handle the problematic settings.json without crashing', () => {
121+
(mockFsExistsSync as Mock).mockImplementation(
122+
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
123+
);
124+
const problemSettingsContent = {
125+
accessibility: {
126+
screenReader: true,
127+
},
128+
ide: {
129+
enabled: false,
130+
hasSeenNudge: true,
131+
},
132+
general: {
133+
debugKeystrokeLogging: false,
134+
enablePromptCompletion: false,
135+
preferredEditor: 'vim',
136+
vimMode: false,
137+
previewFeatures: false,
138+
},
139+
security: {
140+
auth: {
141+
selectedType: 'gemini-api-key',
142+
},
143+
folderTrust: {
144+
enabled: true,
145+
},
146+
},
147+
tools: {
148+
useRipgrep: true,
149+
shell: {
150+
showColor: true,
151+
enableInteractiveShell: true,
152+
},
153+
enableMessageBusIntegration: true,
154+
},
155+
experimental: {
156+
useModelRouter: false,
157+
enableSubagents: false,
158+
codebaseInvestigatorSettings: {
159+
enabled: true,
160+
},
161+
},
162+
ui: {
163+
accessibility: {
164+
screenReader: false,
165+
},
166+
showMemoryUsage: true,
167+
showStatusInTitle: true,
168+
showCitations: true,
169+
useInkScrolling: true,
170+
footer: {
171+
hideContextPercentage: false,
172+
hideModelInfo: false,
173+
},
174+
},
175+
useWriteTodos: true,
176+
output: {
177+
format: 'text',
178+
},
179+
model: {
180+
compressionThreshold: 0.8,
181+
},
182+
};
183+
184+
(fs.readFileSync as Mock).mockImplementation(
185+
(p: fs.PathOrFileDescriptor) => {
186+
if (p === USER_SETTINGS_PATH)
187+
return JSON.stringify(problemSettingsContent);
188+
return '{}';
189+
},
190+
);
191+
192+
const settings = loadSettings(MOCK_WORKSPACE_DIR);
193+
194+
// If it doesn't throw, check if it merged correctly.
195+
// The model.compressionThreshold should be present.
196+
// And model.name should probably be undefined or default, but certainly NOT { compressionThreshold: 0.8 }
197+
expect(settings.merged.model?.compressionThreshold).toBe(0.8);
198+
expect(typeof settings.merged.model?.name).not.toBe('object');
199+
});
200+
});

packages/cli/src/utils/deepMerge.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,4 +199,28 @@ describe('customDeepMerge', () => {
199199
// eslint-disable-next-line @typescript-eslint/no-explicit-any
200200
expect((result as any)['hooks']['disabled']).toEqual(['hook-a', 'hook-b']);
201201
});
202+
203+
it('should overwrite primitive with object', () => {
204+
const target = { a: 1 };
205+
const source = { a: { b: 2 } };
206+
const getMergeStrategy = () => undefined;
207+
const result = customDeepMerge(getMergeStrategy, target, source);
208+
expect(result).toEqual({ a: { b: 2 } });
209+
});
210+
211+
it('should overwrite object with primitive', () => {
212+
const target = { a: { b: 2 } };
213+
const source = { a: 1 };
214+
const getMergeStrategy = () => undefined;
215+
const result = customDeepMerge(getMergeStrategy, target, source);
216+
expect(result).toEqual({ a: 1 });
217+
});
218+
219+
it('should not overwrite with undefined', () => {
220+
const target = { a: 1 };
221+
const source = { a: undefined };
222+
const getMergeStrategy = () => undefined;
223+
const result = customDeepMerge(getMergeStrategy, target, source);
224+
expect(result).toEqual({ a: 1 });
225+
});
202226
});

packages/cli/src/utils/deepMerge.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,16 @@ function mergeRecursively(
2828
path: string[] = [],
2929
) {
3030
for (const key of Object.keys(source)) {
31-
if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
31+
// JSON.parse can create objects with __proto__ as an own property.
32+
// We must skip it to prevent prototype pollution.
33+
if (key === '__proto__') {
3234
continue;
3335
}
34-
const newPath = [...path, key];
3536
const srcValue = source[key];
37+
if (srcValue === undefined) {
38+
continue;
39+
}
40+
const newPath = [...path, key];
3641
const objValue = target[key];
3742
const mergeStrategy = getMergeStrategyForPath(newPath);
3843

0 commit comments

Comments
 (0)