From bce1caefd07cafa270aa8510164eed30a70381a3 Mon Sep 17 00:00:00 2001 From: Gal Zahavi <38544478+galz10@users.noreply.github.com> Date: Mon, 9 Feb 2026 15:46:49 -0800 Subject: [PATCH] feat(cli): disable folder trust in headless mode (#18407) --- package-lock.json | 25 ++- packages/cli/src/config/config.test.ts | 83 ++++++++- packages/cli/src/config/config.ts | 13 +- .../cli/src/config/trustedFolders.test.ts | 167 +++++++++++++++++- packages/cli/src/config/trustedFolders.ts | 5 + .../cli/src/ui/hooks/useFolderTrust.test.ts | 63 ++++++- packages/cli/src/ui/hooks/useFolderTrust.ts | 44 +++-- packages/core/src/config/config.test.ts | 11 +- packages/core/src/index.ts | 1 + packages/core/src/utils/authConsent.test.ts | 28 +-- packages/core/src/utils/authConsent.ts | 3 +- packages/core/src/utils/headless.test.ts | 146 +++++++++++++++ packages/core/src/utils/headless.ts | 45 +++++ packages/test-utils/src/test-rig.ts | 1 + 14 files changed, 587 insertions(+), 48 deletions(-) create mode 100644 packages/core/src/utils/headless.test.ts create mode 100644 packages/core/src/utils/headless.ts diff --git a/package-lock.json b/package-lock.json index 682dbf2777..bb2d9b9b9f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2255,6 +2255,7 @@ "integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.2", @@ -2435,6 +2436,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8.0.0" } @@ -2468,6 +2470,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.0.1.tgz", "integrity": "sha512-MaZk9SJIDgo1peKevlbhP6+IwIiNPNmswNL4AF0WaQJLbHXjr9SrZMgS12+iqr9ToV4ZVosCcc0f8Rg67LXjxw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, @@ -2836,6 +2839,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.0.1.tgz", "integrity": "sha512-dZOB3R6zvBwDKnHDTB4X1xtMArB/d324VsbiPkX/Yu0Q8T2xceRthoIVFhJdvgVM2QhGVUyX9tzwiNxGtoBJUw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/semantic-conventions": "^1.29.0" @@ -2869,6 +2873,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.0.1.tgz", "integrity": "sha512-wf8OaJoSnujMAHWR3g+/hGvNcsC16rf9s1So4JlMiFaFHiE4HpIA3oUh+uWZQ7CNuK8gVW/pQSkgoa5HkkOl0g==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/resources": "2.0.1" @@ -2921,6 +2926,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.0.1.tgz", "integrity": "sha512-xYLlvk/xdScGx1aEqvxLwf6sXQLXCjk3/1SQT9X9AoN5rXRhkdvIFShuNNmtTEPRBqcsMbS4p/gJLNI2wXaDuQ==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/resources": "2.0.1", @@ -4136,6 +4142,7 @@ "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -4430,6 +4437,7 @@ "integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.35.0", "@typescript-eslint/types": "8.35.0", @@ -5422,6 +5430,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -8431,6 +8440,7 @@ "integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -8971,6 +8981,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -10584,6 +10595,7 @@ "resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.8.tgz", "integrity": "sha512-v0thcXIKl9hqF/1w4HqA6MKxIcMoWSP3YtEZIAA+eeJngXpN5lGnMkb6rllB7FnOdwyEyYaFTcu1ZVr4/JZpWQ==", "license": "MIT", + "peer": true, "dependencies": { "@alcalzone/ansi-tokenize": "^0.2.1", "ansi-escapes": "^7.0.0", @@ -14368,6 +14380,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -14378,6 +14391,7 @@ "integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" @@ -16614,6 +16628,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -16837,7 +16852,8 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "dev": true, - "license": "0BSD" + "license": "0BSD", + "peer": true }, "node_modules/tsx": { "version": "4.20.3", @@ -16845,6 +16861,7 @@ "integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" @@ -17017,6 +17034,7 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -17224,6 +17242,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz", "integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -17337,6 +17356,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -17349,6 +17369,7 @@ "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "license": "MIT", + "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", @@ -18053,6 +18074,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -18351,6 +18373,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 4342675500..615f6d0cab 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -141,6 +141,22 @@ vi.mock('@google/gemini-cli-core', async () => { defaultDecision: ServerConfig.PolicyDecision.ASK_USER, approvalMode: ServerConfig.ApprovalMode.DEFAULT, })), + isHeadlessMode: vi.fn((opts) => { + if (process.env['VITEST'] === 'true') { + return ( + !!opts?.prompt || + (!!process.stdin && !process.stdin.isTTY) || + (!!process.stdout && !process.stdout.isTTY) + ); + } + return ( + !!opts?.prompt || + process.env['CI'] === 'true' || + process.env['GITHUB_ACTIONS'] === 'true' || + (!!process.stdin && !process.stdin.isTTY) || + (!!process.stdout && !process.stdout.isTTY) + ); + }), }; }); @@ -154,6 +170,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']; @@ -162,6 +180,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(() => { @@ -171,6 +201,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', () => { @@ -249,6 +289,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: @@ -379,8 +429,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 { @@ -389,7 +443,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 } }); }); @@ -1732,14 +1786,29 @@ describe('loadCliConfig model selection', () => { }); describe('loadCliConfig folderTrust', () => { + let originalVitest: string | undefined; + let originalIntegrationTest: string | undefined; + beforeEach(() => { vi.resetAllMocks(); vi.mocked(os.homedir).mockReturnValue('/mock/home/user'); vi.stubEnv('GEMINI_API_KEY', 'test-api-key'); vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); + + originalVitest = process.env['VITEST']; + originalIntegrationTest = process.env['GEMINI_CLI_INTEGRATION_TEST']; + delete process.env['VITEST']; + delete process.env['GEMINI_CLI_INTEGRATION_TEST']; }); afterEach(() => { + if (originalVitest !== undefined) { + process.env['VITEST'] = originalVitest; + } + if (originalIntegrationTest !== undefined) { + process.env['GEMINI_CLI_INTEGRATION_TEST'] = originalIntegrationTest; + } + vi.unstubAllEnvs(); vi.restoreAllMocks(); }); @@ -2779,6 +2848,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 976cdc8c1d..fcc62721af 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -35,6 +35,7 @@ import { coreEvents, GEMINI_MODEL_ALIAS_AUTO, getAdminErrorMessage, + isHeadlessMode, Config, applyAdminAllowlist, getAdminBlockedMcpServersMessage, @@ -352,7 +353,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.', ); @@ -436,7 +437,11 @@ export async function loadCliConfig( const ideMode = settings.ide?.enabled ?? false; - const folderTrust = settings.security?.folderTrust?.enabled ?? false; + const folderTrust = + process.env['GEMINI_CLI_INTEGRATION_TEST'] === 'true' || + process.env['VITEST'] === 'true' + ? false + : (settings.security?.folderTrust?.enabled ?? false); const trustedFolder = isWorkspaceTrusted(settings, cwd)?.isTrusted ?? false; // Set the context filename in the server's memoryTool module BEFORE loading memory @@ -592,7 +597,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 9ad53a16f0..dff4610b90 100644 --- a/packages/cli/src/config/trustedFolders.test.ts +++ b/packages/cli/src/config/trustedFolders.test.ts @@ -32,6 +32,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { return { ...actual, homedir: () => '/mock/home/user', + isHeadlessMode: vi.fn(() => false), coreEvents: { emitFeedback: vi.fn(), }, @@ -280,6 +281,26 @@ describe('Trusted Folders', () => { }); }); + it('should return true for a child of a trusted folder', () => { + const config = { '/projectA': TrustLevel.TRUST_FOLDER }; + fs.writeFileSync(trustedFoldersPath, JSON.stringify(config), 'utf-8'); + + expect(isWorkspaceTrusted(mockSettings, '/projectA/src')).toEqual({ + isTrusted: true, + source: 'file', + }); + }); + + it('should return true for a child of a trusted parent folder', () => { + const config = { '/projectB/somefile.txt': TrustLevel.TRUST_PARENT }; + fs.writeFileSync(trustedFoldersPath, JSON.stringify(config), 'utf-8'); + + expect(isWorkspaceTrusted(mockSettings, '/projectB')).toEqual({ + isTrusted: true, + source: 'file', + }); + }); + it('should return false for a directly untrusted folder', () => { const config = { '/untrusted': TrustLevel.DO_NOT_TRUST }; fs.writeFileSync(trustedFoldersPath, JSON.stringify(config), 'utf-8'); @@ -290,6 +311,15 @@ describe('Trusted Folders', () => { }); }); + it('should return false for a child of an untrusted folder', () => { + const config = { '/untrusted': TrustLevel.DO_NOT_TRUST }; + fs.writeFileSync(trustedFoldersPath, JSON.stringify(config), 'utf-8'); + + expect(isWorkspaceTrusted(mockSettings, '/untrusted/src').isTrusted).toBe( + false, + ); + }); + it('should return undefined when no rules match', () => { fs.writeFileSync(trustedFoldersPath, '{}', 'utf-8'); expect( @@ -297,6 +327,47 @@ describe('Trusted Folders', () => { ).toBeUndefined(); }); + it('should prioritize specific distrust over parent trust', () => { + const config = { + '/projectA': TrustLevel.TRUST_FOLDER, + '/projectA/untrusted': TrustLevel.DO_NOT_TRUST, + }; + fs.writeFileSync(trustedFoldersPath, JSON.stringify(config), 'utf-8'); + + expect(isWorkspaceTrusted(mockSettings, '/projectA/untrusted')).toEqual({ + isTrusted: false, + source: 'file', + }); + }); + + it('should use workspaceDir instead of process.cwd() when provided', () => { + const config = { + '/projectA': TrustLevel.TRUST_FOLDER, + '/untrusted': TrustLevel.DO_NOT_TRUST, + }; + fs.writeFileSync(trustedFoldersPath, JSON.stringify(config), 'utf-8'); + + vi.spyOn(process, 'cwd').mockImplementation(() => '/untrusted'); + + // process.cwd() is untrusted, but workspaceDir is trusted + expect(isWorkspaceTrusted(mockSettings, '/projectA')).toEqual({ + isTrusted: true, + source: 'file', + }); + }); + + it('should handle path normalization', () => { + const config = { '/home/user/projectA': TrustLevel.TRUST_FOLDER }; + fs.writeFileSync(trustedFoldersPath, JSON.stringify(config), 'utf-8'); + + expect( + isWorkspaceTrusted(mockSettings, '/home/user/../user/projectA'), + ).toEqual({ + isTrusted: true, + source: 'file', + }); + }); + it('should prioritize IDE override over file config', () => { const config = { '/projectA': TrustLevel.DO_NOT_TRUST }; fs.writeFileSync(trustedFoldersPath, JSON.stringify(config), 'utf-8'); @@ -313,6 +384,30 @@ describe('Trusted Folders', () => { } }); + it('should return false when IDE override is false', () => { + const config = { '/projectA': TrustLevel.TRUST_FOLDER }; + fs.writeFileSync(trustedFoldersPath, JSON.stringify(config), 'utf-8'); + + ideContextStore.set({ workspaceState: { isTrusted: false } }); + + try { + expect(isWorkspaceTrusted(mockSettings, '/projectA')).toEqual({ + isTrusted: false, + source: 'ide', + }); + } finally { + ideContextStore.clear(); + } + }); + + it('should throw FatalConfigError when the config file is invalid', () => { + fs.writeFileSync(trustedFoldersPath, 'invalid json', 'utf-8'); + + expect(() => isWorkspaceTrusted(mockSettings, '/any')).toThrow( + FatalConfigError, + ); + }); + it('should always return true if folderTrust setting is disabled', () => { const disabledSettings: Settings = { security: { folderTrust: { enabled: false } }, @@ -324,7 +419,75 @@ describe('Trusted Folders', () => { }); }); + describe('isWorkspaceTrusted headless mode', () => { + const mockSettings: Settings = { + security: { + folderTrust: { + enabled: true, + }, + }, + }; + + it('should return true when isHeadlessMode is true, ignoring config', async () => { + const geminiCore = await import('@google/gemini-cli-core'); + vi.spyOn(geminiCore, 'isHeadlessMode').mockReturnValue(true); + + expect(isWorkspaceTrusted(mockSettings)).toEqual({ + isTrusted: true, + source: undefined, + }); + }); + + it('should fall back to config when isHeadlessMode is false', async () => { + const geminiCore = await import('@google/gemini-cli-core'); + vi.spyOn(geminiCore, 'isHeadlessMode').mockReturnValue(false); + + const config = { '/projectA': TrustLevel.DO_NOT_TRUST }; + fs.writeFileSync(trustedFoldersPath, JSON.stringify(config), 'utf-8'); + + expect(isWorkspaceTrusted(mockSettings, '/projectA').isTrusted).toBe( + false, + ); + }); + }); + + describe('Trusted Folders Caching', () => { + it('should cache the loaded folders object', () => { + // First call should load and cache + const folders1 = loadTrustedFolders(); + + // Second call should return the same instance from cache + const folders2 = loadTrustedFolders(); + expect(folders1).toBe(folders2); + + // Resetting should clear the cache + resetTrustedFoldersForTesting(); + + // Third call should return a new instance + const folders3 = loadTrustedFolders(); + expect(folders3).not.toBe(folders1); + }); + }); + + describe('invalid trust levels', () => { + it('should create a comprehensive error message for invalid trust level', () => { + const config = { '/user/folder': 'INVALID_TRUST_LEVEL' }; + fs.writeFileSync(trustedFoldersPath, JSON.stringify(config), 'utf-8'); + + const { errors } = loadTrustedFolders(); + const possibleValues = Object.values(TrustLevel).join(', '); + expect(errors.length).toBe(1); + expect(errors[0].message).toBe( + `Invalid trust level "INVALID_TRUST_LEVEL" for path "/user/folder". Possible values are: ${possibleValues}.`, + ); + }); + }); + describe('Symlinks Support', () => { + const mockSettings: Settings = { + security: { folderTrust: { enabled: true } }, + }; + it('should trust a folder if the rule matches the realpath', () => { // Create a real directory and a symlink const realDir = path.join(tempDir, 'real'); @@ -339,10 +502,6 @@ describe('Trusted Folders', () => { // Check against symlink path expect(isWorkspaceTrusted(mockSettings, symlinkDir).isTrusted).toBe(true); }); - - const mockSettings: Settings = { - security: { folderTrust: { enabled: true } }, - }; }); describe('Verification: Auth and Trust Interaction', () => { diff --git a/packages/cli/src/config/trustedFolders.ts b/packages/cli/src/config/trustedFolders.ts index a3b78a4187..0b00449700 100644 --- a/packages/cli/src/config/trustedFolders.ts +++ b/packages/cli/src/config/trustedFolders.ts @@ -15,6 +15,7 @@ import { ideContextStore, GEMINI_DIR, homedir, + isHeadlessMode, coreEvents, } from '@google/gemini-cli-core'; import type { Settings } from './settings.js'; @@ -354,6 +355,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 8001efa993..742ad61fed 100644 --- a/packages/cli/src/ui/hooks/useFolderTrust.test.ts +++ b/packages/cli/src/ui/hooks/useFolderTrust.test.ts @@ -23,11 +23,22 @@ import { FolderTrustChoice } from '../components/FolderTrustDialog.js'; import type { LoadedTrustedFolders } from '../../config/trustedFolders.js'; import { TrustLevel } from '../../config/trustedFolders.js'; import * as trustedFolders from '../../config/trustedFolders.js'; -import { coreEvents, ExitCodes } from '@google/gemini-cli-core'; +import { coreEvents, ExitCodes, isHeadlessMode } from '@google/gemini-cli-core'; +import { MessageType } from '../types.js'; const mockedCwd = vi.hoisted(() => vi.fn()); const mockedExit = vi.hoisted(() => vi.fn()); +vi.mock('@google/gemini-cli-core', async () => { + const actual = await vi.importActual< + typeof import('@google/gemini-cli-core') + >('@google/gemini-cli-core'); + return { + ...actual, + isHeadlessMode: vi.fn().mockReturnValue(false), + }; +}); + vi.mock('node:process', async () => { const actual = await vi.importActual('node:process'); @@ -46,8 +57,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 +102,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', () => { @@ -318,4 +355,28 @@ describe('useFolderTrust', () => { ); expect(mockedExit).toHaveBeenCalledWith(ExitCodes.FATAL_CONFIG_ERROR); }); + + describe('headless mode', () => { + it('should force trust and hide dialog in headless mode', () => { + vi.mocked(isHeadlessMode).mockReturnValue(true); + isWorkspaceTrustedSpy.mockReturnValue({ + isTrusted: false, + source: 'file', + }); + + const { result } = renderHook(() => + useFolderTrust(mockSettings, onTrustChange, addItem), + ); + + expect(result.current.isFolderTrustDialogOpen).toBe(false); + expect(onTrustChange).toHaveBeenCalledWith(true); + expect(addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.INFO, + text: expect.stringContaining('This folder is untrusted'), + }), + expect.any(Number), + ); + }); + }); }); diff --git a/packages/cli/src/ui/hooks/useFolderTrust.ts b/packages/cli/src/ui/hooks/useFolderTrust.ts index b8a43659aa..3711cb8d05 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,39 @@ 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 untrusted, project settings, hooks, MCPs, and GEMINI.md files will not be applied for this folder.\nUse the `/permissions` command to change the trust level.', - }, - Date.now(), - ); - startupMessageSent.current = true; + const showUntrustedMessage = () => { + if (trusted === false && !startupMessageSent.current) { + addItem( + { + type: MessageType.INFO, + text: 'This folder is untrusted, project settings, hooks, MCPs, and GEMINI.md files will not be applied for this folder.\nUse the `/permissions` command to change the trust level.', + }, + Date.now(), + ); + startupMessageSent.current = true; + } + }; + + if (isHeadlessMode()) { + if (isMounted) { + setIsTrusted(trusted); + setIsFolderTrustDialogOpen(false); + onTrustChange(true); + showUntrustedMessage(); + } + } else if (isMounted) { + setIsTrusted(trusted); + setIsFolderTrustDialogOpen(trusted === undefined); + onTrustChange(trusted); + showUntrustedMessage(); } + + 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 d2c460d240..6688d13501 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -316,10 +316,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(), @@ -330,8 +334,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 856a896b3a..a8846000d9 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -59,6 +59,7 @@ export * from './utils/fetch.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..27ea5f9cbf --- /dev/null +++ b/packages/core/src/utils/headless.ts @@ -0,0 +1,45 @@ +/** + * @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 { + if (process.env['GEMINI_CLI_INTEGRATION_TEST'] === 'true') { + return ( + !!options?.prompt || + (!!process.stdin && !process.stdin.isTTY) || + (!!process.stdout && !process.stdout.isTTY) + ); + } + return ( + process.env['CI'] === 'true' || + process.env['GITHUB_ACTIONS'] === 'true' || + !!options?.prompt || + (!!process.stdin && !process.stdin.isTTY) || + (!!process.stdout && !process.stdout.isTTY) + ); +} diff --git a/packages/test-utils/src/test-rig.ts b/packages/test-utils/src/test-rig.ts index 9648751339..7a74dc9082 100644 --- a/packages/test-utils/src/test-rig.ts +++ b/packages/test-utils/src/test-rig.ts @@ -485,6 +485,7 @@ export class TestRig { key !== 'GEMINI_MODEL' && key !== 'GEMINI_DEBUG' && key !== 'GEMINI_CLI_TEST_VAR' && + key !== 'GEMINI_CLI_INTEGRATION_TEST' && !key.startsWith('GEMINI_CLI_ACTIVITY_LOG') ) { delete cleanEnv[key];