Standardize exit codes (#7055)

This commit is contained in:
Tommaso Sciortino
2025-08-25 21:44:45 -07:00
committed by GitHub
parent 415a36a195
commit 7e31577813
10 changed files with 116 additions and 97 deletions

View File

@@ -74,6 +74,18 @@ This guide provides solutions to common issues and debugging tips, including top
- **Cause:** The `DEBUG` and `DEBUG_MODE` variables are automatically excluded from project `.env` files to prevent interference with gemini-cli behavior.
- **Solution:** Use a `.gemini/.env` file instead, or configure the `excludedProjectEnvVars` setting in your `settings.json` to exclude fewer variables.
## Exit Codes
The Gemini CLI uses specific exit codes to indicate the reason for termination. This is especially useful for scripting and automation.
| Exit Code | Error Type | Description |
| --------- | -------------------------- | --------------------------------------------------------------------------------------------------- |
| 41 | `FatalAuthenticationError` | An error occurred during the authentication process. |
| 42 | `FatalInputError` | Invalid or missing input was provided to the CLI. (non-interactive mode only) |
| 44 | `FatalSandboxError` | An error occurred with the sandboxing environment (e.g., Docker, Podman, or Seatbelt). |
| 52 | `FatalConfigError` | A configuration file (`settings.json`) is invalid or contains errors. |
| 53 | `FatalTurnLimitedError` | The maximum number of conversational turns for the session was reached. (non-interactive mode only) |
## Debugging Tips
- **CLI debugging:**

View File

@@ -8,9 +8,18 @@
import './src/gemini.js';
import { main } from './src/gemini.js';
import { FatalError } from '@google/gemini-cli-core';
// --- Global Entry Point ---
main().catch((error) => {
if (error instanceof FatalError) {
let errorMessage = error.message;
if (!process.env['NO_COLOR']) {
errorMessage = `\x1b[31m${errorMessage}\x1b[0m`;
}
console.error(errorMessage);
process.exit(error.exitCode);
}
console.error('An unexpected critical error occurred:');
if (error instanceof Error) {
console.error(error.stack);

View File

@@ -5,6 +5,7 @@
*/
import type { SandboxConfig } from '@google/gemini-cli-core';
import { FatalSandboxError } from '@google/gemini-cli-core';
import commandExists from 'command-exists';
import * as os from 'node:os';
import { getPackageJson } from '../utils/package.js';
@@ -51,21 +52,19 @@ function getSandboxCommand(
if (typeof sandbox === 'string' && sandbox) {
if (!isSandboxCommand(sandbox)) {
console.error(
`ERROR: invalid sandbox command '${sandbox}'. Must be one of ${VALID_SANDBOX_COMMANDS.join(
throw new FatalSandboxError(
`Invalid sandbox command '${sandbox}'. Must be one of ${VALID_SANDBOX_COMMANDS.join(
', ',
)}`,
);
process.exit(1);
}
// confirm that specified command exists
if (commandExists.sync(sandbox)) {
return sandbox;
}
console.error(
`ERROR: missing sandbox command '${sandbox}' (from GEMINI_SANDBOX)`,
throw new FatalSandboxError(
`Missing sandbox command '${sandbox}' (from GEMINI_SANDBOX)`,
);
process.exit(1);
}
// look for seatbelt, docker, or podman, in that order
@@ -80,11 +79,10 @@ function getSandboxCommand(
// throw an error if user requested sandbox but no command was found
if (sandbox === true) {
console.error(
'ERROR: GEMINI_SANDBOX is true but failed to determine command for sandbox; ' +
throw new FatalSandboxError(
'GEMINI_SANDBOX is true but failed to determine command for sandbox; ' +
'install docker or podman or specify command in GEMINI_SANDBOX',
);
process.exit(1);
}
return '';

View File

@@ -4,7 +4,6 @@
* SPDX-License-Identifier: Apache-2.0
*/
import stripAnsi from 'strip-ansi';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
main,
@@ -16,6 +15,7 @@ import type { SettingsFile } from './config/settings.js';
import { LoadedSettings, loadSettings } from './config/settings.js';
import { appEvents, AppEvent } from './utils/events.js';
import type { Config } from '@google/gemini-cli-core';
import { FatalConfigError } from '@google/gemini-cli-core';
// Custom error to identify mock process.exit calls
class MockProcessExitError extends Error {
@@ -75,7 +75,6 @@ vi.mock('./utils/sandbox.js', () => ({
}));
describe('gemini.tsx main function', () => {
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
let loadSettingsMock: ReturnType<typeof vi.mocked<typeof loadSettings>>;
let originalEnvGeminiSandbox: string | undefined;
let originalEnvSandbox: string | undefined;
@@ -97,7 +96,6 @@ describe('gemini.tsx main function', () => {
delete process.env['GEMINI_SANDBOX'];
delete process.env['SANDBOX'];
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
initialUnhandledRejectionListeners =
process.listeners('unhandledRejection');
});
@@ -126,7 +124,7 @@ describe('gemini.tsx main function', () => {
vi.restoreAllMocks();
});
it('should call process.exit(1) if settings have errors', async () => {
it('should throw InvalidConfigurationError if settings have errors', async () => {
const settingsError = {
message: 'Test settings error',
path: '/test/settings.json',
@@ -158,28 +156,7 @@ describe('gemini.tsx main function', () => {
loadSettingsMock.mockReturnValue(mockLoadedSettings);
try {
await main();
// If main completes without throwing, the test should fail because process.exit was expected
expect.fail('main function did not exit as expected');
} catch (error) {
expect(error).toBeInstanceOf(MockProcessExitError);
if (error instanceof MockProcessExitError) {
expect(error.code).toBe(1);
}
}
// Verify console.error was called with the error message
expect(consoleErrorSpy).toHaveBeenCalledTimes(2);
expect(stripAnsi(String(consoleErrorSpy.mock.calls[0][0]))).toBe(
'Error in /test/settings.json: Test settings error',
);
expect(stripAnsi(String(consoleErrorSpy.mock.calls[1][0]))).toBe(
'Please fix /test/settings.json and try again.',
);
// Verify process.exit was called.
expect(processExitSpy).toHaveBeenCalledWith(1);
await expect(main()).rejects.toThrow(FatalConfigError);
});
it('should log unhandled promise rejections and open debug console on first error', async () => {

View File

@@ -34,6 +34,7 @@ import {
logIdeConnection,
IdeConnectionEvent,
IdeConnectionType,
FatalConfigError,
} from '@google/gemini-cli-core';
import { validateAuthMethod } from './config/auth.js';
import { setMaxSizedBoxDebugging } from './ui/components/shared/MaxSizedBox.js';
@@ -173,15 +174,12 @@ export async function main() {
await cleanupCheckpoints();
if (settings.errors.length > 0) {
for (const error of settings.errors) {
let errorMessage = `Error in ${error.path}: ${error.message}`;
if (!process.env['NO_COLOR']) {
errorMessage = `\x1b[31m${errorMessage}\x1b[0m`;
}
console.error(errorMessage);
console.error(`Please fix ${error.path} and try again.`);
}
process.exit(1);
const errorMessages = settings.errors.map(
(error) => `Error in ${error.path}: ${error.message}`,
);
throw new FatalConfigError(
`${errorMessages.join('\n')}\nPlease fix the configuration file(s) and try again.`,
);
}
const argv = await parseArguments(settings.merged);

View File

@@ -38,7 +38,6 @@ describe('runNonInteractive', () => {
let mockCoreExecuteToolCall: vi.Mock;
let mockShutdownTelemetry: vi.Mock;
let consoleErrorSpy: vi.SpyInstance;
let processExitSpy: vi.SpyInstance;
let processStdoutSpy: vi.SpyInstance;
let mockGeminiClient: {
sendMessageStream: vi.Mock;
@@ -49,9 +48,6 @@ describe('runNonInteractive', () => {
mockShutdownTelemetry = vi.mocked(shutdownTelemetry);
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
processExitSpy = vi
.spyOn(process, 'exit')
.mockImplementation((() => {}) as (code?: number) => never);
processStdoutSpy = vi
.spyOn(process.stdout, 'write')
.mockImplementation(() => true);
@@ -202,7 +198,6 @@ describe('runNonInteractive', () => {
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Error executing tool errorTool: Execution failed',
);
expect(processExitSpy).not.toHaveBeenCalled();
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(2);
expect(mockGeminiClient.sendMessageStream).toHaveBeenNthCalledWith(
2,
@@ -228,12 +223,9 @@ describe('runNonInteractive', () => {
throw apiError;
});
await runNonInteractive(mockConfig, 'Initial fail', 'prompt-id-4');
expect(consoleErrorSpy).toHaveBeenCalledWith(
'[API Error: API connection failed]',
);
expect(processExitSpy).toHaveBeenCalledWith(1);
await expect(
runNonInteractive(mockConfig, 'Initial fail', 'prompt-id-4'),
).rejects.toThrow(apiError);
});
it('should not exit if a tool is not found, and should send error back to model', async () => {
@@ -272,7 +264,6 @@ describe('runNonInteractive', () => {
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Error executing tool nonexistentTool: Tool "nonexistentTool" not found in registry.',
);
expect(processExitSpy).not.toHaveBeenCalled();
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(2);
expect(processStdoutSpy).toHaveBeenCalledWith(
"Sorry, I can't find that tool.",
@@ -281,9 +272,10 @@ describe('runNonInteractive', () => {
it('should exit when max session turns are exceeded', async () => {
vi.mocked(mockConfig.getMaxSessionTurns).mockReturnValue(0);
await runNonInteractive(mockConfig, 'Trigger loop', 'prompt-id-6');
expect(consoleErrorSpy).toHaveBeenCalledWith(
'\n Reached max session turns for this session. Increase the number of turns by specifying maxSessionTurns in settings.json.',
await expect(
runNonInteractive(mockConfig, 'Trigger loop', 'prompt-id-6'),
).rejects.toThrow(
'Reached max session turns for this session. Increase the number of turns by specifying maxSessionTurns in settings.json.',
);
});

View File

@@ -11,6 +11,8 @@ import {
isTelemetrySdkInitialized,
GeminiEventType,
parseAndFormatApiError,
FatalInputError,
FatalTurnLimitedError,
} from '@google/gemini-cli-core';
import type { Content, Part } from '@google/genai';
@@ -53,8 +55,9 @@ export async function runNonInteractive(
if (!shouldProceed || !processedQuery) {
// An error occurred during @include processing (e.g., file not found).
// The error message is already logged by handleAtCommand.
console.error('Exiting due to an error processing the @ command.');
process.exit(1);
throw new FatalInputError(
'Exiting due to an error processing the @ command.',
);
}
let currentMessages: Content[] = [
@@ -68,10 +71,9 @@ export async function runNonInteractive(
config.getMaxSessionTurns() >= 0 &&
turnCount > config.getMaxSessionTurns()
) {
console.error(
'\n Reached max session turns for this session. Increase the number of turns by specifying maxSessionTurns in settings.json.',
throw new FatalTurnLimitedError(
'Reached max session turns for this session. Increase the number of turns by specifying maxSessionTurns in settings.json.',
);
return;
}
const toolCallRequests: ToolCallRequestInfo[] = [];
@@ -126,7 +128,7 @@ export async function runNonInteractive(
config.getContentGeneratorConfig()?.authType,
),
);
process.exit(1);
throw error;
} finally {
consolePatcher.cleanup();
if (isTelemetrySdkInitialized()) {

View File

@@ -17,6 +17,7 @@ import {
} from '../config/settings.js';
import { promisify } from 'node:util';
import type { Config, SandboxConfig } from '@google/gemini-cli-core';
import { FatalSandboxError } from '@google/gemini-cli-core';
import { ConsolePatcher } from '../ui/utils/ConsolePatcher.js';
const execAsync = promisify(exec);
@@ -198,8 +199,9 @@ export async function start_sandbox(
if (config.command === 'sandbox-exec') {
// disallow BUILD_SANDBOX
if (process.env['BUILD_SANDBOX']) {
console.error('ERROR: cannot BUILD_SANDBOX when using macOS Seatbelt');
process.exit(1);
throw new FatalSandboxError(
'Cannot BUILD_SANDBOX when using macOS Seatbelt',
);
}
const profile = (process.env['SEATBELT_PROFILE'] ??= 'permissive-open');
@@ -214,10 +216,9 @@ export async function start_sandbox(
);
}
if (!fs.existsSync(profileFile)) {
console.error(
`ERROR: missing macos seatbelt profile file '${profileFile}'`,
throw new FatalSandboxError(
`Missing macos seatbelt profile file '${profileFile}'`,
);
process.exit(1);
}
// Log on STDERR so it doesn't clutter the output on STDOUT
console.error(`using macos seatbelt (profile: ${profile}) ...`);
@@ -325,13 +326,12 @@ export async function start_sandbox(
console.error(data.toString());
});
proxyProcess.on('close', (code, signal) => {
console.error(
`ERROR: proxy command '${proxyCommand}' exited with code ${code}, signal ${signal}`,
);
if (sandboxProcess?.pid) {
process.kill(-sandboxProcess.pid, 'SIGTERM');
}
process.exit(1);
throw new FatalSandboxError(
`Proxy command '${proxyCommand}' exited with code ${code}, signal ${signal}`,
);
});
console.log('waiting for proxy to start ...');
await execAsync(
@@ -366,11 +366,10 @@ export async function start_sandbox(
// note this can only be done with binary linked from gemini-cli repo
if (process.env['BUILD_SANDBOX']) {
if (!gcPath.includes('gemini-cli/packages/')) {
console.error(
'ERROR: cannot build sandbox using installed gemini binary; ' +
throw new FatalSandboxError(
'Cannot build sandbox using installed gemini binary; ' +
'run `npm link ./packages/cli` under gemini-cli repo to switch to linked binary.',
);
process.exit(1);
} else {
console.error('building sandbox ...');
const gcRoot = gcPath.split('/packages/')[0];
@@ -403,10 +402,9 @@ export async function start_sandbox(
image === LOCAL_DEV_SANDBOX_IMAGE_NAME
? 'Try running `npm run build:all` or `npm run build:sandbox` under the gemini-cli repo to build it locally, or check the image name and your network connection.'
: 'Please check the image name, your network connection, or notify gemini-cli-dev@google.com if the issue persists.';
console.error(
`ERROR: Sandbox image '${image}' is missing or could not be pulled. ${remedy}`,
throw new FatalSandboxError(
`Sandbox image '${image}' is missing or could not be pulled. ${remedy}`,
);
process.exit(1);
}
// use interactive mode and auto-remove container on exit
@@ -484,17 +482,15 @@ export async function start_sandbox(
mount = `${from}:${to}:${opts}`;
// check that from path is absolute
if (!path.isAbsolute(from)) {
console.error(
`ERROR: path '${from}' listed in SANDBOX_MOUNTS must be absolute`,
throw new FatalSandboxError(
`Path '${from}' listed in SANDBOX_MOUNTS must be absolute`,
);
process.exit(1);
}
// check that from path exists on host
if (!fs.existsSync(from)) {
console.error(
`ERROR: missing mount path '${from}' listed in SANDBOX_MOUNTS`,
throw new FatalSandboxError(
`Missing mount path '${from}' listed in SANDBOX_MOUNTS`,
);
process.exit(1);
}
console.error(`SANDBOX_MOUNTS: ${from} -> ${to} (${opts})`);
args.push('--volume', mount);
@@ -665,10 +661,9 @@ export async function start_sandbox(
console.error(`SANDBOX_ENV: ${env}`);
args.push('--env', env);
} else {
console.error(
'ERROR: SANDBOX_ENV must be a comma-separated list of key=value pairs',
throw new FatalSandboxError(
'SANDBOX_ENV must be a comma-separated list of key=value pairs',
);
process.exit(1);
}
}
}
@@ -776,13 +771,12 @@ export async function start_sandbox(
console.error(data.toString().trim());
});
proxyProcess.on('close', (code, signal) => {
console.error(
`ERROR: proxy container command '${proxyContainerCommand}' exited with code ${code}, signal ${signal}`,
);
if (sandboxProcess?.pid) {
process.kill(-sandboxProcess.pid, 'SIGTERM');
}
process.exit(1);
throw new FatalSandboxError(
`Proxy container command '${proxyContainerCommand}' exited with code ${code}, signal ${signal}`,
);
});
console.log('waiting for proxy to start ...');
await execAsync(

View File

@@ -18,7 +18,7 @@ import open from 'open';
import path from 'node:path';
import { promises as fs } from 'node:fs';
import type { Config } from '../config/config.js';
import { getErrorMessage } from '../utils/errors.js';
import { getErrorMessage, FatalAuthenticationError } from '../utils/errors.js';
import { UserAccountManager } from '../utils/userAccountManager.js';
import { AuthType } from '../core/contentGenerator.js';
import readline from 'node:readline';
@@ -142,7 +142,9 @@ async function initOauthClient(
}
}
if (!success) {
process.exit(1);
throw new FatalAuthenticationError(
'Failed to authenticate with user code.',
);
}
} else {
const webLogin = await authWithWeb(client);
@@ -166,7 +168,7 @@ async function initOauthClient(
console.error(
'Failed to open browser automatically. Please try running again with NO_BROWSER=true set.',
);
process.exit(1);
throw new FatalAuthenticationError('Failed to open browser.');
});
} catch (err) {
console.error(
@@ -174,7 +176,7 @@ async function initOauthClient(
err,
'\nPlease try running again with NO_BROWSER=true set.',
);
process.exit(1);
throw new FatalAuthenticationError('Failed to open browser.');
}
console.log('Waiting for authentication...');

View File

@@ -25,6 +25,41 @@ export function getErrorMessage(error: unknown): string {
}
}
export class FatalError extends Error {
constructor(
message: string,
readonly exitCode: number,
) {
super(message);
}
}
export class FatalAuthenticationError extends FatalError {
constructor(message: string) {
super(message, 41);
}
}
export class FatalInputError extends FatalError {
constructor(message: string) {
super(message, 42);
}
}
export class FatalSandboxError extends FatalError {
constructor(message: string) {
super(message, 44);
}
}
export class FatalConfigError extends FatalError {
constructor(message: string) {
super(message, 52);
}
}
export class FatalTurnLimitedError extends FatalError {
constructor(message: string) {
super(message, 53);
}
}
export class ForbiddenError extends Error {}
export class UnauthorizedError extends Error {}
export class BadRequestError extends Error {}