Compare commits

...

12 Commits

Author SHA1 Message Date
galz10 ae829e436d fix(cli): make folder trust headless detection more robust 2026-02-11 14:55:20 -08:00
galz10 08b1d89c62 fix: ensure folderTrust returns true in headless mode 2026-02-11 14:48:20 -08:00
galz10 ba2b133476 fix(cli): disable folder trust in test-rig.ts 2026-02-09 13:17:29 -08:00
galz10 c576f3a367 fix(cli): disable folder trust and headless mode in tests 2026-02-09 11:45:59 -08:00
Gal Zahavi 593e2b4afa Merge branch 'main' into galzahavi/fix/headless-foldertrust 2026-02-09 11:30:23 -08:00
galz10 c4da99840d fix(cli): disable folder trust in test environments
Disable folder trust when running in integration tests (GEMINI_CLI_INTEGRATION_TEST)
   or unit tests (VITEST) to prevent interactive prompts from blocking automated tests.

   Also updates config.test.ts to isolate folder trust tests from these environment
   variables so the underlying logic can still be verified.
2026-02-09 10:46:55 -08:00
galz10 ad02f74171 test(cli): isolate config tests from CI environment variables
Unset CI and GITHUB_ACTIONS environment variables in config.test.ts to
   ensure tests that rely on interactive/TTY mode pass correctly in CI
   environments.

   The isHeadlessMode detection logic defaults to non-interactive mode if
   these variables are present, which previously caused assertion failures
   in tests expecting interactive startup messages and specific tool
   exclusion behaviors.
2026-02-09 10:10:01 -08:00
galz10 f1454e6f68 Merge branch 'main' into galzahavi/fix/headless-foldertrust 2026-02-09 09:50:30 -08:00
Gal Zahavi ea738ec719 Merge branch 'main' into galzahavi/fix/headless-foldertrust 2026-02-09 09:30:49 -08:00
Gal Zahavi 40cdb85856 Merge branch 'main' into galzahavi/fix/headless-foldertrust 2026-02-06 14:50:08 -08:00
galz10 8daf6151e8 Merge branch 'main' into galzahavi/fix/headless-foldertrust 2026-02-06 14:49:23 -08:00
galz10 80a6ac3759 feat(core,cli): harden headless mode detection and align mocks
- Update isHeadlessMode in packages/core to check both stdin.isTTY and stdout.isTTY.
- Synchronize isHeadlessMode mock in packages/cli tests and add global TTY stubs to ensure consistent test environments.
- Add isMounted guard to useFolderTrust hook to prevent state updates on unmounted components in headless mode.
- Expand unit tests in packages/core to cover new TTY check combinations and edge cases.
- Stabilize flaky MCP initialization test in packages/core/src/config/config.test.ts by using a deterministic promise.
- Address review findings regarding environment detection consistency and CI indicator checks.
2026-02-05 15:19:36 -08:00
14 changed files with 668 additions and 48 deletions
+24 -1
View File
@@ -2254,6 +2254,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",
@@ -2434,6 +2435,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"
}
@@ -2467,6 +2469,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"
},
@@ -2835,6 +2838,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"
@@ -2868,6 +2872,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"
@@ -2920,6 +2925,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",
@@ -4135,6 +4141,7 @@
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -4429,6 +4436,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",
@@ -5421,6 +5429,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"
},
@@ -8430,6 +8439,7 @@
"integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -8970,6 +8980,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",
@@ -10571,6 +10582,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",
@@ -14355,6 +14367,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -14365,6 +14378,7 @@
"integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"shell-quote": "^1.6.1",
"ws": "^7"
@@ -16601,6 +16615,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -16824,7 +16839,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",
@@ -16832,6 +16848,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"
@@ -17004,6 +17021,7 @@
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -17211,6 +17229,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",
@@ -17324,6 +17343,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -17336,6 +17356,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",
@@ -18040,6 +18061,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"
}
@@ -18338,6 +18360,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
+81 -2
View File
@@ -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;
});
+10 -3
View File
@@ -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);
+219 -4
View File
@@ -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,131 @@ 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,
);
});
it('should return true for isPathTrusted when isHeadlessMode is true', async () => {
const geminiCore = await import('@google/gemini-cli-core');
vi.spyOn(geminiCore, 'isHeadlessMode').mockReturnValue(true);
const folders = loadTrustedFolders();
expect(folders.isPathTrusted('/any-untrusted-path')).toBe(true);
});
it('should return true when a prompt flag is detected in process.argv', async () => {
const geminiCore = await import('@google/gemini-cli-core');
vi.spyOn(geminiCore, 'isHeadlessMode').mockReturnValue(false);
const originalArgv = process.argv;
process.argv = ['node', 'gemini', '--prompt', 'test'];
try {
expect(isWorkspaceTrusted(mockSettings).isTrusted).toBe(true);
const folders = loadTrustedFolders();
expect(folders.isPathTrusted('/any-path')).toBe(true);
} finally {
process.argv = originalArgv;
}
});
it('should return true when a positional argument (query/command) is detected', async () => {
const geminiCore = await import('@google/gemini-cli-core');
vi.spyOn(geminiCore, 'isHeadlessMode').mockReturnValue(false);
const originalArgv = process.argv;
process.argv = ['node', 'gemini', '/pickle'];
try {
expect(isWorkspaceTrusted(mockSettings).isTrusted).toBe(true);
} finally {
process.argv = originalArgv;
}
});
it('should return false (or check config) when no headless indicators are present', async () => {
const geminiCore = await import('@google/gemini-cli-core');
vi.spyOn(geminiCore, 'isHeadlessMode').mockReturnValue(false);
const originalArgv = process.argv;
process.argv = ['node', 'gemini'];
try {
const config = { '/projectA': TrustLevel.DO_NOT_TRUST };
fs.writeFileSync(trustedFoldersPath, JSON.stringify(config), 'utf-8');
expect(isWorkspaceTrusted(mockSettings, '/projectA').isTrusted).toBe(
false,
);
} finally {
process.argv = originalArgv;
}
});
});
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 +558,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', () => {
+30
View File
@@ -15,6 +15,7 @@ import {
ideContextStore,
GEMINI_DIR,
homedir,
isHeadlessMode,
coreEvents,
} from '@google/gemini-cli-core';
import type { Settings } from './settings.js';
@@ -103,6 +104,28 @@ function getRealPath(location: string): string {
return realPath;
}
function isActuallyHeadless(): boolean {
if (isHeadlessMode()) {
return true;
}
// Sniff for headless mode from process.argv if not already detected by core.
// This helps identify "headless" sessions (e.g. running a specific command or query)
// that don't need interactive trust prompts.
const args = process.argv.slice(2);
const doubleDashIndex = args.indexOf('--');
const relevantArgs =
doubleDashIndex === -1 ? args : args.slice(0, doubleDashIndex);
return (
relevantArgs.includes('-p') ||
relevantArgs.includes('--prompt') ||
relevantArgs.includes('-q') ||
relevantArgs.includes('--query') ||
(relevantArgs.length > 0 && !relevantArgs[0].startsWith('-'))
);
}
export class LoadedTrustedFolders {
constructor(
readonly user: TrustedFoldersFile,
@@ -127,6 +150,9 @@ export class LoadedTrustedFolders {
location: string,
config?: Record<string, TrustLevel>,
): boolean | undefined {
if (isActuallyHeadless()) {
return true;
}
const configToUse = config ?? this.user.config;
// Resolve location to its realpath for canonical comparison
@@ -354,6 +380,10 @@ export function isWorkspaceTrusted(
workspaceDir: string = process.cwd(),
trustConfig?: Record<string, TrustLevel>,
): TrustResult {
if (isActuallyHeadless()) {
return { isTrusted: true, source: undefined };
}
if (!isFolderTrustEnabled(settings)) {
return { isTrusted: true, source: undefined };
}
@@ -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<typeof import('node:process')>('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),
);
});
});
});
+31 -13
View File
@@ -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(
+8 -3
View File
@@ -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);
});
+1
View File
@@ -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';
+8 -20
View File
@@ -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,
});
});
});
+2 -1
View File
@@ -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<boolean> {
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.',
+146
View File
@@ -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);
});
});
+45
View File
@@ -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)
);
}
+1
View File
@@ -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];