Improve code coverage for cli package (#13724)

This commit is contained in:
Megha Bansal
2025-11-24 23:11:46 +05:30
committed by GitHub
parent 569c6f1dd0
commit 95693e265e
47 changed files with 5115 additions and 489 deletions

View File

@@ -0,0 +1,32 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { checkExhaustive, assumeExhaustive } from './checks.js';
describe('checks', () => {
describe('checkExhaustive', () => {
it('should throw an error with default message', () => {
expect(() => {
checkExhaustive('unexpected' as never);
}).toThrow('unexpected value unexpected!');
});
it('should throw an error with custom message', () => {
expect(() => {
checkExhaustive('unexpected' as never, 'custom message');
}).toThrow('custom message');
});
});
describe('assumeExhaustive', () => {
it('should do nothing', () => {
expect(() => {
assumeExhaustive('unexpected' as never);
}).not.toThrow();
});
});
});

View File

@@ -5,61 +5,122 @@
*/
import { vi, describe, it, expect, beforeEach } from 'vitest';
import type { registerCleanup, runExitCleanup } from './cleanup.js';
import { promises as fs } from 'node:fs';
import * as path from 'node:path';
vi.mock('@google/gemini-cli-core', () => ({
Storage: vi.fn().mockImplementation(() => ({
getProjectTempDir: vi.fn().mockReturnValue('/tmp/project'),
})),
}));
vi.mock('node:fs', () => ({
promises: {
rm: vi.fn(),
},
}));
describe('cleanup', () => {
let register: typeof registerCleanup;
let runExit: typeof runExitCleanup;
beforeEach(async () => {
vi.resetModules();
const cleanupModule = await import('./cleanup.js');
register = cleanupModule.registerCleanup;
runExit = cleanupModule.runExitCleanup;
}, 30000);
vi.clearAllMocks();
// No need to re-assign, we can use the imported functions directly
// because we are using vi.resetModules() and re-importing if necessary,
// but actually, since we are mocking dependencies, we might not need to re-import cleanup.js
// unless it has internal state that needs resetting. It does (cleanupFunctions array).
// So we DO need to re-import it to get fresh state.
});
it('should run a registered synchronous function', async () => {
const cleanupModule = await import('./cleanup.js');
const cleanupFn = vi.fn();
register(cleanupFn);
cleanupModule.registerCleanup(cleanupFn);
await runExit();
await cleanupModule.runExitCleanup();
expect(cleanupFn).toHaveBeenCalledTimes(1);
});
it('should run a registered asynchronous function', async () => {
const cleanupModule = await import('./cleanup.js');
const cleanupFn = vi.fn().mockResolvedValue(undefined);
register(cleanupFn);
cleanupModule.registerCleanup(cleanupFn);
await runExit();
await cleanupModule.runExitCleanup();
expect(cleanupFn).toHaveBeenCalledTimes(1);
});
it('should run multiple registered functions', async () => {
const cleanupModule = await import('./cleanup.js');
const syncFn = vi.fn();
const asyncFn = vi.fn().mockResolvedValue(undefined);
register(syncFn);
register(asyncFn);
cleanupModule.registerCleanup(syncFn);
cleanupModule.registerCleanup(asyncFn);
await runExit();
await cleanupModule.runExitCleanup();
expect(syncFn).toHaveBeenCalledTimes(1);
expect(asyncFn).toHaveBeenCalledTimes(1);
});
it('should continue running cleanup functions even if one throws an error', async () => {
const cleanupModule = await import('./cleanup.js');
const errorFn = vi.fn().mockImplementation(() => {
throw new Error('test error');
});
const successFn = vi.fn();
register(errorFn);
register(successFn);
cleanupModule.registerCleanup(errorFn);
cleanupModule.registerCleanup(successFn);
await expect(runExit()).resolves.not.toThrow();
await expect(cleanupModule.runExitCleanup()).resolves.not.toThrow();
expect(errorFn).toHaveBeenCalledTimes(1);
expect(successFn).toHaveBeenCalledTimes(1);
});
describe('sync cleanup', () => {
it('should run registered sync functions', async () => {
const cleanupModule = await import('./cleanup.js');
const syncFn = vi.fn();
cleanupModule.registerSyncCleanup(syncFn);
cleanupModule.runSyncCleanup();
expect(syncFn).toHaveBeenCalledTimes(1);
});
it('should continue running sync cleanup functions even if one throws', async () => {
const cleanupModule = await import('./cleanup.js');
const errorFn = vi.fn().mockImplementation(() => {
throw new Error('test error');
});
const successFn = vi.fn();
cleanupModule.registerSyncCleanup(errorFn);
cleanupModule.registerSyncCleanup(successFn);
expect(() => cleanupModule.runSyncCleanup()).not.toThrow();
expect(errorFn).toHaveBeenCalledTimes(1);
expect(successFn).toHaveBeenCalledTimes(1);
});
});
describe('cleanupCheckpoints', () => {
it('should remove checkpoints directory', async () => {
const cleanupModule = await import('./cleanup.js');
await cleanupModule.cleanupCheckpoints();
expect(fs.rm).toHaveBeenCalledWith(
path.join('/tmp/project', 'checkpoints'),
{
recursive: true,
force: true,
},
);
});
it('should ignore errors during checkpoint removal', async () => {
const cleanupModule = await import('./cleanup.js');
vi.mocked(fs.rm).mockRejectedValue(new Error('Failed to remove'));
await expect(cleanupModule.cleanupCheckpoints()).resolves.not.toThrow();
});
});
});

View File

@@ -0,0 +1,108 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { SettingScope } from '../config/settings.js';
import type { LoadedSettings } from '../config/settings.js';
import {
getScopeItems,
getScopeMessageForSetting,
} from './dialogScopeUtils.js';
import { settingExistsInScope } from './settingsUtils.js';
vi.mock('../config/settings', () => ({
SettingScope: {
User: 'user',
Workspace: 'workspace',
System: 'system',
},
isLoadableSettingScope: (scope: string) =>
['user', 'workspace', 'system'].includes(scope),
}));
vi.mock('./settingsUtils', () => ({
settingExistsInScope: vi.fn(),
}));
describe('dialogScopeUtils', () => {
beforeEach(() => {
vi.resetAllMocks();
});
describe('getScopeItems', () => {
it('should return scope items with correct labels and values', () => {
const items = getScopeItems();
expect(items).toEqual([
{ label: 'User Settings', value: SettingScope.User },
{ label: 'Workspace Settings', value: SettingScope.Workspace },
{ label: 'System Settings', value: SettingScope.System },
]);
});
});
describe('getScopeMessageForSetting', () => {
let mockSettings: { forScope: ReturnType<typeof vi.fn> };
beforeEach(() => {
mockSettings = {
forScope: vi.fn().mockReturnValue({ settings: {} }),
};
});
it('should return empty string if not modified in other scopes', () => {
vi.mocked(settingExistsInScope).mockReturnValue(false);
const message = getScopeMessageForSetting(
'key',
SettingScope.User,
mockSettings as unknown as LoadedSettings,
);
expect(message).toBe('');
});
it('should return message indicating modification in other scopes', () => {
vi.mocked(settingExistsInScope).mockReturnValue(true);
const message = getScopeMessageForSetting(
'key',
SettingScope.User,
mockSettings as unknown as LoadedSettings,
);
expect(message).toMatch(/Also modified in/);
expect(message).toMatch(/workspace/);
expect(message).toMatch(/system/);
});
it('should return message indicating modification in other scopes but not current', () => {
const workspaceSettings = { scope: 'workspace' };
const systemSettings = { scope: 'system' };
const userSettings = { scope: 'user' };
mockSettings.forScope.mockImplementation((scope: string) => {
if (scope === SettingScope.Workspace)
return { settings: workspaceSettings };
if (scope === SettingScope.System) return { settings: systemSettings };
if (scope === SettingScope.User) return { settings: userSettings };
return { settings: {} };
});
vi.mocked(settingExistsInScope).mockImplementation(
(_key, settings: unknown) => {
if (settings === workspaceSettings) return true;
if (settings === systemSettings) return false;
if (settings === userSettings) return false;
return false;
},
);
const message = getScopeMessageForSetting(
'key',
SettingScope.User,
mockSettings as unknown as LoadedSettings,
);
expect(message).toBe('(Modified in workspace)');
});
});
});

View File

@@ -43,6 +43,16 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
),
),
})),
StreamJsonFormatter: vi.fn().mockImplementation(() => ({
emitEvent: vi.fn(),
convertToStreamStats: vi.fn().mockReturnValue({}),
})),
uiTelemetryService: {
getMetrics: vi.fn().mockReturnValue({}),
},
JsonStreamEventType: {
RESULT: 'result',
},
FatalToolExecutionError: class extends Error {
constructor(message: string) {
super(message);
@@ -248,6 +258,30 @@ describe('errors', () => {
);
});
});
describe('in STREAM_JSON mode', () => {
beforeEach(() => {
(
mockConfig.getOutputFormat as ReturnType<typeof vi.fn>
).mockReturnValue(OutputFormat.STREAM_JSON);
});
it('should emit result event and exit', () => {
const testError = new Error('Test error');
expect(() => {
handleError(testError, mockConfig);
}).toThrow('process.exit called with code: 1');
});
it('should extract exitCode from FatalError instances', () => {
const fatalError = new FatalInputError('Fatal error');
expect(() => {
handleError(fatalError, mockConfig);
}).toThrow('process.exit called with code: 42');
});
});
});
describe('handleToolError', () => {
@@ -377,6 +411,28 @@ describe('errors', () => {
});
});
});
describe('in STREAM_JSON mode', () => {
beforeEach(() => {
(
mockConfig.getOutputFormat as ReturnType<typeof vi.fn>
).mockReturnValue(OutputFormat.STREAM_JSON);
});
it('should emit result event and exit for fatal errors', () => {
expect(() => {
handleToolError(toolName, toolError, mockConfig, 'no_space_left');
}).toThrow('process.exit called with code: 54');
});
it('should log to stderr and not exit for non-fatal errors', () => {
handleToolError(toolName, toolError, mockConfig, 'invalid_tool_params');
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Error executing tool test-tool: Tool failed',
);
expect(processExitSpy).not.toHaveBeenCalled();
});
});
});
describe('handleCancellationError', () => {
@@ -423,6 +479,20 @@ describe('errors', () => {
);
});
});
describe('in STREAM_JSON mode', () => {
beforeEach(() => {
(
mockConfig.getOutputFormat as ReturnType<typeof vi.fn>
).mockReturnValue(OutputFormat.STREAM_JSON);
});
it('should emit result event and exit with 130', () => {
expect(() => {
handleCancellationError(mockConfig);
}).toThrow('process.exit called with code: 130');
});
});
});
describe('handleMaxTurnsExceededError', () => {
@@ -472,5 +542,19 @@ describe('errors', () => {
);
});
});
describe('in STREAM_JSON mode', () => {
beforeEach(() => {
(
mockConfig.getOutputFormat as ReturnType<typeof vi.fn>
).mockReturnValue(OutputFormat.STREAM_JSON);
});
it('should emit result event and exit with 53', () => {
expect(() => {
handleMaxTurnsExceededError(mockConfig);
}).toThrow('process.exit called with code: 53');
});
});
});
});

View File

@@ -0,0 +1,32 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi } from 'vitest';
import { appEvents, AppEvent } from './events.js';
describe('events', () => {
it('should allow registering and emitting events', () => {
const callback = vi.fn();
appEvents.on(AppEvent.OauthDisplayMessage, callback);
appEvents.emit(AppEvent.OauthDisplayMessage, 'test message');
expect(callback).toHaveBeenCalledWith('test message');
appEvents.off(AppEvent.OauthDisplayMessage, callback);
});
it('should work with events without data', () => {
const callback = vi.fn();
appEvents.on(AppEvent.Flicker, callback);
appEvents.emit(AppEvent.Flicker);
expect(callback).toHaveBeenCalled();
appEvents.off(AppEvent.Flicker, callback);
});
});

View File

@@ -11,7 +11,8 @@ import { updateEventEmitter } from './updateEventEmitter.js';
import type { UpdateObject } from '../ui/utils/updateCheck.js';
import type { LoadedSettings } from '../config/settings.js';
import EventEmitter from 'node:events';
import { handleAutoUpdate } from './handleAutoUpdate.js';
import { handleAutoUpdate, setUpdateHandler } from './handleAutoUpdate.js';
import { MessageType } from '../ui/types.js';
vi.mock('./installationInfo.js', async () => {
const actual = await vi.importActual('./installationInfo.js');
@@ -21,16 +22,11 @@ vi.mock('./installationInfo.js', async () => {
};
});
vi.mock('./updateEventEmitter.js', async () => {
const actual = await vi.importActual('./updateEventEmitter.js');
return {
...actual,
updateEventEmitter: {
...(actual['updateEventEmitter'] as EventEmitter),
emit: vi.fn(),
},
};
});
vi.mock(
'./updateEventEmitter.js',
async (importOriginal) =>
await importOriginal<typeof import('./updateEventEmitter.js')>(),
);
interface MockChildProcess extends EventEmitter {
stdin: EventEmitter & {
@@ -41,7 +37,7 @@ interface MockChildProcess extends EventEmitter {
}
const mockGetInstallationInfo = vi.mocked(getInstallationInfo);
const mockUpdateEventEmitter = vi.mocked(updateEventEmitter);
// updateEventEmitter is now real, but we will spy on it
describe('handleAutoUpdate', () => {
let mockSpawn: Mock;
@@ -52,6 +48,7 @@ describe('handleAutoUpdate', () => {
beforeEach(() => {
mockSpawn = vi.fn();
vi.clearAllMocks();
vi.spyOn(updateEventEmitter, 'emit');
mockUpdateInfo = {
update: {
latest: '2.0.0',
@@ -90,7 +87,7 @@ describe('handleAutoUpdate', () => {
it('should do nothing if update info is null', () => {
handleAutoUpdate(null, mockSettings, '/root', mockSpawn);
expect(mockGetInstallationInfo).not.toHaveBeenCalled();
expect(mockUpdateEventEmitter.emit).not.toHaveBeenCalled();
expect(updateEventEmitter.emit).not.toHaveBeenCalled();
expect(mockSpawn).not.toHaveBeenCalled();
});
@@ -98,7 +95,7 @@ describe('handleAutoUpdate', () => {
mockSettings.merged.general!.disableUpdateNag = true;
handleAutoUpdate(mockUpdateInfo, mockSettings, '/root', mockSpawn);
expect(mockGetInstallationInfo).not.toHaveBeenCalled();
expect(mockUpdateEventEmitter.emit).not.toHaveBeenCalled();
expect(updateEventEmitter.emit).not.toHaveBeenCalled();
expect(mockSpawn).not.toHaveBeenCalled();
});
@@ -113,13 +110,10 @@ describe('handleAutoUpdate', () => {
handleAutoUpdate(mockUpdateInfo, mockSettings, '/root', mockSpawn);
expect(mockUpdateEventEmitter.emit).toHaveBeenCalledTimes(1);
expect(mockUpdateEventEmitter.emit).toHaveBeenCalledWith(
'update-received',
{
message: 'An update is available!\nPlease update manually.',
},
);
expect(updateEventEmitter.emit).toHaveBeenCalledTimes(1);
expect(updateEventEmitter.emit).toHaveBeenCalledWith('update-received', {
message: 'An update is available!\nPlease update manually.',
});
expect(mockSpawn).not.toHaveBeenCalled();
});
@@ -135,7 +129,7 @@ describe('handleAutoUpdate', () => {
handleAutoUpdate(mockUpdateInfo, mockSettings, '/root', mockSpawn);
expect(mockUpdateEventEmitter.emit).not.toHaveBeenCalled();
expect(updateEventEmitter.emit).not.toHaveBeenCalled();
expect(mockSpawn).not.toHaveBeenCalled();
},
);
@@ -150,13 +144,10 @@ describe('handleAutoUpdate', () => {
handleAutoUpdate(mockUpdateInfo, mockSettings, '/root', mockSpawn);
expect(mockUpdateEventEmitter.emit).toHaveBeenCalledTimes(1);
expect(mockUpdateEventEmitter.emit).toHaveBeenCalledWith(
'update-received',
{
message: 'An update is available!\nCannot determine update command.',
},
);
expect(updateEventEmitter.emit).toHaveBeenCalledTimes(1);
expect(updateEventEmitter.emit).toHaveBeenCalledWith('update-received', {
message: 'An update is available!\nCannot determine update command.',
});
expect(mockSpawn).not.toHaveBeenCalled();
});
@@ -170,13 +161,10 @@ describe('handleAutoUpdate', () => {
handleAutoUpdate(mockUpdateInfo, mockSettings, '/root', mockSpawn);
expect(mockUpdateEventEmitter.emit).toHaveBeenCalledTimes(1);
expect(mockUpdateEventEmitter.emit).toHaveBeenCalledWith(
'update-received',
{
message: 'An update is available!\nThis is an additional message.',
},
);
expect(updateEventEmitter.emit).toHaveBeenCalledTimes(1);
expect(updateEventEmitter.emit).toHaveBeenCalledWith('update-received', {
message: 'An update is available!\nThis is an additional message.',
});
});
it('should attempt to perform an update when conditions are met', async () => {
@@ -216,7 +204,7 @@ describe('handleAutoUpdate', () => {
handleAutoUpdate(mockUpdateInfo, mockSettings, '/root', mockSpawn);
});
expect(mockUpdateEventEmitter.emit).toHaveBeenCalledWith('update-failed', {
expect(updateEventEmitter.emit).toHaveBeenCalledWith('update-failed', {
message:
'Automatic update failed. Please try updating manually. (command: npm i -g @google/gemini-cli@2.0.0, stderr: An error occurred)',
});
@@ -240,7 +228,7 @@ describe('handleAutoUpdate', () => {
handleAutoUpdate(mockUpdateInfo, mockSettings, '/root', mockSpawn);
});
expect(mockUpdateEventEmitter.emit).toHaveBeenCalledWith('update-failed', {
expect(updateEventEmitter.emit).toHaveBeenCalledWith('update-failed', {
message:
'Automatic update failed. Please try updating manually. (error: Spawn error)',
});
@@ -290,9 +278,129 @@ describe('handleAutoUpdate', () => {
handleAutoUpdate(mockUpdateInfo, mockSettings, '/root', mockSpawn);
});
expect(mockUpdateEventEmitter.emit).toHaveBeenCalledWith('update-success', {
expect(updateEventEmitter.emit).toHaveBeenCalledWith('update-success', {
message:
'Update successful! The new version will be used on your next run.',
});
});
});
describe('setUpdateHandler', () => {
let addItem: ReturnType<typeof vi.fn>;
let setUpdateInfo: ReturnType<typeof vi.fn>;
let unregister: () => void;
beforeEach(() => {
addItem = vi.fn();
setUpdateInfo = vi.fn();
vi.useFakeTimers();
unregister = setUpdateHandler(addItem, setUpdateInfo);
});
afterEach(() => {
unregister();
vi.useRealTimers();
vi.clearAllMocks();
});
it('should register event listeners', () => {
// We can't easily check if listeners are registered on the real EventEmitter
// without mocking it more deeply, but we can check if they respond to events.
expect(unregister).toBeInstanceOf(Function);
});
it('should handle update-received event', () => {
const updateInfo: UpdateObject = {
update: {
latest: '2.0.0',
current: '1.0.0',
type: 'major',
name: '@google/gemini-cli',
},
message: 'Update available',
};
// Access the actual emitter to emit events
updateEventEmitter.emit('update-received', updateInfo);
expect(setUpdateInfo).toHaveBeenCalledWith(updateInfo);
// Advance timers to trigger timeout
vi.advanceTimersByTime(60000);
expect(addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: 'Update available',
},
expect.any(Number),
);
expect(setUpdateInfo).toHaveBeenCalledWith(null);
});
it('should handle update-failed event', () => {
updateEventEmitter.emit('update-failed', { message: 'Failed' });
expect(setUpdateInfo).toHaveBeenCalledWith(null);
expect(addItem).toHaveBeenCalledWith(
{
type: MessageType.ERROR,
text: 'Automatic update failed. Please try updating manually',
},
expect.any(Number),
);
});
it('should handle update-success event', () => {
updateEventEmitter.emit('update-success', { message: 'Success' });
expect(setUpdateInfo).toHaveBeenCalledWith(null);
expect(addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: 'Update successful! The new version will be used on your next run.',
},
expect.any(Number),
);
});
it('should not show update-received message if update-success was called', () => {
const updateInfo: UpdateObject = {
update: {
latest: '2.0.0',
current: '1.0.0',
type: 'major',
name: '@google/gemini-cli',
},
message: 'Update available',
};
updateEventEmitter.emit('update-received', updateInfo);
updateEventEmitter.emit('update-success', { message: 'Success' });
// Advance timers
vi.advanceTimersByTime(60000);
// Should only have called addItem for success, not for received (after timeout)
expect(addItem).toHaveBeenCalledTimes(1);
expect(addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: 'Update successful! The new version will be used on your next run.',
},
expect.any(Number),
);
});
it('should handle update-info event', () => {
updateEventEmitter.emit('update-info', { message: 'Info message' });
expect(addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: 'Info message',
},
expect.any(Number),
);
});
});

View File

@@ -0,0 +1,24 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { lerp } from './math.js';
describe('math', () => {
describe('lerp', () => {
it.each([
[0, 10, 0, 0],
[0, 10, 1, 10],
[0, 10, 0.5, 5],
[10, 20, 0.5, 15],
[-10, 10, 0.5, 0],
[0, 10, 2, 20],
[0, 10, -1, -10],
])('lerp(%d, %d, %d) should return %d', (start, end, t, expected) => {
expect(lerp(start, end, t)).toBe(expected);
});
});
});

View File

@@ -0,0 +1,83 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { Storage, debugLogger } from '@google/gemini-cli-core';
import { PersistentState } from './persistentState.js';
vi.mock('node:fs');
vi.mock('@google/gemini-cli-core', () => ({
Storage: {
getGlobalGeminiDir: vi.fn(),
},
debugLogger: {
warn: vi.fn(),
},
}));
describe('PersistentState', () => {
let persistentState: PersistentState;
const mockDir = '/mock/dir';
const mockFilePath = path.join(mockDir, 'state.json');
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(Storage.getGlobalGeminiDir).mockReturnValue(mockDir);
persistentState = new PersistentState();
});
it('should load state from file if it exists', () => {
const mockData = { defaultBannerShownCount: { banner1: 1 } };
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(mockData));
const value = persistentState.get('defaultBannerShownCount');
expect(value).toEqual(mockData.defaultBannerShownCount);
expect(fs.readFileSync).toHaveBeenCalledWith(mockFilePath, 'utf-8');
});
it('should return undefined if key does not exist', () => {
vi.mocked(fs.existsSync).mockReturnValue(false);
const value = persistentState.get('defaultBannerShownCount');
expect(value).toBeUndefined();
});
it('should save state to file', () => {
vi.mocked(fs.existsSync).mockReturnValue(false);
persistentState.set('defaultBannerShownCount', { banner1: 1 });
expect(fs.mkdirSync).toHaveBeenCalledWith(path.normalize(mockDir), {
recursive: true,
});
expect(fs.writeFileSync).toHaveBeenCalledWith(
mockFilePath,
JSON.stringify({ defaultBannerShownCount: { banner1: 1 } }, null, 2),
);
});
it('should handle load errors and start fresh', () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockImplementation(() => {
throw new Error('Read error');
});
const value = persistentState.get('defaultBannerShownCount');
expect(value).toBeUndefined();
expect(debugLogger.warn).toHaveBeenCalled();
});
it('should handle save errors', () => {
vi.mocked(fs.existsSync).mockReturnValue(false);
vi.mocked(fs.writeFileSync).mockImplementation(() => {
throw new Error('Write error');
});
persistentState.set('defaultBannerShownCount', { banner1: 1 });
expect(debugLogger.warn).toHaveBeenCalled();
});
});

View File

@@ -6,6 +6,13 @@
import { vi, describe, expect, it, beforeEach, afterEach } from 'vitest';
import { readStdin } from './readStdin.js';
import { debugLogger } from '@google/gemini-cli-core';
vi.mock('@google/gemini-cli-core', () => ({
debugLogger: {
warn: vi.fn(),
},
}));
// Mock process.stdin
const mockStdin = {
@@ -20,6 +27,7 @@ describe('readStdin', () => {
let originalStdin: typeof process.stdin;
let onReadableHandler: () => void;
let onEndHandler: () => void;
let onErrorHandler: (err: Error) => void;
beforeEach(() => {
vi.clearAllMocks();
@@ -33,10 +41,13 @@ describe('readStdin', () => {
});
// Capture event handlers
mockStdin.on.mockImplementation((event: string, handler: () => void) => {
if (event === 'readable') onReadableHandler = handler;
if (event === 'end') onEndHandler = handler;
});
mockStdin.on.mockImplementation(
(event: string, handler: (...args: unknown[]) => void) => {
if (event === 'readable') onReadableHandler = handler as () => void;
if (event === 'end') onEndHandler = handler as () => void;
if (event === 'error') onErrorHandler = handler as (err: Error) => void;
},
);
});
afterEach(() => {
@@ -109,4 +120,26 @@ describe('readStdin', () => {
await expect(promise).resolves.toBe('chunk1chunk2');
});
it('should truncate input if it exceeds MAX_STDIN_SIZE', async () => {
const MAX_STDIN_SIZE = 8 * 1024 * 1024;
const largeChunk = 'a'.repeat(MAX_STDIN_SIZE + 100);
mockStdin.read.mockReturnValueOnce(largeChunk).mockReturnValueOnce(null);
const promise = readStdin();
onReadableHandler();
await expect(promise).resolves.toBe('a'.repeat(MAX_STDIN_SIZE));
expect(debugLogger.warn).toHaveBeenCalledWith(
`Warning: stdin input truncated to ${MAX_STDIN_SIZE} bytes.`,
);
expect(mockStdin.destroy).toHaveBeenCalled();
});
it('should handle stdin error', async () => {
const promise = readStdin();
const error = new Error('stdin error');
onErrorHandler(error);
await expect(promise).rejects.toThrow('stdin error');
});
});

View File

@@ -36,6 +36,7 @@ export async function readStdin(): Promise<string> {
`Warning: stdin input truncated to ${MAX_STDIN_SIZE} bytes.`,
);
process.stdin.destroy(); // Stop reading further
onEnd();
break;
}
data += chunk;

View File

@@ -0,0 +1,35 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import * as os from 'node:os';
import * as path from 'node:path';
import { resolvePath } from './resolvePath.js';
vi.mock('node:os', () => ({
homedir: vi.fn(),
}));
describe('resolvePath', () => {
beforeEach(() => {
vi.mocked(os.homedir).mockReturnValue('/home/user');
});
it.each([
['', ''],
['/foo/bar', path.normalize('/foo/bar')],
['~/foo', path.join('/home/user', 'foo')],
['~', path.normalize('/home/user')],
['%userprofile%/foo', path.join('/home/user', 'foo')],
['%USERPROFILE%/foo', path.join('/home/user', 'foo')],
])('resolvePath(%s) should return %s', (input, expected) => {
expect(resolvePath(input)).toBe(expected);
});
it('should handle path normalization', () => {
expect(resolvePath('/foo//bar/../baz')).toBe(path.normalize('/foo/baz'));
});
});

View File

@@ -0,0 +1,409 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import { spawn, exec, execSync } from 'node:child_process';
import os from 'node:os';
import fs from 'node:fs';
import { start_sandbox } from './sandbox.js';
import { FatalSandboxError, type SandboxConfig } from '@google/gemini-cli-core';
import { EventEmitter } from 'node:events';
vi.mock('../config/settings.js', () => ({
USER_SETTINGS_DIR: '/home/user/.gemini',
}));
vi.mock('node:child_process');
vi.mock('node:os');
vi.mock('node:fs');
vi.mock('node:util', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:util')>();
return {
...actual,
promisify: (fn: (...args: unknown[]) => unknown) => {
if (fn === exec) {
return async (cmd: string) => {
if (cmd === 'id -u' || cmd === 'id -g') {
return { stdout: '1000', stderr: '' };
}
if (cmd.includes('curl')) {
return { stdout: '', stderr: '' };
}
if (cmd.includes('getconf DARWIN_USER_CACHE_DIR')) {
return { stdout: '/tmp/cache', stderr: '' };
}
if (cmd.includes('ps -a --format')) {
return { stdout: 'existing-container', stderr: '' };
}
return { stdout: '', stderr: '' };
};
}
return actual.promisify(fn);
},
};
});
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
debugLogger: {
log: vi.fn(),
debug: vi.fn(),
warn: vi.fn(),
},
coreEvents: {
emitFeedback: vi.fn(),
},
FatalSandboxError: class extends Error {
constructor(message: string) {
super(message);
this.name = 'FatalSandboxError';
}
},
GEMINI_DIR: '.gemini',
USER_SETTINGS_DIR: '/home/user/.gemini',
};
});
describe('sandbox', () => {
const originalEnv = process.env;
const originalArgv = process.argv;
let mockProcessIn: {
pause: ReturnType<typeof vi.fn>;
resume: ReturnType<typeof vi.fn>;
isTTY: boolean;
};
beforeEach(() => {
vi.clearAllMocks();
process.env = { ...originalEnv };
process.argv = [...originalArgv];
mockProcessIn = {
pause: vi.fn(),
resume: vi.fn(),
isTTY: true,
};
Object.defineProperty(process, 'stdin', {
value: mockProcessIn,
writable: true,
});
vi.mocked(os.platform).mockReturnValue('linux');
vi.mocked(os.homedir).mockReturnValue('/home/user');
vi.mocked(os.tmpdir).mockReturnValue('/tmp');
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.realpathSync).mockImplementation((p) => p as string);
vi.mocked(execSync).mockReturnValue(Buffer.from(''));
});
afterEach(() => {
process.env = originalEnv;
process.argv = originalArgv;
});
describe('start_sandbox', () => {
it('should handle macOS seatbelt (sandbox-exec)', async () => {
vi.mocked(os.platform).mockReturnValue('darwin');
const config: SandboxConfig = {
command: 'sandbox-exec',
image: 'some-image',
};
interface MockProcess extends EventEmitter {
stdout: EventEmitter;
stderr: EventEmitter;
}
const mockSpawnProcess = new EventEmitter() as MockProcess;
mockSpawnProcess.stdout = new EventEmitter();
mockSpawnProcess.stderr = new EventEmitter();
vi.mocked(spawn).mockReturnValue(
mockSpawnProcess as unknown as ReturnType<typeof spawn>,
);
const promise = start_sandbox(config, [], undefined, ['arg1']);
setTimeout(() => {
mockSpawnProcess.emit('close', 0);
}, 10);
await expect(promise).resolves.toBe(0);
expect(spawn).toHaveBeenCalledWith(
'sandbox-exec',
expect.arrayContaining([
'-f',
expect.stringContaining('sandbox-macos-permissive-open.sb'),
]),
expect.objectContaining({ stdio: 'inherit' }),
);
});
it('should throw FatalSandboxError if seatbelt profile is missing', async () => {
vi.mocked(os.platform).mockReturnValue('darwin');
vi.mocked(fs.existsSync).mockReturnValue(false);
const config: SandboxConfig = {
command: 'sandbox-exec',
image: 'some-image',
};
await expect(start_sandbox(config)).rejects.toThrow(FatalSandboxError);
});
it('should handle Docker execution', async () => {
const config: SandboxConfig = {
command: 'docker',
image: 'gemini-cli-sandbox',
};
// Mock image check to return true (image exists)
interface MockProcessWithStdout extends EventEmitter {
stdout: EventEmitter;
}
const mockImageCheckProcess = new EventEmitter() as MockProcessWithStdout;
mockImageCheckProcess.stdout = new EventEmitter();
vi.mocked(spawn).mockImplementationOnce((_cmd, args) => {
if (args && args[0] === 'images') {
setTimeout(() => {
mockImageCheckProcess.stdout.emit('data', Buffer.from('image-id'));
mockImageCheckProcess.emit('close', 0);
}, 1);
return mockImageCheckProcess as unknown as ReturnType<typeof spawn>;
}
return new EventEmitter() as unknown as ReturnType<typeof spawn>; // fallback
});
const mockSpawnProcess = new EventEmitter() as unknown as ReturnType<
typeof spawn
>;
mockSpawnProcess.on = vi.fn().mockImplementation((event, cb) => {
if (event === 'close') {
setTimeout(() => cb(0), 10);
}
return mockSpawnProcess;
});
vi.mocked(spawn).mockImplementationOnce((cmd, args) => {
if (cmd === 'docker' && args && args[0] === 'run') {
return mockSpawnProcess;
}
return new EventEmitter() as unknown as ReturnType<typeof spawn>;
});
const promise = start_sandbox(config, [], undefined, ['arg1']);
await expect(promise).resolves.toBe(0);
expect(spawn).toHaveBeenCalledWith(
'docker',
expect.arrayContaining(['run', '-i', '--rm', '--init']),
expect.objectContaining({ stdio: 'inherit' }),
);
});
it('should pull image if missing', async () => {
const config: SandboxConfig = {
command: 'docker',
image: 'missing-image',
};
// 1. Image check fails
interface MockProcessWithStdout extends EventEmitter {
stdout: EventEmitter;
}
const mockImageCheckProcess1 =
new EventEmitter() as MockProcessWithStdout;
mockImageCheckProcess1.stdout = new EventEmitter();
vi.mocked(spawn).mockImplementationOnce(() => {
setTimeout(() => {
mockImageCheckProcess1.emit('close', 0);
}, 1);
return mockImageCheckProcess1 as unknown as ReturnType<typeof spawn>;
});
// 2. Pull image succeeds
interface MockProcessWithStdoutStderr extends EventEmitter {
stdout: EventEmitter;
stderr: EventEmitter;
}
const mockPullProcess = new EventEmitter() as MockProcessWithStdoutStderr;
mockPullProcess.stdout = new EventEmitter();
mockPullProcess.stderr = new EventEmitter();
vi.mocked(spawn).mockImplementationOnce(() => {
setTimeout(() => {
mockPullProcess.emit('close', 0);
}, 1);
return mockPullProcess as unknown as ReturnType<typeof spawn>;
});
// 3. Image check succeeds
const mockImageCheckProcess2 =
new EventEmitter() as MockProcessWithStdout;
mockImageCheckProcess2.stdout = new EventEmitter();
vi.mocked(spawn).mockImplementationOnce(() => {
setTimeout(() => {
mockImageCheckProcess2.stdout.emit('data', Buffer.from('image-id'));
mockImageCheckProcess2.emit('close', 0);
}, 1);
return mockImageCheckProcess2 as unknown as ReturnType<typeof spawn>;
});
// 4. Docker run
const mockSpawnProcess = new EventEmitter() as unknown as ReturnType<
typeof spawn
>;
mockSpawnProcess.on = vi.fn().mockImplementation((event, cb) => {
if (event === 'close') {
setTimeout(() => cb(0), 10);
}
return mockSpawnProcess;
});
vi.mocked(spawn).mockImplementationOnce(() => mockSpawnProcess);
const promise = start_sandbox(config, [], undefined, ['arg1']);
await expect(promise).resolves.toBe(0);
expect(spawn).toHaveBeenCalledWith(
'docker',
expect.arrayContaining(['pull', 'missing-image']),
expect.any(Object),
);
});
it('should throw if image pull fails', async () => {
const config: SandboxConfig = {
command: 'docker',
image: 'missing-image',
};
// 1. Image check fails
interface MockProcessWithStdout extends EventEmitter {
stdout: EventEmitter;
}
const mockImageCheckProcess1 =
new EventEmitter() as MockProcessWithStdout;
mockImageCheckProcess1.stdout = new EventEmitter();
vi.mocked(spawn).mockImplementationOnce(() => {
setTimeout(() => {
mockImageCheckProcess1.emit('close', 0);
}, 1);
return mockImageCheckProcess1 as unknown as ReturnType<typeof spawn>;
});
// 2. Pull image fails
interface MockProcessWithStdoutStderr extends EventEmitter {
stdout: EventEmitter;
stderr: EventEmitter;
}
const mockPullProcess = new EventEmitter() as MockProcessWithStdoutStderr;
mockPullProcess.stdout = new EventEmitter();
mockPullProcess.stderr = new EventEmitter();
vi.mocked(spawn).mockImplementationOnce(() => {
setTimeout(() => {
mockPullProcess.emit('close', 1);
}, 1);
return mockPullProcess as unknown as ReturnType<typeof spawn>;
});
await expect(start_sandbox(config)).rejects.toThrow(FatalSandboxError);
});
it('should mount volumes correctly', async () => {
const config: SandboxConfig = {
command: 'docker',
image: 'gemini-cli-sandbox',
};
process.env['SANDBOX_MOUNTS'] = '/host/path:/container/path:ro';
vi.mocked(fs.existsSync).mockReturnValue(true); // For mount path check
// Mock image check to return true
interface MockProcessWithStdout extends EventEmitter {
stdout: EventEmitter;
}
const mockImageCheckProcess = new EventEmitter() as MockProcessWithStdout;
mockImageCheckProcess.stdout = new EventEmitter();
vi.mocked(spawn).mockImplementationOnce(() => {
setTimeout(() => {
mockImageCheckProcess.stdout.emit('data', Buffer.from('image-id'));
mockImageCheckProcess.emit('close', 0);
}, 1);
return mockImageCheckProcess as unknown as ReturnType<typeof spawn>;
});
const mockSpawnProcess = new EventEmitter() as unknown as ReturnType<
typeof spawn
>;
mockSpawnProcess.on = vi.fn().mockImplementation((event, cb) => {
if (event === 'close') {
setTimeout(() => cb(0), 10);
}
return mockSpawnProcess;
});
vi.mocked(spawn).mockImplementationOnce(() => mockSpawnProcess);
await start_sandbox(config);
expect(spawn).toHaveBeenCalledWith(
'docker',
expect.arrayContaining([
'--volume',
'/host/path:/container/path:ro',
'--volume',
expect.stringContaining('/home/user/.gemini'),
]),
expect.any(Object),
);
});
it('should handle user creation on Linux if needed', async () => {
const config: SandboxConfig = {
command: 'docker',
image: 'gemini-cli-sandbox',
};
process.env['SANDBOX_SET_UID_GID'] = 'true';
vi.mocked(os.platform).mockReturnValue('linux');
vi.mocked(execSync).mockImplementation((cmd) => {
if (cmd === 'id -u') return Buffer.from('1000');
if (cmd === 'id -g') return Buffer.from('1000');
return Buffer.from('');
});
// Mock image check to return true
interface MockProcessWithStdout extends EventEmitter {
stdout: EventEmitter;
}
const mockImageCheckProcess = new EventEmitter() as MockProcessWithStdout;
mockImageCheckProcess.stdout = new EventEmitter();
vi.mocked(spawn).mockImplementationOnce(() => {
setTimeout(() => {
mockImageCheckProcess.stdout.emit('data', Buffer.from('image-id'));
mockImageCheckProcess.emit('close', 0);
}, 1);
return mockImageCheckProcess as unknown as ReturnType<typeof spawn>;
});
const mockSpawnProcess = new EventEmitter() as unknown as ReturnType<
typeof spawn
>;
mockSpawnProcess.on = vi.fn().mockImplementation((event, cb) => {
if (event === 'close') {
setTimeout(() => cb(0), 10);
}
return mockSpawnProcess;
});
vi.mocked(spawn).mockImplementationOnce(() => mockSpawnProcess);
await start_sandbox(config);
expect(spawn).toHaveBeenCalledWith(
'docker',
expect.arrayContaining(['--user', 'root', '--env', 'HOME=/home/user']),
expect.any(Object),
);
// Check that the entrypoint command includes useradd/groupadd
const args = vi.mocked(spawn).mock.calls[1][1] as string[];
const entrypointCmd = args[args.length - 1];
expect(entrypointCmd).toContain('groupadd');
expect(entrypointCmd).toContain('useradd');
expect(entrypointCmd).toContain('su -p gemini');
});
});
});

View File

@@ -8,7 +8,6 @@ import { exec, execSync, spawn, type ChildProcess } from 'node:child_process';
import os from 'node:os';
import path from 'node:path';
import fs from 'node:fs';
import { readFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { quote, parse } from 'shell-quote';
import { USER_SETTINGS_DIR } from '../config/settings.js';
@@ -22,166 +21,20 @@ import {
} from '@google/gemini-cli-core';
import { ConsolePatcher } from '../ui/utils/ConsolePatcher.js';
import { randomBytes } from 'node:crypto';
import {
getContainerPath,
shouldUseCurrentUserInSandbox,
parseImageName,
ports,
entrypoint,
LOCAL_DEV_SANDBOX_IMAGE_NAME,
SANDBOX_NETWORK_NAME,
SANDBOX_PROXY_NAME,
BUILTIN_SEATBELT_PROFILES,
} from './sandboxUtils.js';
const execAsync = promisify(exec);
function getContainerPath(hostPath: string): string {
if (os.platform() !== 'win32') {
return hostPath;
}
const withForwardSlashes = hostPath.replace(/\\/g, '/');
const match = withForwardSlashes.match(/^([A-Z]):\/(.*)/i);
if (match) {
return `/${match[1].toLowerCase()}/${match[2]}`;
}
return hostPath;
}
const LOCAL_DEV_SANDBOX_IMAGE_NAME = 'gemini-cli-sandbox';
const SANDBOX_NETWORK_NAME = 'gemini-cli-sandbox';
const SANDBOX_PROXY_NAME = 'gemini-cli-sandbox-proxy';
const BUILTIN_SEATBELT_PROFILES = [
'permissive-open',
'permissive-closed',
'permissive-proxied',
'restrictive-open',
'restrictive-closed',
'restrictive-proxied',
];
/**
* Determines whether the sandbox container should be run with the current user's UID and GID.
* This is often necessary on Linux systems (especially Debian/Ubuntu based) when using
* rootful Docker without userns-remap configured, to avoid permission issues with
* mounted volumes.
*
* The behavior is controlled by the `SANDBOX_SET_UID_GID` environment variable:
* - If `SANDBOX_SET_UID_GID` is "1" or "true", this function returns `true`.
* - If `SANDBOX_SET_UID_GID` is "0" or "false", this function returns `false`.
* - If `SANDBOX_SET_UID_GID` is not set:
* - On Debian/Ubuntu Linux, it defaults to `true`.
* - On other OSes, or if OS detection fails, it defaults to `false`.
*
* For more context on running Docker containers as non-root, see:
* https://medium.com/redbubble/running-a-docker-container-as-a-non-root-user-7d2e00f8ee15
*
* @returns {Promise<boolean>} A promise that resolves to true if the current user's UID/GID should be used, false otherwise.
*/
async function shouldUseCurrentUserInSandbox(): Promise<boolean> {
const envVar = process.env['SANDBOX_SET_UID_GID']?.toLowerCase().trim();
if (envVar === '1' || envVar === 'true') {
return true;
}
if (envVar === '0' || envVar === 'false') {
return false;
}
// If environment variable is not explicitly set, check for Debian/Ubuntu Linux
if (os.platform() === 'linux') {
try {
const osReleaseContent = await readFile('/etc/os-release', 'utf8');
if (
osReleaseContent.includes('ID=debian') ||
osReleaseContent.includes('ID=ubuntu') ||
osReleaseContent.match(/^ID_LIKE=.*debian.*/m) || // Covers derivatives
osReleaseContent.match(/^ID_LIKE=.*ubuntu.*/m) // Covers derivatives
) {
debugLogger.log(
'Defaulting to use current user UID/GID for Debian/Ubuntu-based Linux.',
);
return true;
}
} catch (_err) {
// Silently ignore if /etc/os-release is not found or unreadable.
// The default (false) will be applied in this case.
debugLogger.warn(
'Warning: Could not read /etc/os-release to auto-detect Debian/Ubuntu for UID/GID default.',
);
}
}
return false; // Default to false if no other condition is met
}
// docker does not allow container names to contain ':' or '/', so we
// parse those out to shorten the name
function parseImageName(image: string): string {
const [fullName, tag] = image.split(':');
const name = fullName.split('/').at(-1) ?? 'unknown-image';
return tag ? `${name}-${tag}` : name;
}
function ports(): string[] {
return (process.env['SANDBOX_PORTS'] ?? '')
.split(',')
.filter((p) => p.trim())
.map((p) => p.trim());
}
function entrypoint(workdir: string, cliArgs: string[]): string[] {
const isWindows = os.platform() === 'win32';
const containerWorkdir = getContainerPath(workdir);
const shellCmds = [];
const pathSeparator = isWindows ? ';' : ':';
let pathSuffix = '';
if (process.env['PATH']) {
const paths = process.env['PATH'].split(pathSeparator);
for (const p of paths) {
const containerPath = getContainerPath(p);
if (
containerPath.toLowerCase().startsWith(containerWorkdir.toLowerCase())
) {
pathSuffix += `:${containerPath}`;
}
}
}
if (pathSuffix) {
shellCmds.push(`export PATH="$PATH${pathSuffix}";`);
}
let pythonPathSuffix = '';
if (process.env['PYTHONPATH']) {
const paths = process.env['PYTHONPATH'].split(pathSeparator);
for (const p of paths) {
const containerPath = getContainerPath(p);
if (
containerPath.toLowerCase().startsWith(containerWorkdir.toLowerCase())
) {
pythonPathSuffix += `:${containerPath}`;
}
}
}
if (pythonPathSuffix) {
shellCmds.push(`export PYTHONPATH="$PYTHONPATH${pythonPathSuffix}";`);
}
const projectSandboxBashrc = path.join(GEMINI_DIR, 'sandbox.bashrc');
if (fs.existsSync(projectSandboxBashrc)) {
shellCmds.push(`source ${getContainerPath(projectSandboxBashrc)};`);
}
ports().forEach((p) =>
shellCmds.push(
`socat TCP4-LISTEN:${p},bind=$(hostname -i),fork,reuseaddr TCP4:127.0.0.1:${p} 2> /dev/null &`,
),
);
const quotedCliArgs = cliArgs.slice(2).map((arg) => quote([arg]));
const cliCmd =
process.env['NODE_ENV'] === 'development'
? process.env['DEBUG']
? 'npm run debug --'
: 'npm rebuild && npm run start --'
: process.env['DEBUG']
? `node --inspect-brk=0.0.0.0:${process.env['DEBUG_PORT'] || '9229'} $(which gemini)`
: 'gemini';
const args = [...shellCmds, cliCmd, ...quotedCliArgs];
return ['bash', '-c', args.join(' ')];
}
export async function start_sandbox(
config: SandboxConfig,
nodeArgs: string[] = [],
@@ -231,7 +84,7 @@ export async function start_sandbox(
'-D',
`HOME_DIR=${fs.realpathSync(os.homedir())}`,
'-D',
`CACHE_DIR=${fs.realpathSync(execSync(`getconf DARWIN_USER_CACHE_DIR`).toString().trim())}`,
`CACHE_DIR=${fs.realpathSync((await execAsync('getconf DARWIN_USER_CACHE_DIR')).stdout.trim())}`,
];
// Add included directories from the workspace context
@@ -350,7 +203,7 @@ export async function start_sandbox(
debugLogger.log(`hopping into sandbox (command: ${config.command}) ...`);
// determine full path for gemini-cli to distinguish linked vs installed setting
const gcPath = fs.realpathSync(process.argv[1]);
const gcPath = process.argv[1] ? fs.realpathSync(process.argv[1]) : '';
const projectSandboxDockerfile = path.join(
GEMINI_DIR,
@@ -565,11 +418,9 @@ export async function start_sandbox(
debugLogger.log(`ContainerName: ${containerName}`);
} else {
let index = 0;
const containerNameCheck = execSync(
`${config.command} ps -a --format "{{.Names}}"`,
)
.toString()
.trim();
const containerNameCheck = (
await execAsync(`${config.command} ps -a --format "{{.Names}}"`)
).stdout.trim();
while (containerNameCheck.includes(`${imageName}-${index}`)) {
index++;
}
@@ -723,8 +574,8 @@ export async function start_sandbox(
// The entrypoint script then handles dropping privileges to the correct user.
args.push('--user', 'root');
const uid = execSync('id -u').toString().trim();
const gid = execSync('id -g').toString().trim();
const uid = (await execAsync('id -u')).stdout.trim();
const gid = (await execAsync('id -g')).stdout.trim();
// Instead of passing --user to the main sandbox container, we let it
// start as root, then create a user with the host's UID/GID, and

View File

@@ -0,0 +1,149 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import os from 'node:os';
import fs from 'node:fs';
import { readFile } from 'node:fs/promises';
import {
getContainerPath,
parseImageName,
ports,
entrypoint,
shouldUseCurrentUserInSandbox,
} from './sandboxUtils.js';
vi.mock('node:os');
vi.mock('node:fs');
vi.mock('node:fs/promises');
vi.mock('@google/gemini-cli-core', () => ({
debugLogger: {
log: vi.fn(),
warn: vi.fn(),
},
GEMINI_DIR: '.gemini',
}));
describe('sandboxUtils', () => {
const originalEnv = process.env;
beforeEach(() => {
vi.clearAllMocks();
process.env = { ...originalEnv };
});
afterEach(() => {
process.env = originalEnv;
});
describe('getContainerPath', () => {
it('should return same path on non-Windows', () => {
vi.mocked(os.platform).mockReturnValue('linux');
expect(getContainerPath('/home/user')).toBe('/home/user');
});
it('should convert Windows path to container path', () => {
vi.mocked(os.platform).mockReturnValue('win32');
expect(getContainerPath('C:\\Users\\user')).toBe('/c/Users/user');
});
it('should handle Windows path without drive letter', () => {
vi.mocked(os.platform).mockReturnValue('win32');
expect(getContainerPath('\\Users\\user')).toBe('/Users/user');
});
});
describe('parseImageName', () => {
it('should parse image name with tag', () => {
expect(parseImageName('my-image:latest')).toBe('my-image-latest');
});
it('should parse image name without tag', () => {
expect(parseImageName('my-image')).toBe('my-image');
});
it('should handle registry path', () => {
expect(parseImageName('gcr.io/my-project/my-image:v1')).toBe(
'my-image-v1',
);
});
});
describe('ports', () => {
it('should return empty array if SANDBOX_PORTS is not set', () => {
delete process.env['SANDBOX_PORTS'];
expect(ports()).toEqual([]);
});
it('should parse comma-separated ports', () => {
process.env['SANDBOX_PORTS'] = '8080, 3000 , 9000';
expect(ports()).toEqual(['8080', '3000', '9000']);
});
});
describe('entrypoint', () => {
beforeEach(() => {
vi.mocked(os.platform).mockReturnValue('linux');
vi.mocked(fs.existsSync).mockReturnValue(false);
});
it('should generate default entrypoint', () => {
const args = entrypoint('/work', ['node', 'gemini', 'arg1']);
expect(args).toEqual(['bash', '-c', 'gemini arg1']);
});
it('should include PATH and PYTHONPATH if set', () => {
process.env['PATH'] = '/work/bin:/usr/bin';
process.env['PYTHONPATH'] = '/work/lib';
const args = entrypoint('/work', ['node', 'gemini', 'arg1']);
expect(args[2]).toContain('export PATH="$PATH:/work/bin"');
expect(args[2]).toContain('export PYTHONPATH="$PYTHONPATH:/work/lib"');
});
it('should source sandbox.bashrc if exists', () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
const args = entrypoint('/work', ['node', 'gemini', 'arg1']);
expect(args[2]).toContain('source .gemini/sandbox.bashrc');
});
it('should include socat commands for ports', () => {
process.env['SANDBOX_PORTS'] = '8080';
const args = entrypoint('/work', ['node', 'gemini', 'arg1']);
expect(args[2]).toContain('socat TCP4-LISTEN:8080');
});
it('should use development command if NODE_ENV is development', () => {
process.env['NODE_ENV'] = 'development';
const args = entrypoint('/work', ['node', 'gemini', 'arg1']);
expect(args[2]).toContain('npm rebuild && npm run start --');
});
});
describe('shouldUseCurrentUserInSandbox', () => {
it('should return true if SANDBOX_SET_UID_GID is 1', async () => {
process.env['SANDBOX_SET_UID_GID'] = '1';
expect(await shouldUseCurrentUserInSandbox()).toBe(true);
});
it('should return false if SANDBOX_SET_UID_GID is 0', async () => {
process.env['SANDBOX_SET_UID_GID'] = '0';
expect(await shouldUseCurrentUserInSandbox()).toBe(false);
});
it('should return true on Debian Linux', async () => {
delete process.env['SANDBOX_SET_UID_GID'];
vi.mocked(os.platform).mockReturnValue('linux');
vi.mocked(readFile).mockResolvedValue('ID=debian\n');
expect(await shouldUseCurrentUserInSandbox()).toBe(true);
});
it('should return false on non-Linux', async () => {
delete process.env['SANDBOX_SET_UID_GID'];
vi.mocked(os.platform).mockReturnValue('darwin');
expect(await shouldUseCurrentUserInSandbox()).toBe(false);
});
});
});

View File

@@ -0,0 +1,148 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import os from 'node:os';
import fs from 'node:fs';
import { readFile } from 'node:fs/promises';
import { quote } from 'shell-quote';
import { debugLogger, GEMINI_DIR } from '@google/gemini-cli-core';
export const LOCAL_DEV_SANDBOX_IMAGE_NAME = 'gemini-cli-sandbox';
export const SANDBOX_NETWORK_NAME = 'gemini-cli-sandbox';
export const SANDBOX_PROXY_NAME = 'gemini-cli-sandbox-proxy';
export const BUILTIN_SEATBELT_PROFILES = [
'permissive-open',
'permissive-closed',
'permissive-proxied',
'restrictive-open',
'restrictive-closed',
'restrictive-proxied',
];
export function getContainerPath(hostPath: string): string {
if (os.platform() !== 'win32') {
return hostPath;
}
const withForwardSlashes = hostPath.replace(/\\/g, '/');
const match = withForwardSlashes.match(/^([A-Z]):\/(.*)/i);
if (match) {
return `/${match[1].toLowerCase()}/${match[2]}`;
}
return withForwardSlashes;
}
export async function shouldUseCurrentUserInSandbox(): Promise<boolean> {
const envVar = process.env['SANDBOX_SET_UID_GID']?.toLowerCase().trim();
if (envVar === '1' || envVar === 'true') {
return true;
}
if (envVar === '0' || envVar === 'false') {
return false;
}
// If environment variable is not explicitly set, check for Debian/Ubuntu Linux
if (os.platform() === 'linux') {
try {
const osReleaseContent = await readFile('/etc/os-release', 'utf8');
if (
osReleaseContent.includes('ID=debian') ||
osReleaseContent.includes('ID=ubuntu') ||
osReleaseContent.match(/^ID_LIKE=.*debian.*/m) || // Covers derivatives
osReleaseContent.match(/^ID_LIKE=.*ubuntu.*/m) // Covers derivatives
) {
debugLogger.log(
'Defaulting to use current user UID/GID for Debian/Ubuntu-based Linux.',
);
return true;
}
} catch (_err) {
// Silently ignore if /etc/os-release is not found or unreadable.
// The default (false) will be applied in this case.
debugLogger.warn(
'Warning: Could not read /etc/os-release to auto-detect Debian/Ubuntu for UID/GID default.',
);
}
}
return false; // Default to false if no other condition is met
}
export function parseImageName(image: string): string {
const [fullName, tag] = image.split(':');
const name = fullName.split('/').at(-1) ?? 'unknown-image';
return tag ? `${name}-${tag}` : name;
}
export function ports(): string[] {
return (process.env['SANDBOX_PORTS'] ?? '')
.split(',')
.filter((p) => p.trim())
.map((p) => p.trim());
}
export function entrypoint(workdir: string, cliArgs: string[]): string[] {
const isWindows = os.platform() === 'win32';
const containerWorkdir = getContainerPath(workdir);
const shellCmds = [];
const pathSeparator = isWindows ? ';' : ':';
let pathSuffix = '';
if (process.env['PATH']) {
const paths = process.env['PATH'].split(pathSeparator);
for (const p of paths) {
const containerPath = getContainerPath(p);
if (
containerPath.toLowerCase().startsWith(containerWorkdir.toLowerCase())
) {
pathSuffix += `:${containerPath}`;
}
}
}
if (pathSuffix) {
shellCmds.push(`export PATH="$PATH${pathSuffix}";`);
}
let pythonPathSuffix = '';
if (process.env['PYTHONPATH']) {
const paths = process.env['PYTHONPATH'].split(pathSeparator);
for (const p of paths) {
const containerPath = getContainerPath(p);
if (
containerPath.toLowerCase().startsWith(containerWorkdir.toLowerCase())
) {
pythonPathSuffix += `:${containerPath}`;
}
}
}
if (pythonPathSuffix) {
shellCmds.push(`export PYTHONPATH="$PYTHONPATH${pythonPathSuffix}";`);
}
const projectSandboxBashrc = `${GEMINI_DIR}/sandbox.bashrc`;
if (fs.existsSync(projectSandboxBashrc)) {
shellCmds.push(`source ${getContainerPath(projectSandboxBashrc)};`);
}
ports().forEach((p) =>
shellCmds.push(
`socat TCP4-LISTEN:${p},bind=$(hostname -i),fork,reuseaddr TCP4:127.0.0.1:${p} 2> /dev/null &`,
),
);
const quotedCliArgs = cliArgs.slice(2).map((arg) => quote([arg]));
const cliCmd =
process.env['NODE_ENV'] === 'development'
? process.env['DEBUG']
? 'npm run debug --'
: 'npm rebuild && npm run start --'
: process.env['DEBUG']
? `node --inspect-brk=0.0.0.0:${process.env['DEBUG_PORT'] || '9229'} $(which gemini)`
: 'gemini';
const args = [...shellCmds, cliCmd, ...quotedCliArgs];
return ['bash', '-c', args.join(' ')];
}

View File

@@ -0,0 +1,22 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi } from 'vitest';
import { updateEventEmitter } from './updateEventEmitter.js';
describe('updateEventEmitter', () => {
it('should allow registering and emitting events', () => {
const callback = vi.fn();
const eventName = 'test-event';
updateEventEmitter.on(eventName, callback);
updateEventEmitter.emit(eventName, 'test-data');
expect(callback).toHaveBeenCalledWith('test-data');
updateEventEmitter.off(eventName, callback);
});
});

View File

@@ -0,0 +1,46 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { getCliVersion } from './version.js';
import * as core from '@google/gemini-cli-core';
vi.mock('@google/gemini-cli-core', () => ({
getPackageJson: vi.fn(),
}));
describe('version', () => {
const originalEnv = process.env;
beforeEach(() => {
vi.resetModules();
process.env = { ...originalEnv };
vi.mocked(core.getPackageJson).mockResolvedValue({ version: '1.0.0' });
});
afterEach(() => {
process.env = originalEnv;
});
it('should return CLI_VERSION from env if set', async () => {
process.env['CLI_VERSION'] = '2.0.0';
const version = await getCliVersion();
expect(version).toBe('2.0.0');
});
it('should return version from package.json if CLI_VERSION is not set', async () => {
delete process.env['CLI_VERSION'];
const version = await getCliVersion();
expect(version).toBe('1.0.0');
});
it('should return "unknown" if package.json is not found and CLI_VERSION is not set', async () => {
delete process.env['CLI_VERSION'];
vi.mocked(core.getPackageJson).mockResolvedValue(undefined);
const version = await getCliVersion();
expect(version).toBe('unknown');
});
});