Skip to content

Commit 75dbf90

Browse files
authored
A2a admin setting (google-gemini#17868)
1 parent 675ca07 commit 75dbf90

4 files changed

Lines changed: 317 additions & 30 deletions

File tree

packages/a2a-server/src/config/config.test.ts

Lines changed: 142 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@ import type { Settings } from './settings.js';
1111
import {
1212
type ExtensionLoader,
1313
FileDiscoveryService,
14+
getCodeAssistServer,
15+
Config,
16+
ExperimentFlags,
17+
fetchAdminControlsOnce,
18+
type FetchAdminControlsResponse,
1419
} from '@google/gemini-cli-core';
1520

1621
// Mock dependencies
@@ -19,18 +24,35 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
1924
await importOriginal<typeof import('@google/gemini-cli-core')>();
2025
return {
2126
...actual,
22-
Config: vi.fn().mockImplementation((params) => ({
23-
initialize: vi.fn(),
24-
refreshAuth: vi.fn(),
25-
...params, // Expose params for assertion
26-
})),
27+
Config: vi.fn().mockImplementation((params) => {
28+
const mockConfig = {
29+
...params,
30+
initialize: vi.fn(),
31+
refreshAuth: vi.fn(),
32+
getExperiments: vi.fn().mockReturnValue({
33+
flags: {
34+
[actual.ExperimentFlags.ENABLE_ADMIN_CONTROLS]: {
35+
boolValue: false,
36+
},
37+
},
38+
}),
39+
getRemoteAdminSettings: vi.fn(),
40+
setRemoteAdminSettings: vi.fn(),
41+
};
42+
return mockConfig;
43+
}),
2744
loadServerHierarchicalMemory: vi
2845
.fn()
2946
.mockResolvedValue({ memoryContent: '', fileCount: 0, filePaths: [] }),
3047
startupProfiler: {
3148
flush: vi.fn(),
3249
},
3350
FileDiscoveryService: vi.fn(),
51+
getCodeAssistServer: vi.fn(),
52+
fetchAdminControlsOnce: vi.fn(),
53+
coreEvents: {
54+
emitAdminSettingsChanged: vi.fn(),
55+
},
3456
};
3557
});
3658

@@ -56,6 +78,121 @@ describe('loadConfig', () => {
5678
delete process.env['GEMINI_API_KEY'];
5779
});
5880

81+
describe('admin settings overrides', () => {
82+
it('should not fetch admin controls if experiment is disabled', async () => {
83+
await loadConfig(mockSettings, mockExtensionLoader, taskId);
84+
expect(fetchAdminControlsOnce).not.toHaveBeenCalled();
85+
});
86+
87+
describe('when admin controls experiment is enabled', () => {
88+
beforeEach(() => {
89+
// We need to cast to any here to modify the mock implementation
90+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
91+
(Config as any).mockImplementation((params: unknown) => {
92+
const mockConfig = {
93+
...(params as object),
94+
initialize: vi.fn(),
95+
refreshAuth: vi.fn(),
96+
getExperiments: vi.fn().mockReturnValue({
97+
flags: {
98+
[ExperimentFlags.ENABLE_ADMIN_CONTROLS]: {
99+
boolValue: true,
100+
},
101+
},
102+
}),
103+
getRemoteAdminSettings: vi.fn().mockReturnValue({}),
104+
setRemoteAdminSettings: vi.fn(),
105+
};
106+
return mockConfig;
107+
});
108+
});
109+
110+
it('should fetch admin controls and apply them', async () => {
111+
const mockAdminSettings: FetchAdminControlsResponse = {
112+
mcpSetting: {
113+
mcpEnabled: false,
114+
},
115+
cliFeatureSetting: {
116+
extensionsSetting: {
117+
extensionsEnabled: false,
118+
},
119+
},
120+
strictModeDisabled: false,
121+
};
122+
vi.mocked(fetchAdminControlsOnce).mockResolvedValue(mockAdminSettings);
123+
124+
await loadConfig(mockSettings, mockExtensionLoader, taskId);
125+
126+
expect(Config).toHaveBeenCalledWith(
127+
expect.objectContaining({
128+
disableYoloMode: !mockAdminSettings.strictModeDisabled,
129+
mcpEnabled: mockAdminSettings.mcpSetting?.mcpEnabled,
130+
extensionsEnabled:
131+
mockAdminSettings.cliFeatureSetting?.extensionsSetting
132+
?.extensionsEnabled,
133+
}),
134+
);
135+
});
136+
137+
it('should treat unset admin settings as false when admin settings are passed', async () => {
138+
const mockAdminSettings: FetchAdminControlsResponse = {
139+
mcpSetting: {
140+
mcpEnabled: true,
141+
},
142+
};
143+
vi.mocked(fetchAdminControlsOnce).mockResolvedValue(mockAdminSettings);
144+
145+
await loadConfig(mockSettings, mockExtensionLoader, taskId);
146+
147+
expect(Config).toHaveBeenCalledWith(
148+
expect.objectContaining({
149+
disableYoloMode: !false,
150+
mcpEnabled: mockAdminSettings.mcpSetting?.mcpEnabled,
151+
extensionsEnabled: false,
152+
}),
153+
);
154+
});
155+
156+
it('should not pass default unset admin settings when no admin settings are present', async () => {
157+
const mockAdminSettings: FetchAdminControlsResponse = {};
158+
vi.mocked(fetchAdminControlsOnce).mockResolvedValue(mockAdminSettings);
159+
160+
await loadConfig(mockSettings, mockExtensionLoader, taskId);
161+
162+
expect(Config).toHaveBeenCalledWith(expect.objectContaining({}));
163+
});
164+
165+
it('should fetch admin controls using the code assist server when available', async () => {
166+
const mockAdminSettings: FetchAdminControlsResponse = {
167+
mcpSetting: {
168+
mcpEnabled: true,
169+
},
170+
strictModeDisabled: true,
171+
};
172+
const mockCodeAssistServer = { projectId: 'test-project' };
173+
vi.mocked(getCodeAssistServer).mockReturnValue(
174+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
175+
mockCodeAssistServer as any,
176+
);
177+
vi.mocked(fetchAdminControlsOnce).mockResolvedValue(mockAdminSettings);
178+
179+
await loadConfig(mockSettings, mockExtensionLoader, taskId);
180+
181+
expect(fetchAdminControlsOnce).toHaveBeenCalledWith(
182+
mockCodeAssistServer,
183+
true,
184+
);
185+
expect(Config).toHaveBeenCalledWith(
186+
expect.objectContaining({
187+
disableYoloMode: !mockAdminSettings.strictModeDisabled,
188+
mcpEnabled: mockAdminSettings.mcpSetting?.mcpEnabled,
189+
extensionsEnabled: false,
190+
}),
191+
);
192+
});
193+
});
194+
});
195+
59196
it('should set customIgnoreFilePaths when CUSTOM_IGNORE_FILE_PATHS env var is present', async () => {
60197
const testPath = '/tmp/ignore';
61198
process.env['CUSTOM_IGNORE_FILE_PATHS'] = testPath;

packages/a2a-server/src/config/config.ts

Lines changed: 75 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ import {
2424
PREVIEW_GEMINI_MODEL,
2525
homedir,
2626
GitService,
27+
fetchAdminControlsOnce,
28+
getCodeAssistServer,
29+
ExperimentFlags,
2730
} from '@google/gemini-cli-core';
2831

2932
import { logger } from '../utils/logger.js';
@@ -124,38 +127,55 @@ export async function loadConfig(
124127
configParams.userMemory = memoryContent;
125128
configParams.geminiMdFileCount = fileCount;
126129
configParams.geminiMdFilePaths = filePaths;
127-
const config = new Config({
130+
131+
// Set an initial config to use to get a code assist server.
132+
// This is needed to fetch admin controls.
133+
const initialConfig = new Config({
128134
...configParams,
129135
});
130-
// Needed to initialize ToolRegistry, and git checkpointing if enabled
131-
await config.initialize();
132-
startupProfiler.flush(config);
133136

134-
if (process.env['USE_CCPA']) {
135-
logger.info('[Config] Using CCPA Auth:');
136-
try {
137-
if (adcFilePath) {
138-
path.resolve(adcFilePath);
139-
}
140-
} catch (e) {
141-
logger.error(
142-
`[Config] USE_CCPA env var is true but unable to resolve GOOGLE_APPLICATION_CREDENTIALS file path ${adcFilePath}. Error ${e}`,
137+
const codeAssistServer = getCodeAssistServer(initialConfig);
138+
139+
const adminControlsEnabled =
140+
initialConfig.getExperiments()?.flags[ExperimentFlags.ENABLE_ADMIN_CONTROLS]
141+
?.boolValue ?? false;
142+
143+
// Initialize final config parameters to the previous parameters.
144+
// If no admin controls are needed, these will be used as-is for the final
145+
// config.
146+
const finalConfigParams = { ...configParams };
147+
if (adminControlsEnabled) {
148+
const adminSettings = await fetchAdminControlsOnce(
149+
codeAssistServer,
150+
adminControlsEnabled,
151+
);
152+
153+
// Admin settings are able to be undefined if unset, but if any are present,
154+
// we should initialize them all.
155+
// If any are present, undefined settings should be treated as if they were
156+
// set to false.
157+
// If NONE are present, disregard admin settings entirely, and pass the
158+
// final config as is.
159+
if (Object.keys(adminSettings).length !== 0) {
160+
finalConfigParams.disableYoloMode = !(
161+
adminSettings.strictModeDisabled ?? false
143162
);
163+
finalConfigParams.mcpEnabled =
164+
adminSettings.mcpSetting?.mcpEnabled ?? false;
165+
finalConfigParams.extensionsEnabled =
166+
adminSettings.cliFeatureSetting?.extensionsSetting?.extensionsEnabled ??
167+
false;
144168
}
145-
await config.refreshAuth(AuthType.LOGIN_WITH_GOOGLE);
146-
logger.info(
147-
`[Config] GOOGLE_CLOUD_PROJECT: ${process.env['GOOGLE_CLOUD_PROJECT']}`,
148-
);
149-
} else if (process.env['GEMINI_API_KEY']) {
150-
logger.info('[Config] Using Gemini API Key');
151-
await config.refreshAuth(AuthType.USE_GEMINI);
152-
} else {
153-
const errorMessage =
154-
'[Config] Unable to set GeneratorConfig. Please provide a GEMINI_API_KEY or set USE_CCPA.';
155-
logger.error(errorMessage);
156-
throw new Error(errorMessage);
157169
}
158170

171+
const config = new Config(finalConfigParams);
172+
173+
// Needed to initialize ToolRegistry, and git checkpointing if enabled
174+
await config.initialize();
175+
startupProfiler.flush(config);
176+
177+
await refreshAuthentication(config, adcFilePath, 'Config');
178+
159179
return config;
160180
}
161181

@@ -222,3 +242,33 @@ function findEnvFile(startDir: string): string | null {
222242
currentDir = parentDir;
223243
}
224244
}
245+
246+
async function refreshAuthentication(
247+
config: Config,
248+
adcFilePath: string | undefined,
249+
logPrefix: string,
250+
): Promise<void> {
251+
if (process.env['USE_CCPA']) {
252+
logger.info(`[${logPrefix}] Using CCPA Auth:`);
253+
try {
254+
if (adcFilePath) {
255+
path.resolve(adcFilePath);
256+
}
257+
} catch (e) {
258+
logger.error(
259+
`[${logPrefix}] USE_CCPA env var is true but unable to resolve GOOGLE_APPLICATION_CREDENTIALS file path ${adcFilePath}. Error ${e}`,
260+
);
261+
}
262+
await config.refreshAuth(AuthType.LOGIN_WITH_GOOGLE);
263+
logger.info(
264+
`[${logPrefix}] GOOGLE_CLOUD_PROJECT: ${process.env['GOOGLE_CLOUD_PROJECT']}`,
265+
);
266+
} else if (process.env['GEMINI_API_KEY']) {
267+
logger.info(`[${logPrefix}] Using Gemini API Key`);
268+
await config.refreshAuth(AuthType.USE_GEMINI);
269+
} else {
270+
const errorMessage = `[${logPrefix}] Unable to set GeneratorConfig. Please provide a GEMINI_API_KEY or set USE_CCPA.`;
271+
logger.error(errorMessage);
272+
throw new Error(errorMessage);
273+
}
274+
}

packages/core/src/code_assist/admin/admin_controls.test.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
} from 'vitest';
1616
import {
1717
fetchAdminControls,
18+
fetchAdminControlsOnce,
1819
sanitizeAdminSettings,
1920
stopAdminControlsPolling,
2021
getAdminErrorMessage,
@@ -248,6 +249,71 @@ describe('Admin Controls', () => {
248249
});
249250
});
250251

252+
describe('fetchAdminControlsOnce', () => {
253+
it('should return empty object if server is missing', async () => {
254+
const result = await fetchAdminControlsOnce(undefined, true);
255+
expect(result).toEqual({});
256+
expect(mockServer.fetchAdminControls).not.toHaveBeenCalled();
257+
});
258+
259+
it('should return empty object if project ID is missing', async () => {
260+
mockServer = {
261+
fetchAdminControls: vi.fn(),
262+
} as unknown as CodeAssistServer;
263+
const result = await fetchAdminControlsOnce(mockServer, true);
264+
expect(result).toEqual({});
265+
expect(mockServer.fetchAdminControls).not.toHaveBeenCalled();
266+
});
267+
268+
it('should return empty object if admin controls are disabled', async () => {
269+
const result = await fetchAdminControlsOnce(mockServer, false);
270+
expect(result).toEqual({});
271+
expect(mockServer.fetchAdminControls).not.toHaveBeenCalled();
272+
});
273+
274+
it('should fetch from server and sanitize the response', async () => {
275+
const serverResponse = {
276+
strictModeDisabled: true,
277+
unknownField: 'should be removed',
278+
};
279+
(mockServer.fetchAdminControls as Mock).mockResolvedValue(serverResponse);
280+
281+
const result = await fetchAdminControlsOnce(mockServer, true);
282+
expect(result).toEqual({ strictModeDisabled: true });
283+
expect(mockServer.fetchAdminControls).toHaveBeenCalledTimes(1);
284+
});
285+
286+
it('should return empty object on 403 fetch error', async () => {
287+
const error403 = new Error('Forbidden');
288+
Object.assign(error403, { status: 403 });
289+
(mockServer.fetchAdminControls as Mock).mockRejectedValue(error403);
290+
291+
const result = await fetchAdminControlsOnce(mockServer, true);
292+
expect(result).toEqual({});
293+
expect(mockServer.fetchAdminControls).toHaveBeenCalledTimes(1);
294+
});
295+
296+
it('should return empty object on any other fetch error', async () => {
297+
(mockServer.fetchAdminControls as Mock).mockRejectedValue(
298+
new Error('Network error'),
299+
);
300+
const result = await fetchAdminControlsOnce(mockServer, true);
301+
expect(result).toEqual({});
302+
expect(mockServer.fetchAdminControls).toHaveBeenCalledTimes(1);
303+
});
304+
305+
it('should not start or stop any polling timers', async () => {
306+
const setIntervalSpy = vi.spyOn(global, 'setInterval');
307+
const clearIntervalSpy = vi.spyOn(global, 'clearInterval');
308+
309+
(mockServer.fetchAdminControls as Mock).mockResolvedValue({});
310+
await fetchAdminControlsOnce(mockServer, true);
311+
312+
expect(setIntervalSpy).not.toHaveBeenCalled();
313+
expect(clearIntervalSpy).not.toHaveBeenCalled();
314+
});
315+
});
316+
251317
describe('polling', () => {
252318
it('should poll and emit changes', async () => {
253319
// Initial fetch

0 commit comments

Comments
 (0)