diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index c809cf1ff1..5627c31455 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -140,6 +140,14 @@ vi.mock('@google/gemini-cli-core', async () => { defaultDecision: ServerConfig.PolicyDecision.ASK_USER, approvalMode: ServerConfig.ApprovalMode.DEFAULT, })), + isHeadlessMode: vi.fn( + (opts) => + !!opts?.prompt || + process.env['CI'] === 'true' || + process.env['GITHUB_ACTIONS'] === 'true' || + (!!process.stdin && !process.stdin.isTTY) || + (!!process.stdout && !process.stdout.isTTY), + ), }; }); @@ -153,6 +161,8 @@ vi.mock('./extension-manager.js', () => { // Global setup to ensure clean environment for all tests in this file const originalArgv = process.argv; const originalGeminiModel = process.env['GEMINI_MODEL']; +const originalStdoutIsTTY = process.stdout.isTTY; +const originalStdinIsTTY = process.stdin.isTTY; beforeEach(() => { delete process.env['GEMINI_MODEL']; @@ -161,6 +171,18 @@ beforeEach(() => { ExtensionManager.prototype.loadExtensions = vi .fn() .mockResolvedValue(undefined); + + // Default to interactive mode for tests unless otherwise specified + Object.defineProperty(process.stdout, 'isTTY', { + value: true, + configurable: true, + writable: true, + }); + Object.defineProperty(process.stdin, 'isTTY', { + value: true, + configurable: true, + writable: true, + }); }); afterEach(() => { @@ -170,6 +192,16 @@ afterEach(() => { } else { delete process.env['GEMINI_MODEL']; } + Object.defineProperty(process.stdout, 'isTTY', { + value: originalStdoutIsTTY, + configurable: true, + writable: true, + }); + Object.defineProperty(process.stdin, 'isTTY', { + value: originalStdinIsTTY, + configurable: true, + writable: true, + }); }); describe('parseArguments', () => { @@ -248,6 +280,16 @@ describe('parseArguments', () => { }); describe('positional arguments and @commands', () => { + beforeEach(() => { + // Default to headless mode for these tests as they mostly expect one-shot behavior + process.stdin.isTTY = false; + Object.defineProperty(process.stdout, 'isTTY', { + value: false, + configurable: true, + writable: true, + }); + }); + it.each([ { description: @@ -378,8 +420,12 @@ describe('parseArguments', () => { ); it('should include a startup message when converting positional query to interactive prompt', async () => { - const originalIsTTY = process.stdin.isTTY; process.stdin.isTTY = true; + Object.defineProperty(process.stdout, 'isTTY', { + value: true, + configurable: true, + writable: true, + }); process.argv = ['node', 'script.js', 'hello']; try { @@ -388,7 +434,7 @@ describe('parseArguments', () => { 'Positional arguments now default to interactive mode. To run in non-interactive mode, use the --prompt (-p) flag.', ); } finally { - process.stdin.isTTY = originalIsTTY; + // beforeEach handles resetting } }); }); @@ -2573,6 +2619,16 @@ describe('Output format', () => { describe('parseArguments with positional prompt', () => { const originalArgv = process.argv; + beforeEach(() => { + // Default to headless mode for these tests as they mostly expect one-shot behavior + process.stdin.isTTY = false; + Object.defineProperty(process.stdout, 'isTTY', { + value: false, + configurable: true, + writable: true, + }); + }); + afterEach(() => { process.argv = originalArgv; }); diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 6ddaada892..c2e505ff34 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -40,6 +40,7 @@ import { coreEvents, GEMINI_MODEL_ALIAS_AUTO, getAdminErrorMessage, + isHeadlessMode, } from '@google/gemini-cli-core'; import { type Settings, @@ -349,7 +350,7 @@ export async function parseArguments( // -p/--prompt forces non-interactive mode; positional args default to interactive in TTY if (q && !result['prompt']) { - if (process.stdin.isTTY) { + if (!isHeadlessMode()) { startupMessages.push( 'Positional arguments now default to interactive mode. To run in non-interactive mode, use the --prompt (-p) flag.', ); @@ -589,7 +590,9 @@ export async function loadCliConfig( const interactive = !!argv.promptInteractive || !!argv.experimentalAcp || - (process.stdin.isTTY && !argv.query && !argv.prompt && !argv.isCommand); + (!isHeadlessMode({ prompt: argv.prompt }) && + !argv.query && + !argv.isCommand); const allowedTools = argv.allowedTools || settings.tools?.allowed || []; const allowedToolsSet = new Set(allowedTools); diff --git a/packages/cli/src/config/trustedFolders.test.ts b/packages/cli/src/config/trustedFolders.test.ts index c0d7b64cb2..914bdd107d 100644 --- a/packages/cli/src/config/trustedFolders.test.ts +++ b/packages/cli/src/config/trustedFolders.test.ts @@ -50,6 +50,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { return { ...actual, homedir: () => '/mock/home/user', + isHeadlessMode: vi.fn(() => false), }; }); vi.mock('fs', async (importOriginal) => { @@ -496,6 +497,50 @@ describe('isWorkspaceTrusted with IDE override', () => { }); }); +describe('isWorkspaceTrusted headless mode', () => { + const mockSettings: Settings = { + security: { + folderTrust: { + enabled: true, + }, + }, + }; + + beforeEach(() => { + resetTrustedFoldersForTesting(); + vi.spyOn(fs, 'existsSync').mockReturnValue(false); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should return true when isHeadlessMode is true, ignoring config', async () => { + const { isHeadlessMode } = await import('@google/gemini-cli-core'); + (isHeadlessMode as Mock).mockReturnValue(true); + + expect(isWorkspaceTrusted(mockSettings)).toEqual({ + isTrusted: true, + source: undefined, + }); + }); + + it('should fall back to config when isHeadlessMode is false', async () => { + const { isHeadlessMode } = await import('@google/gemini-cli-core'); + (isHeadlessMode as Mock).mockReturnValue(false); + + // Config says untrusted + vi.spyOn(fs, 'existsSync').mockImplementation((p) => + p.toString().endsWith('trustedFolders.json') ? true : false, + ); + vi.spyOn(fs, 'readFileSync').mockReturnValue( + JSON.stringify({ [process.cwd()]: TrustLevel.DO_NOT_TRUST }), + ); + + expect(isWorkspaceTrusted(mockSettings).isTrusted).toBe(false); + }); +}); + describe('Trusted Folders Caching', () => { beforeEach(() => { resetTrustedFoldersForTesting(); diff --git a/packages/cli/src/config/trustedFolders.ts b/packages/cli/src/config/trustedFolders.ts index 31827e0cab..ec490192cc 100644 --- a/packages/cli/src/config/trustedFolders.ts +++ b/packages/cli/src/config/trustedFolders.ts @@ -13,6 +13,7 @@ import { ideContextStore, GEMINI_DIR, homedir, + isHeadlessMode, } from '@google/gemini-cli-core'; import type { Settings } from './settings.js'; import stripJsonComments from 'strip-json-comments'; @@ -282,6 +283,10 @@ export function isWorkspaceTrusted( workspaceDir: string = process.cwd(), trustConfig?: Record, ): TrustResult { + if (isHeadlessMode()) { + return { isTrusted: true, source: undefined }; + } + if (!isFolderTrustEnabled(settings)) { return { isTrusted: true, source: undefined }; } diff --git a/packages/cli/src/ui/hooks/useFolderTrust.test.ts b/packages/cli/src/ui/hooks/useFolderTrust.test.ts index 4c8549ab2c..db65c984af 100644 --- a/packages/cli/src/ui/hooks/useFolderTrust.test.ts +++ b/packages/cli/src/ui/hooks/useFolderTrust.test.ts @@ -46,8 +46,24 @@ describe('useFolderTrust', () => { let onTrustChange: (isTrusted: boolean | undefined) => void; let addItem: Mock; + const originalStdoutIsTTY = process.stdout.isTTY; + const originalStdinIsTTY = process.stdin.isTTY; + beforeEach(() => { vi.useFakeTimers(); + + // Default to interactive mode for tests + Object.defineProperty(process.stdout, 'isTTY', { + value: true, + configurable: true, + writable: true, + }); + Object.defineProperty(process.stdin, 'isTTY', { + value: true, + configurable: true, + writable: true, + }); + mockSettings = { merged: { security: { @@ -75,6 +91,16 @@ describe('useFolderTrust', () => { afterEach(() => { vi.useRealTimers(); vi.clearAllMocks(); + Object.defineProperty(process.stdout, 'isTTY', { + value: originalStdoutIsTTY, + configurable: true, + writable: true, + }); + Object.defineProperty(process.stdin, 'isTTY', { + value: originalStdinIsTTY, + configurable: true, + writable: true, + }); }); it('should not open dialog when folder is already trusted', () => { diff --git a/packages/cli/src/ui/hooks/useFolderTrust.ts b/packages/cli/src/ui/hooks/useFolderTrust.ts index 05915b8f43..879bdeca7b 100644 --- a/packages/cli/src/ui/hooks/useFolderTrust.ts +++ b/packages/cli/src/ui/hooks/useFolderTrust.ts @@ -14,7 +14,7 @@ import { } from '../../config/trustedFolders.js'; import * as process from 'node:process'; import { type HistoryItemWithoutId, MessageType } from '../types.js'; -import { coreEvents, ExitCodes } from '@google/gemini-cli-core'; +import { coreEvents, ExitCodes, isHeadlessMode } from '@google/gemini-cli-core'; import { runExitCleanup } from '../../utils/cleanup.js'; export const useFolderTrust = ( @@ -30,21 +30,40 @@ export const useFolderTrust = ( const folderTrust = settings.merged.security.folderTrust.enabled ?? true; useEffect(() => { + let isMounted = true; const { isTrusted: trusted } = isWorkspaceTrusted(settings.merged); - setIsTrusted(trusted); - setIsFolderTrustDialogOpen(trusted === undefined); - onTrustChange(trusted); - if (trusted === false && !startupMessageSent.current) { - addItem( - { - type: MessageType.INFO, - text: 'This folder is not trusted. Some features may be disabled. Use the `/permissions` command to change the trust level.', - }, - Date.now(), - ); - startupMessageSent.current = true; + if (isHeadlessMode()) { + if (isMounted) { + setIsTrusted(trusted); + setIsFolderTrustDialogOpen(false); + onTrustChange(true); + } + return () => { + isMounted = false; + }; } + + if (isMounted) { + setIsTrusted(trusted); + setIsFolderTrustDialogOpen(trusted === undefined); + onTrustChange(trusted); + + if (trusted === false && !startupMessageSent.current) { + addItem( + { + type: MessageType.INFO, + text: 'This folder is not trusted. Some features may be disabled. Use the `/permissions` command to change the trust level.', + }, + Date.now(), + ); + startupMessageSent.current = true; + } + } + + return () => { + isMounted = false; + }; }, [folderTrust, onTrustChange, settings.merged, addItem]); const handleFolderTrustSelect = useCallback( diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index 41270276f3..d978f19d10 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -317,10 +317,14 @@ describe('Server Config (config.ts)', () => { '../tools/mcp-client-manager.js' ); let mcpStarted = false; + let resolveMcp: (value: unknown) => void; + const mcpPromise = new Promise((resolve) => { + resolveMcp = resolve; + }); (McpClientManager as unknown as Mock).mockImplementation(() => ({ startConfiguredMcpServers: vi.fn().mockImplementation(async () => { - await new Promise((resolve) => setTimeout(resolve, 50)); + await mcpPromise; mcpStarted = true; }), getMcpInstructions: vi.fn(), @@ -331,8 +335,9 @@ describe('Server Config (config.ts)', () => { // Should return immediately, before MCP finishes expect(mcpStarted).toBe(false); - // Wait for it to eventually finish to avoid open handles - await new Promise((resolve) => setTimeout(resolve, 60)); + // Now let it finish + resolveMcp!(undefined); + await new Promise((resolve) => setTimeout(resolve, 0)); expect(mcpStarted).toBe(true); }); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 41c11961fd..c4255e6a60 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -57,6 +57,7 @@ export * from './core/apiKeyCredentialStorage.js'; export { homedir, tmpdir } from './utils/paths.js'; export * from './utils/paths.js'; export * from './utils/checks.js'; +export * from './utils/headless.js'; export * from './utils/schemaValidator.js'; export * from './utils/errors.js'; export * from './utils/exitCodes.js'; diff --git a/packages/core/src/utils/authConsent.test.ts b/packages/core/src/utils/authConsent.test.ts index 1db8e105bc..d2188ded17 100644 --- a/packages/core/src/utils/authConsent.test.ts +++ b/packages/core/src/utils/authConsent.test.ts @@ -12,8 +12,12 @@ import { coreEvents } from './events.js'; import { getConsentForOauth } from './authConsent.js'; import { FatalAuthenticationError } from './errors.js'; import { writeToStdout } from './stdio.js'; +import { isHeadlessMode } from './headless.js'; vi.mock('node:readline'); +vi.mock('./headless.js', () => ({ + isHeadlessMode: vi.fn(), +})); vi.mock('./stdio.js', () => ({ writeToStdout: vi.fn(), createWorkingStdio: vi.fn(() => ({ @@ -49,16 +53,12 @@ describe('getConsentForOauth', () => { mockEmitConsentRequest.mockRestore(); }); - it('should use readline when no listeners are present and stdin is a TTY', async () => { + it('should use readline when no listeners are present and not headless', async () => { vi.restoreAllMocks(); const mockListenerCount = vi .spyOn(coreEvents, 'listenerCount') .mockReturnValue(0); - const originalIsTTY = process.stdin.isTTY; - Object.defineProperty(process.stdin, 'isTTY', { - value: true, - configurable: true, - }); + (isHeadlessMode as Mock).mockReturnValue(false); const mockReadline = { on: vi.fn((event, callback) => { @@ -81,31 +81,19 @@ describe('getConsentForOauth', () => { ); mockListenerCount.mockRestore(); - Object.defineProperty(process.stdin, 'isTTY', { - value: originalIsTTY, - configurable: true, - }); }); - it('should throw FatalAuthenticationError when no listeners and not a TTY', async () => { + it('should throw FatalAuthenticationError when no listeners and headless', async () => { vi.restoreAllMocks(); const mockListenerCount = vi .spyOn(coreEvents, 'listenerCount') .mockReturnValue(0); - const originalIsTTY = process.stdin.isTTY; - Object.defineProperty(process.stdin, 'isTTY', { - value: false, - configurable: true, - }); + (isHeadlessMode as Mock).mockReturnValue(true); await expect(getConsentForOauth('Login required.')).rejects.toThrow( FatalAuthenticationError, ); mockListenerCount.mockRestore(); - Object.defineProperty(process.stdin, 'isTTY', { - value: originalIsTTY, - configurable: true, - }); }); }); diff --git a/packages/core/src/utils/authConsent.ts b/packages/core/src/utils/authConsent.ts index 859eaf10f3..65ef633dd4 100644 --- a/packages/core/src/utils/authConsent.ts +++ b/packages/core/src/utils/authConsent.ts @@ -8,6 +8,7 @@ import readline from 'node:readline'; import { CoreEvent, coreEvents } from './events.js'; import { FatalAuthenticationError } from './errors.js'; import { createWorkingStdio, writeToStdout } from './stdio.js'; +import { isHeadlessMode } from './headless.js'; /** * Requests consent from the user for OAuth login. @@ -17,7 +18,7 @@ export async function getConsentForOauth(prompt: string): Promise { const finalPrompt = prompt + ' Opening authentication page in your browser. '; if (coreEvents.listenerCount(CoreEvent.ConsentRequest) === 0) { - if (!process.stdin.isTTY) { + if (isHeadlessMode()) { throw new FatalAuthenticationError( 'Interactive consent could not be obtained.\n' + 'Please run Gemini CLI in an interactive terminal to authenticate, or use NO_BROWSER=true for manual authentication.', diff --git a/packages/core/src/utils/headless.test.ts b/packages/core/src/utils/headless.test.ts new file mode 100644 index 0000000000..89f42ffcd6 --- /dev/null +++ b/packages/core/src/utils/headless.test.ts @@ -0,0 +1,146 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { isHeadlessMode } from './headless.js'; +import process from 'node:process'; + +describe('isHeadlessMode', () => { + const originalStdoutIsTTY = process.stdout.isTTY; + const originalStdinIsTTY = process.stdin.isTTY; + + beforeEach(() => { + vi.stubEnv('CI', ''); + vi.stubEnv('GITHUB_ACTIONS', ''); + // We can't easily stub process.stdout.isTTY with vi.stubEnv + // So we'll use Object.defineProperty + Object.defineProperty(process.stdout, 'isTTY', { + value: true, + configurable: true, + }); + Object.defineProperty(process.stdin, 'isTTY', { + value: true, + configurable: true, + }); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + Object.defineProperty(process.stdout, 'isTTY', { + value: originalStdoutIsTTY, + configurable: true, + }); + Object.defineProperty(process.stdin, 'isTTY', { + value: originalStdinIsTTY, + configurable: true, + }); + vi.restoreAllMocks(); + }); + + it('should return false in a normal TTY environment', () => { + expect(isHeadlessMode()).toBe(false); + }); + + it('should return true if CI environment variable is "true"', () => { + vi.stubEnv('CI', 'true'); + expect(isHeadlessMode()).toBe(true); + }); + + it('should return true if GITHUB_ACTIONS environment variable is "true"', () => { + vi.stubEnv('GITHUB_ACTIONS', 'true'); + expect(isHeadlessMode()).toBe(true); + }); + + it('should return true if stdout is not a TTY', () => { + Object.defineProperty(process.stdout, 'isTTY', { + value: false, + configurable: true, + }); + expect(isHeadlessMode()).toBe(true); + }); + + it('should return true if stdin is not a TTY', () => { + Object.defineProperty(process.stdin, 'isTTY', { + value: false, + configurable: true, + }); + expect(isHeadlessMode()).toBe(true); + }); + + it('should return true if stdin is a TTY but stdout is not', () => { + Object.defineProperty(process.stdin, 'isTTY', { + value: true, + configurable: true, + }); + Object.defineProperty(process.stdout, 'isTTY', { + value: false, + configurable: true, + }); + expect(isHeadlessMode()).toBe(true); + }); + + it('should return true if stdout is a TTY but stdin is not', () => { + Object.defineProperty(process.stdin, 'isTTY', { + value: false, + configurable: true, + }); + Object.defineProperty(process.stdout, 'isTTY', { + value: true, + configurable: true, + }); + expect(isHeadlessMode()).toBe(true); + }); + + it('should return true if a prompt option is provided', () => { + expect(isHeadlessMode({ prompt: 'test prompt' })).toBe(true); + expect(isHeadlessMode({ prompt: true })).toBe(true); + }); + + it('should return false if query is provided but it is still a TTY', () => { + // Note: per current logic, query alone doesn't force headless if TTY + // This matches the existing behavior in packages/cli/src/config/config.ts + expect(isHeadlessMode({ query: 'test query' })).toBe(false); + }); + + it('should handle undefined process.stdout gracefully', () => { + const originalStdout = process.stdout; + // @ts-expect-error - testing edge case + delete process.stdout; + + try { + expect(isHeadlessMode()).toBe(false); + } finally { + Object.defineProperty(process, 'stdout', { + value: originalStdout, + configurable: true, + }); + } + }); + + it('should handle undefined process.stdin gracefully', () => { + const originalStdin = process.stdin; + // @ts-expect-error - testing edge case + delete process.stdin; + + try { + expect(isHeadlessMode()).toBe(false); + } finally { + Object.defineProperty(process, 'stdin', { + value: originalStdin, + configurable: true, + }); + } + }); + + it('should return true if multiple headless indicators are set', () => { + vi.stubEnv('CI', 'true'); + Object.defineProperty(process.stdout, 'isTTY', { + value: false, + configurable: true, + }); + expect(isHeadlessMode({ prompt: true })).toBe(true); + }); +}); diff --git a/packages/core/src/utils/headless.ts b/packages/core/src/utils/headless.ts new file mode 100644 index 0000000000..750031e8dd --- /dev/null +++ b/packages/core/src/utils/headless.ts @@ -0,0 +1,38 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import process from 'node:process'; + +/** + * Options for headless mode detection. + */ +export interface HeadlessModeOptions { + /** Explicit prompt string or flag. */ + prompt?: string | boolean; + /** Initial query positional argument. */ + query?: string | boolean; +} + +/** + * Detects if the CLI is running in a "headless" (non-interactive) mode. + * + * Headless mode is triggered by: + * 1. process.env.CI being set to 'true'. + * 2. process.stdout not being a TTY. + * 3. Presence of an explicit prompt flag. + * + * @param options - Optional flags and arguments from the CLI. + * @returns true if the environment is considered headless. + */ +export function isHeadlessMode(options?: HeadlessModeOptions): boolean { + return ( + process.env['CI'] === 'true' || + process.env['GITHUB_ACTIONS'] === 'true' || + !!options?.prompt || + (!!process.stdin && !process.stdin.isTTY) || + (!!process.stdout && !process.stdout.isTTY) + ); +}