Introduce GEMINI_CLI_HOME for strict test isolation (#15907)

This commit is contained in:
N. Taylor Mullen
2026-01-06 20:09:39 -08:00
committed by GitHub
parent a26463b056
commit 7956eb239e
54 changed files with 455 additions and 148 deletions
@@ -9,9 +9,8 @@ import { HybridTokenStorage } from '../mcp/token-storage/hybrid-token-storage.js
import { OAUTH_FILE } from '../config/storage.js';
import type { OAuthCredentials } from '../mcp/token-storage/types.js';
import * as path from 'node:path';
import * as os from 'node:os';
import { promises as fs } from 'node:fs';
import { GEMINI_DIR } from '../utils/paths.js';
import { GEMINI_DIR, homedir } from '../utils/paths.js';
import { coreEvents } from '../utils/events.js';
const KEYCHAIN_SERVICE_NAME = 'gemini-cli-oauth';
@@ -91,7 +90,7 @@ export class OAuthCredentialStorage {
await this.storage.deleteCredentials(MAIN_ACCOUNT_KEY);
// Also try to remove the old file if it exists
const oldFilePath = path.join(os.homedir(), GEMINI_DIR, OAUTH_FILE);
const oldFilePath = path.join(homedir(), GEMINI_DIR, OAUTH_FILE);
await fs.rm(oldFilePath, { force: true }).catch(() => {});
} catch (error: unknown) {
coreEvents.emitFeedback(
@@ -107,7 +106,7 @@ export class OAuthCredentialStorage {
* Migrate credentials from old file-based storage to keychain
*/
private static async migrateFromFileStorage(): Promise<Credentials | null> {
const oldFilePath = path.join(os.homedir(), GEMINI_DIR, OAUTH_FILE);
const oldFilePath = path.join(homedir(), GEMINI_DIR, OAUTH_FILE);
let credsJson: string;
try {
+54 -21
View File
@@ -26,16 +26,24 @@ import { AuthType } from '../core/contentGenerator.js';
import type { Config } from '../config/config.js';
import readline from 'node:readline';
import { FORCE_ENCRYPTED_FILE_ENV_VAR } from '../mcp/token-storage/index.js';
import { GEMINI_DIR } from '../utils/paths.js';
import { GEMINI_DIR, homedir as pathsHomedir } from '../utils/paths.js';
import { debugLogger } from '../utils/debugLogger.js';
import { writeToStdout } from '../utils/stdio.js';
import { FatalCancellationError } from '../utils/errors.js';
import process from 'node:process';
vi.mock('os', async (importOriginal) => {
const os = await importOriginal<typeof import('os')>();
vi.mock('node:os', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:os')>();
return {
...os,
...actual,
homedir: vi.fn(),
};
});
vi.mock('../utils/paths.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../utils/paths.js')>();
return {
...actual,
homedir: vi.fn(),
};
});
@@ -89,6 +97,7 @@ describe('oauth2', () => {
path.join(os.tmpdir(), 'gemini-cli-test-home-'),
);
vi.mocked(os.homedir).mockReturnValue(tempHomeDir);
vi.mocked(pathsHomedir).mockReturnValue(tempHomeDir);
});
afterEach(() => {
fs.rmSync(tempHomeDir, { recursive: true, force: true });
@@ -1129,15 +1138,10 @@ describe('oauth2', () => {
() => mockHttpServer as unknown as http.Server,
);
// Mock process.on to immediately trigger SIGINT
// Mock process.on to capture SIGINT handler
const processOnSpy = vi
.spyOn(process, 'on')
.mockImplementation((event, listener: () => void) => {
if (event === 'SIGINT') {
listener();
}
return process;
});
.mockImplementation(() => process);
const processRemoveListenerSpy = vi.spyOn(process, 'removeListener');
@@ -1146,6 +1150,24 @@ describe('oauth2', () => {
mockConfig,
);
// Wait for the SIGINT handler to be registered
let sigIntHandler: (() => void) | undefined;
await vi.waitFor(() => {
const sigintCall = processOnSpy.mock.calls.find(
(call) => call[0] === 'SIGINT',
);
sigIntHandler = sigintCall?.[1] as (() => void) | undefined;
if (!sigIntHandler)
throw new Error('SIGINT handler not registered yet');
});
expect(sigIntHandler).toBeDefined();
// Trigger SIGINT
if (sigIntHandler) {
sigIntHandler();
}
await expect(clientPromise).rejects.toThrow(FatalCancellationError);
expect(processRemoveListenerSpy).toHaveBeenCalledWith(
'SIGINT',
@@ -1180,17 +1202,10 @@ describe('oauth2', () => {
() => mockHttpServer as unknown as http.Server,
);
// Spy on process.stdin.on and immediately trigger Ctrl+C
// Spy on process.stdin.on to capture data handler
const stdinOnSpy = vi
.spyOn(process.stdin, 'on')
.mockImplementation(
(event: string, listener: (data: Buffer) => void) => {
if (event === 'data') {
listener(Buffer.from([0x03]));
}
return process.stdin;
},
);
.mockImplementation(() => process.stdin);
const stdinRemoveListenerSpy = vi.spyOn(
process.stdin,
@@ -1202,6 +1217,23 @@ describe('oauth2', () => {
mockConfig,
);
// Wait for the stdin handler to be registered
let dataHandler: ((data: Buffer) => void) | undefined;
await vi.waitFor(() => {
const dataCall = stdinOnSpy.mock.calls.find(
(call: [string, ...unknown[]]) => call[0] === 'data',
);
dataHandler = dataCall?.[1] as ((data: Buffer) => void) | undefined;
if (!dataHandler) throw new Error('stdin handler not registered yet');
});
expect(dataHandler).toBeDefined();
// Trigger Ctrl+C
if (dataHandler) {
dataHandler(Buffer.from([0x03]));
}
await expect(clientPromise).rejects.toThrow(FatalCancellationError);
expect(stdinRemoveListenerSpy).toHaveBeenCalledWith(
'data',
@@ -1302,7 +1334,8 @@ describe('oauth2', () => {
tempHomeDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'gemini-cli-test-home-'),
);
(os.homedir as Mock).mockReturnValue(tempHomeDir);
vi.mocked(os.homedir).mockReturnValue(tempHomeDir);
vi.mocked(pathsHomedir).mockReturnValue(tempHomeDir);
});
afterEach(() => {
+1 -1
View File
@@ -336,7 +336,7 @@ async function initOauthClient(
// Note that SIGINT might not get raised on Ctrl+C in raw mode
// so we also need to look for Ctrl+C directly in stdin.
stdinHandler = (data) => {
stdinHandler = (data: Buffer) => {
if (data.includes(0x03)) {
reject(
new FatalCancellationError('Authentication cancelled by user.'),
+2 -2
View File
@@ -8,7 +8,7 @@ import * as path from 'node:path';
import * as os from 'node:os';
import * as crypto from 'node:crypto';
import * as fs from 'node:fs';
import { GEMINI_DIR } from '../utils/paths.js';
import { GEMINI_DIR, homedir } from '../utils/paths.js';
export const GOOGLE_ACCOUNTS_FILENAME = 'google_accounts.json';
export const OAUTH_FILE = 'oauth_creds.json';
@@ -23,7 +23,7 @@ export class Storage {
}
static getGlobalGeminiDir(): string {
const homeDir = os.homedir();
const homeDir = homedir();
if (!homeDir) {
return path.join(os.tmpdir(), GEMINI_DIR);
}
+2 -3
View File
@@ -6,7 +6,6 @@
import path from 'node:path';
import fs from 'node:fs';
import os from 'node:os';
import {
EDIT_TOOL_NAME,
GLOB_TOOL_NAME,
@@ -23,7 +22,7 @@ import process from 'node:process';
import { isGitRepository } from '../utils/gitUtils.js';
import { CodebaseInvestigatorAgent } from '../agents/codebase-investigator.js';
import type { Config } from '../config/config.js';
import { GEMINI_DIR } from '../utils/paths.js';
import { GEMINI_DIR, homedir } from '../utils/paths.js';
import { debugLogger } from '../utils/debugLogger.js';
import { WriteTodosTool } from '../tools/write-todos.js';
import { resolveModel, isPreviewModel } from '../config/models.js';
@@ -53,7 +52,7 @@ export function resolvePathFromEnv(envVar?: string): {
// Safely expand the tilde (~) character to the user's home directory.
if (customPath.startsWith('~/') || customPath === '~') {
try {
const home = os.homedir(); // This is the call that can throw an error.
const home = homedir(); // This is the call that can throw an error.
if (customPath === '~') {
customPath = home;
} else {
+11 -2
View File
@@ -14,8 +14,15 @@ vi.mock('node:child_process', async (importOriginal) => {
spawnSync: vi.fn(() => ({ status: 0 })),
};
});
vi.mock('fs');
vi.mock('os');
vi.mock('node:fs');
vi.mock('node:os');
vi.mock('../utils/paths.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../utils/paths.js')>();
return {
...actual,
homedir: vi.fn(),
};
});
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { getIdeInstaller } from './ide-installer.js';
@@ -24,12 +31,14 @@ import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { IDE_DEFINITIONS, type IdeInfo } from './detect-ide.js';
import { homedir as pathsHomedir } from '../utils/paths.js';
describe('ide-installer', () => {
const HOME_DIR = '/home/user';
beforeEach(() => {
vi.spyOn(os, 'homedir').mockReturnValue(HOME_DIR);
vi.mocked(pathsHomedir).mockReturnValue(HOME_DIR);
});
afterEach(() => {
+2 -2
View File
@@ -8,9 +8,9 @@ import * as child_process from 'node:child_process';
import * as process from 'node:process';
import * as path from 'node:path';
import * as fs from 'node:fs';
import * as os from 'node:os';
import { IDE_DEFINITIONS, type IdeInfo } from './detect-ide.js';
import { GEMINI_CLI_COMPANION_EXTENSION_NAME } from './constants.js';
import { homedir } from '../utils/paths.js';
export interface IdeInstaller {
install(): Promise<InstallResult>;
@@ -49,7 +49,7 @@ async function findCommand(
// 2. Check common installation locations.
const locations: string[] = [];
const homeDir = os.homedir();
const homeDir = homedir();
if (command === 'code' || command === 'code.cmd') {
if (platform === 'darwin') {
+1
View File
@@ -50,6 +50,7 @@ export * from './code_assist/telemetry.js';
export * from './core/apiKeyCredentialStorage.js';
// Export utilities
export { homedir, tmpdir } from './utils/paths.js';
export * from './utils/paths.js';
export * from './utils/schemaValidator.js';
export * from './utils/errors.js';
@@ -10,7 +10,7 @@ import * as os from 'node:os';
import * as crypto from 'node:crypto';
import { BaseTokenStorage } from './base-token-storage.js';
import type { OAuthCredentials } from './types.js';
import { GEMINI_DIR } from '../../utils/paths.js';
import { GEMINI_DIR, homedir } from '../../utils/paths.js';
export class FileTokenStorage extends BaseTokenStorage {
private readonly tokenFilePath: string;
@@ -18,7 +18,7 @@ export class FileTokenStorage extends BaseTokenStorage {
constructor(serviceName: string) {
super(serviceName);
const configDir = path.join(os.homedir(), GEMINI_DIR);
const configDir = path.join(homedir(), GEMINI_DIR);
this.tokenFilePath = path.join(configDir, 'mcp-oauth-tokens-v2.json');
this.encryptionKey = this.deriveEncryptionKey();
}
+15 -2
View File
@@ -18,7 +18,11 @@ import { Storage } from '../config/storage.js';
import * as path from 'node:path';
import * as fs from 'node:fs/promises';
import * as os from 'node:os';
import { getProjectHash, GEMINI_DIR } from '../utils/paths.js';
import {
getProjectHash,
GEMINI_DIR,
homedir as pathsHomedir,
} from '../utils/paths.js';
import { spawnAsync } from '../utils/shell-utils.js';
vi.mock('../utils/shell-utils.js', () => ({
@@ -52,7 +56,7 @@ vi.mock('../utils/gitUtils.js', () => ({
}));
const hoistedMockHomedir = vi.hoisted(() => vi.fn());
vi.mock('os', async (importOriginal) => {
vi.mock('node:os', async (importOriginal) => {
const actual = await importOriginal<typeof os>();
return {
...actual,
@@ -60,6 +64,14 @@ vi.mock('os', async (importOriginal) => {
};
});
vi.mock('../utils/paths.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../utils/paths.js')>();
return {
...actual,
homedir: vi.fn(),
};
});
const hoistedMockDebugLogger = vi.hoisted(() => ({
debug: vi.fn(),
warn: vi.fn(),
@@ -93,6 +105,7 @@ describe('GitService', () => {
});
hoistedMockHomedir.mockReturnValue(homedir);
(pathsHomedir as Mock).mockReturnValue(homedir);
hoistedMockEnv.mockImplementation(() => ({
checkIsRepo: hoistedMockCheckIsRepo,
@@ -11,7 +11,7 @@ import * as fs from 'node:fs';
import * as os from 'node:os';
import path from 'node:path';
import { randomUUID } from 'node:crypto';
import { GEMINI_DIR } from './paths.js';
import { GEMINI_DIR, homedir as pathsHomedir } from './paths.js';
import { debugLogger } from './debugLogger.js';
vi.mock('node:fs', async (importOriginal) => {
@@ -23,22 +23,30 @@ vi.mock('node:fs', async (importOriginal) => {
} as typeof actual;
});
vi.mock('os', async (importOriginal) => {
const os = await importOriginal<typeof import('os')>();
vi.mock('node:os', async (importOriginal) => {
const os = await importOriginal<typeof import('node:os')>();
return {
...os,
homedir: vi.fn(),
};
});
vi.mock('crypto', async (importOriginal) => {
const crypto = await importOriginal<typeof import('crypto')>();
vi.mock('node:crypto', async (importOriginal) => {
const crypto = await importOriginal<typeof import('node:crypto')>();
return {
...crypto,
randomUUID: vi.fn(),
};
});
vi.mock('./paths.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('./paths.js')>();
return {
...actual,
homedir: vi.fn(),
};
});
describe('InstallationManager', () => {
let tempHomeDir: string;
let installationManager: InstallationManager;
@@ -49,6 +57,7 @@ describe('InstallationManager', () => {
tempHomeDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'gemini-cli-test-home-'),
);
(pathsHomedir as Mock).mockReturnValue(tempHomeDir);
(os.homedir as Mock).mockReturnValue(tempHomeDir);
installationManager = new InstallationManager();
});
@@ -35,6 +35,16 @@ vi.mock('os', async (importOriginal) => {
};
});
vi.mock('../utils/paths.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../utils/paths.js')>();
return {
...actual,
homedir: vi.fn(),
};
});
import { homedir as pathsHomedir } from './paths.js';
describe('memoryDiscovery', () => {
const DEFAULT_FOLDER_TRUST = true;
let testRootDir: string;
@@ -67,6 +77,7 @@ describe('memoryDiscovery', () => {
cwd = await createEmptyDir(path.join(projectRoot, 'src'));
homedir = await createEmptyDir(path.join(testRootDir, 'userhome'));
vi.mocked(os.homedir).mockReturnValue(homedir);
vi.mocked(pathsHomedir).mockReturnValue(homedir);
});
afterEach(async () => {
+1 -2
View File
@@ -7,14 +7,13 @@
import * as fs from 'node:fs/promises';
import * as fsSync from 'node:fs';
import * as path from 'node:path';
import { homedir } from 'node:os';
import { bfsFileSearch } from './bfsFileSearch.js';
import { getAllGeminiMdFilenames } from '../tools/memoryTool.js';
import type { FileDiscoveryService } from '../services/fileDiscoveryService.js';
import { processImports } from './memoryImportProcessor.js';
import type { FileFilteringOptions } from '../config/constants.js';
import { DEFAULT_MEMORY_FILE_FILTERING_OPTIONS } from '../config/constants.js';
import { GEMINI_DIR } from './paths.js';
import { GEMINI_DIR, homedir } from './paths.js';
import type { ExtensionLoader } from './extensionLoader.js';
import { debugLogger } from './debugLogger.js';
import type { Config } from '../config/config.js';
+22 -1
View File
@@ -6,6 +6,7 @@
import path from 'node:path';
import os from 'node:os';
import process from 'node:process';
import * as crypto from 'node:crypto';
export const GEMINI_DIR = '.gemini';
@@ -18,13 +19,33 @@ export const GOOGLE_ACCOUNTS_FILENAME = 'google_accounts.json';
*/
export const SHELL_SPECIAL_CHARS = /[ \t()[\]{};|*?$`'"#&<>!~]/;
/**
* Returns the home directory.
* If GEMINI_CLI_HOME environment variable is set, it returns its value.
* Otherwise, it returns the user's home directory.
*/
export function homedir(): string {
const envHome = process.env['GEMINI_CLI_HOME'];
if (envHome) {
return envHome;
}
return os.homedir();
}
/**
* Returns the operating system's default directory for temporary files.
*/
export function tmpdir(): string {
return os.tmpdir();
}
/**
* Replaces the home directory with a tilde.
* @param path - The path to tildeify.
* @returns The tildeified path.
*/
export function tildeifyPath(path: string): string {
const homeDir = os.homedir();
const homeDir = homedir();
if (path.startsWith(homeDir)) {
return path.replace(homeDir, '~');
}
@@ -10,13 +10,13 @@ import { UserAccountManager } from './userAccountManager.js';
import * as fs from 'node:fs';
import * as os from 'node:os';
import path from 'node:path';
import { GEMINI_DIR } from './paths.js';
import { GEMINI_DIR, homedir as pathsHomedir } from './paths.js';
import { debugLogger } from './debugLogger.js';
vi.mock('os', async (importOriginal) => {
const os = await importOriginal<typeof import('os')>();
vi.mock('./paths.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('./paths.js')>();
return {
...os,
...actual,
homedir: vi.fn(),
};
});
@@ -30,7 +30,7 @@ describe('UserAccountManager', () => {
tempHomeDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'gemini-cli-test-home-'),
);
(os.homedir as Mock).mockReturnValue(tempHomeDir);
(pathsHomedir as Mock).mockReturnValue(tempHomeDir);
accountsFile = () =>
path.join(tempHomeDir, GEMINI_DIR, 'google_accounts.json');
userAccountManager = new UserAccountManager();