Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
1d8a2ad
fix(auth): prevent infinite auth loop from file contention, headless …
villahernandez-coder Sep 22, 2026
bca175f
fix(auth): address PR #29448 review comments for headless keyring, IP…
villahernandez-coder Sep 22, 2026
971ffbf
Merge branch 'main' into FixBug-cla-561555286
villahernandez-coder Sep 22, 2026
05d13fa
fix(auth): persist auth override across relaunch loops in child proce…
villahernandez-coder Sep 22, 2026
3f79a2f
fix(auth): handle process.send backpressure timeout and enforce super…
villahernandez-coder Sep 22, 2026
0cf17e8
fix(security): prevent prototype pollution in applyKeyDiff and avoid …
villahernandez-coder Sep 22, 2026
106ae18
fix(auth): retry oauth_creds.json reads on transient contention
villahernandez-coder Sep 23, 2026
3a82fb8
Merge branch 'main' into FixBug-cla-561555286
villahernandez-coder Sep 23, 2026
16ef9ce
Merge branch 'main' into FixBug-cla-561555286
villahernandez-coder Sep 24, 2026
f8c6c92
fix(ci): resolve prettier formatting, vitest globals, and eval-invent…
villahernandez-coder Sep 24, 2026
a4f1688
fix(auth): address review feedback on synchronous sleep and atomic te…
villahernandez-coder Sep 24, 2026
c5ab925
Merge branch 'main' into FixBug-cla-561555286
villahernandez-coder Sep 24, 2026
cdff87e
Merge branch 'main' into FixBug-cla-561555286
villahernandez-coder Sep 25, 2026
4caf189
Merge branch 'main' into FixBug-cla-561555286
villahernandez-coder Sep 25, 2026
d785e0c
fix(test-utils): optimize test directory cleanup to prevent Windows E…
villahernandez-coder Sep 25, 2026
cd3bc97
Merge branch 'main' into FixBug-cla-561555286
villahernandez-coder Sep 28, 2026
ab21bb2
fix(auth): address review feedback on headless keyring, busy wait, an…
villahernandez-coder Sep 28, 2026
76ebd0e
fix(cli): remove synchronous readFileWithRetry in commentJson
villahernandez-coder Sep 28, 2026
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
21 changes: 16 additions & 5 deletions packages/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,15 +76,26 @@ async function run() {
env: newEnv,
});

// Clear one-time auth override from supervisor environment after passing to child
delete process.env['GEMINI_CLI_AUTH_OVERRIDE'];
delete newEnv['GEMINI_CLI_AUTH_OVERRIDE'];

if (latestAdminSettings) {
child.send({ type: 'admin-settings', settings: latestAdminSettings });
}

child.on('message', (msg: { type?: string; settings?: unknown }) => {
if (msg.type === 'admin-settings-update' && msg.settings) {
latestAdminSettings = msg.settings;
}
});
child.on(
'message',
(msg: { type?: string; settings?: unknown; authType?: string }) => {
if (msg.type === 'admin-settings-update' && msg.settings) {
latestAdminSettings = msg.settings;
}
if (msg.type === 'auth-selected-type' && msg.authType) {
process.env['GEMINI_CLI_AUTH_OVERRIDE'] = msg.authType;
newEnv['GEMINI_CLI_AUTH_OVERRIDE'] = msg.authType;
}
},
);

return new Promise<number>((resolve) => {
child.on('error', (err) => {
Expand Down
37 changes: 37 additions & 0 deletions packages/cli/src/config/settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,43 @@ describe('Settings Loading and Merging', () => {
},
);

it('should unconditionally override selectedType when GEMINI_CLI_AUTH_OVERRIDE is present and delete it from process.env', () => {
(mockFsExistsSync as Mock).mockImplementation(
(pathLike: fs.PathLike) =>
path.normalize(pathLike.toString()) ===
path.normalize(USER_SETTINGS_PATH),
);
(fs.readFileSync as Mock).mockImplementation(
(pathDesc: fs.PathOrFileDescriptor) => {
if (
path.normalize(pathDesc.toString()) ===
path.normalize(USER_SETTINGS_PATH)
) {
return JSON.stringify({
security: {
auth: {
selectedType: AuthType.USE_GEMINI,
},
},
});
}
return '{}';
},
);

vi.stubEnv('GEMINI_CLI_AUTH_OVERRIDE', AuthType.LOGIN_WITH_GOOGLE);

const settings = loadSettings(MOCK_WORKSPACE_DIR);

expect(settings.user.settings.security?.auth?.selectedType).toBe(
AuthType.LOGIN_WITH_GOOGLE,
);
expect(settings.merged.security.auth.selectedType).toBe(
AuthType.LOGIN_WITH_GOOGLE,
);
expect(process.env['GEMINI_CLI_AUTH_OVERRIDE']).toBeUndefined();
});

it('should merge system, user and workspace settings, with system taking precedence over workspace, and workspace over user', () => {
(mockFsExistsSync as Mock).mockImplementation((p: fs.PathLike) => {
const normP = path.normalize(p.toString());
Expand Down
29 changes: 29 additions & 0 deletions packages/cli/src/config/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -904,6 +904,35 @@ function _doLoadSettings(workspaceDir: string): LoadedSettings {
userSettings = userResult.settings;
workspaceSettings = workspaceResult.settings;

// Support environment variable override from relaunch supervisor across exit code 199
const envAuthOverride = process.env['GEMINI_CLI_AUTH_OVERRIDE'];
if (envAuthOverride) {
delete process.env['GEMINI_CLI_AUTH_OVERRIDE'];
}
const authOverride =
envAuthOverride &&
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
Object.values(AuthType).includes(envAuthOverride as AuthType)
? // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(envAuthOverride as AuthType)
: undefined;
if (authOverride) {
if (!userSettings.security) {
userSettings.security = {};
}
if (!userSettings.security.auth) {
userSettings.security.auth = {};
}
userSettings.security.auth.selectedType = authOverride;
if (!userOriginalSettings.security) {
userOriginalSettings.security = {};
}
if (!userOriginalSettings.security.auth) {
userOriginalSettings.security.auth = {};
}
userOriginalSettings.security.auth.selectedType = authOverride;
}

// Support legacy theme names
if (userSettings.ui?.theme === 'VS') {
userSettings.ui.theme = DefaultLight.name;
Expand Down
14 changes: 12 additions & 2 deletions packages/cli/src/ui/auth/AuthDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
AuthType,
clearCachedCredentialFile,
type Config,
debugLogger,
} from '@google/gemini-cli-core';
import { useKeypress } from '../hooks/useKeypress.js';
import { AuthState } from '../types.js';
Expand Down Expand Up @@ -129,15 +130,24 @@ export function AuthDialog({
} else {
setAuthContext({});
}
await clearCachedCredentialFile();

const currentAuthType = settings.merged.security?.auth?.selectedType;
if (currentAuthType && currentAuthType !== authType) {
await clearCachedCredentialFile();
}

settings.setValue(scope, 'security.auth.selectedType', authType);
if (
authType === AuthType.LOGIN_WITH_GOOGLE &&
config.isBrowserLaunchSuppressed()
) {
setExiting(true);
setTimeout(relaunchApp, 100);
setTimeout(() => {
void relaunchApp({ overrideAuthType: authType }).catch((err) => {
debugLogger.error('Failed to trigger supervisor relaunch:', err);
process.exit(1);
});
}, 100);
return;
}

Expand Down
33 changes: 33 additions & 0 deletions packages/cli/src/utils/commentJson.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -366,5 +366,38 @@ describe('commentJson', () => {

expect(updatedContent).toContain('// This should be preserved');
});

it('should ignore dangerous keys (__proto__, constructor, prototype) to prevent prototype pollution', () => {
const originalContent = `{
"theme": "light"
}`;

fs.writeFileSync(testFilePath, originalContent, 'utf-8');

const maliciousUpdates = JSON.parse(
'{"__proto__": {"polluted": "yes"}, "theme": "dark"}',
) as Record<string, unknown>;

updateSettingsFilePreservingFormat(testFilePath, maliciousUpdates);

// Verify prototype was not polluted
expect(
(Object.prototype as unknown as Record<string, unknown>)['polluted'],
).toBeUndefined();

const updatedContent = fs.readFileSync(testFilePath, 'utf-8');
expect(updatedContent).toContain('"theme": "dark"');
});

it('should handle ENOENT gracefully if file is missing during read', () => {
const nonExistentPath = path.join(tempDir, 'missing.json');
updateSettingsFilePreservingFormat(nonExistentPath, {
theme: 'dark',
});

expect(fs.existsSync(nonExistentPath)).toBe(true);
const content = fs.readFileSync(nonExistentPath, 'utf-8');
expect(JSON.parse(content)).toEqual({ theme: 'dark' });
});
});
});
81 changes: 74 additions & 7 deletions packages/cli/src/utils/commentJson.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
*/

import * as fs from 'node:fs';
import * as path from 'node:path';
import { parse, stringify } from 'comment-json';
import { coreEvents } from '@google/gemini-cli-core';

Expand All @@ -13,25 +14,82 @@ import { coreEvents } from '@google/gemini-cli-core';
*/
type CommentedRecord = Record<string | symbol, unknown>;

function isDangerousKey(key: string): boolean {
return key === '__proto__' || key === 'constructor' || key === 'prototype';
}

let tempCounter = 0;

function writeAtomicSync(filePath: string, content: string): void {
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
const tempPath = path.join(
dir,
`.${path.basename(filePath)}.${process.pid}.${Date.now()}.${tempCounter++}.tmp`,
);
try {
fs.writeFileSync(tempPath, content, 'utf-8');
fs.renameSync(tempPath, filePath);
} catch {
try {
if (fs.existsSync(tempPath)) {
fs.unlinkSync(tempPath);
}
} catch {
// ignore
}
// Fallback to direct write if rename fails
fs.writeFileSync(filePath, content, 'utf-8');
}
}

/**
* Updates a JSON file while preserving comments and formatting.
*/
export function updateSettingsFilePreservingFormat(
filePath: string,
updates: Record<string, unknown>,
): void {
const dirPath = path.dirname(filePath);
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}

if (!fs.existsSync(filePath)) {
fs.writeFileSync(filePath, JSON.stringify(updates, null, 2), 'utf-8');
writeAtomicSync(filePath, JSON.stringify(updates, null, 2));
return;
}

const originalContent = fs.readFileSync(filePath, 'utf-8');

let parsed: Record<string, unknown>;
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
parsed = parse(originalContent) as Record<string, unknown>;
} catch (error) {
const originalContent = fs.readFileSync(filePath, 'utf-8');
if (!originalContent.trim()) {
parsed = {};
} else {
const rawParsed: unknown = parse(originalContent);
if (
typeof rawParsed !== 'object' ||
rawParsed === null ||
Array.isArray(rawParsed)
) {
parsed = {};
} else {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
parsed = rawParsed as Record<string, unknown>;
}
}
} catch (error: unknown) {
if (
error &&
typeof error === 'object' &&
'code' in error &&
error.code === 'ENOENT'
) {
writeAtomicSync(filePath, JSON.stringify(updates, null, 2));
return;
}
coreEvents.emitFeedback(
'error',
'Error parsing settings file. Please check the JSON syntax.',
Expand All @@ -43,7 +101,7 @@ export function updateSettingsFilePreservingFormat(
const updatedStructure = applyUpdates(parsed, updates);
const updatedContent = stringify(updatedStructure, null, 2);

fs.writeFileSync(filePath, updatedContent, 'utf-8');
writeAtomicSync(filePath, updatedContent);
Comment thread
villahernandez-coder marked this conversation as resolved.
}

/**
Expand All @@ -58,6 +116,9 @@ function preserveCommentsOnPropertyDeletion(
container: Record<string, unknown>,
propName: string,
): void {
if (isDangerousKey(propName)) {
return;
}
const target = container as CommentedRecord;
const beforeSym = Symbol.for(`before:${propName}`);
const afterSym = Symbol.for(`after:${propName}`);
Expand Down Expand Up @@ -117,13 +178,19 @@ function applyKeyDiff(
desired: Record<string, unknown>,
): void {
for (const existingKey of Object.getOwnPropertyNames(base)) {
if (isDangerousKey(existingKey)) {
continue;
}
if (!Object.prototype.hasOwnProperty.call(desired, existingKey)) {
preserveCommentsOnPropertyDeletion(base, existingKey);
delete base[existingKey];
}
}

for (const nextKey of Object.getOwnPropertyNames(desired)) {
if (isDangerousKey(nextKey)) {
continue;
}
const nextVal = desired[nextKey];
const baseVal = base[nextKey];

Expand Down
64 changes: 63 additions & 1 deletion packages/cli/src/utils/processUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/

import { vi } from 'vitest';
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import {
RELAUNCH_EXIT_CODE,
relaunchApp,
Expand Down Expand Up @@ -40,6 +40,68 @@ describe('processUtils', () => {
expect(runExitCleanup).toHaveBeenCalledTimes(1);
expect(processExit).toHaveBeenCalledWith(RELAUNCH_EXIT_CODE);
});

it('should send auth override IPC message with callback if options.overrideAuthType is provided', async () => {
const originalSend = process.send;
const sendMock = vi.fn((_msg: unknown, cb?: () => void) => {
if (cb) cb();
return true;
});
Object.defineProperty(process, 'send', {
value: sendMock,
configurable: true,
writable: true,
});
vi.stubEnv('VITEST', '');

try {
await relaunchApp({ overrideAuthType: 'login_with_google' });
expect(sendMock).toHaveBeenCalledWith(
{
type: 'auth-selected-type',
authType: 'login_with_google',
},
expect.any(Function),
);
expect(processExit).toHaveBeenCalledWith(RELAUNCH_EXIT_CODE);
} finally {
Object.defineProperty(process, 'send', {
value: originalSend,
configurable: true,
writable: true,
});
}
});

it('should fall back to timeout if process.send callback is never called', async () => {
vi.useFakeTimers();
const originalSend = process.send;
const sendMock = vi.fn(() => false);
Object.defineProperty(process, 'send', {
value: sendMock,
configurable: true,
writable: true,
});
vi.stubEnv('VITEST', '');

try {
const relaunchPromise = relaunchApp({
overrideAuthType: 'login_with_google',
});
await vi.advanceTimersByTimeAsync(500);
await relaunchPromise;

expect(sendMock).toHaveBeenCalled();
expect(processExit).toHaveBeenCalledWith(RELAUNCH_EXIT_CODE);
} finally {
Object.defineProperty(process, 'send', {
value: originalSend,
configurable: true,
writable: true,
});
vi.useRealTimers();
}
});
});

describe('SEA handling utilities', () => {
Expand Down
Loading
Loading