Compare commits

...

16 Commits

Author SHA1 Message Date
gemini-cli-robot 4fdc047eb8 chore(release): v0.28.0 2026-02-10 21:53:19 +00:00
gemini-cli-robot b7bd9ec183 chore(release): v0.28.0-preview.8 2026-02-10 19:13:00 +00:00
gemini-cli-robot b90be47308 fix(patch): cherry-pick bce1cae to release/v0.28.0-preview.6-pr-18407 [CONFLICTS] (#18739)
Co-authored-by: Gal Zahavi <38544478+galz10@users.noreply.github.com>
Co-authored-by: galz10 <galzahavi@google.com>
2026-02-10 10:50:49 -08:00
gemini-cli-robot b27fd49c16 chore(release): v0.28.0-preview.6 2026-02-09 21:00:30 +00:00
gemini-cli-robot 9506601073 fix(patch): cherry-pick 81ccd80 to release/v0.28.0-preview.5-pr-18406 to patch version v0.28.0-preview.5 and create version 0.28.0-preview.6 (#18651)
Co-authored-by: Gal Zahavi <38544478+galz10@users.noreply.github.com>
Co-authored-by: galz10 <galzahavi@google.com>
2026-02-09 18:21:53 +00:00
gemini-cli-robot 9affc72d3a chore(release): v0.28.0-preview.5 2026-02-06 21:25:17 +00:00
gemini-cli-robot 44c67daa07 fix(patch): cherry-pick ec5836c to release/v0.28.0-preview.4-pr-18343 to patch version v0.28.0-preview.4 and create version 0.28.0-preview.5 (#18472) 2026-02-06 13:01:51 -08:00
gemini-cli-robot 37f24a94e8 chore(release): v0.28.0-preview.4 2026-02-06 20:29:55 +00:00
gemini-cli-robot 900e0c9f05 fix(patch): cherry-pick 1cae5ab to release/v0.28.0-preview.3-pr-18376 to patch version v0.28.0-preview.3 and create version 0.28.0-preview.4 (#18463)
Co-authored-by: Peter Friese <peter@peterfriese.de>
2026-02-06 19:51:00 +00:00
gemini-cli-robot 7fb0e1d1c1 chore(release): v0.28.0-preview.3 2026-02-06 19:05:25 +00:00
gemini-cli-robot 64642f2718 fix(patch): cherry-pick d86b1f7 to release/v0.28.0-preview.2-pr-18447 to patch version v0.28.0-preview.2 and create version 0.28.0-preview.3 (#18450)
Co-authored-by: christine betts <chrstn@uw.edu>
2026-02-06 18:21:09 +00:00
gemini-cli-robot 171ecdef18 chore(release): v0.28.0-preview.2 2026-02-05 21:17:47 +00:00
gemini-cli-robot 45866d8e0a fix(patch): cherry-pick 9ca7300 to release/v0.28.0-preview.1-pr-18395 to patch version v0.28.0-preview.1 and create version 0.28.0-preview.2 (#18400)
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
2026-02-05 20:55:31 +00:00
gemini-cli-robot 1795ffaf36 chore(release): v0.28.0-preview.1 2026-02-05 15:38:07 +00:00
gemini-cli-robot 8468a48bff fix(patch): cherry-pick aba8c5f to release/v0.28.0-preview.0-pr-17806 to patch version v0.28.0-preview.0 and create version 0.28.0-preview.1 (#18307) 2026-02-04 08:07:42 -08:00
gemini-cli-robot 9bd6ec6664 chore(release): v0.28.0-preview.0 2026-02-03 23:58:27 +00:00
73 changed files with 2563 additions and 1544 deletions
+11 -10
View File
@@ -106,16 +106,17 @@ available combinations.
| Toggle YOLO (auto-approval) mode for tool calls. | `Ctrl + Y` |
| Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only). | `Shift + Tab` |
| Expand a height-constrained response to show additional lines when not in alternate buffer mode. | `Ctrl + O`<br />`Ctrl + S` |
| Ctrl+B | `Ctrl + B` |
| Ctrl+L | `Ctrl + L` |
| Ctrl+K | `Ctrl + K` |
| Enter | `Enter` |
| Esc | `Esc` |
| Shift+Tab | `Shift + Tab` |
| Tab | `Tab (no Shift)` |
| Tab | `Tab (no Shift)` |
| Focus the shell input from the gemini input. | `Tab (no Shift)` |
| Focus the Gemini input from the shell input. | `Tab` |
| Toggle current background shell visibility. | `Ctrl + B` |
| Toggle background shell list. | `Ctrl + L` |
| Kill the active background shell. | `Ctrl + K` |
| Confirm selection in background shell list. | `Enter` |
| Dismiss background shell list. | `Esc` |
| Move focus from background shell to Gemini. | `Shift + Tab` |
| Move focus from background shell list to Gemini. | `Tab (no Shift)` |
| Show warning when trying to unfocus background shell via Tab. | `Tab (no Shift)` |
| Show warning when trying to unfocus shell input via Tab. | `Tab (no Shift)` |
| Move focus from Gemini to the active shell. | `Tab (no Shift)` |
| Move focus from the shell back to Gemini. | `Shift + Tab` |
| Clear the terminal screen and redraw the UI. | `Ctrl + L` |
| Restart the application. | `R` |
| Suspend the application (not yet implemented). | `Ctrl + Z` |
+1 -1
View File
@@ -115,7 +115,7 @@ they appear in the UI.
| Allow Permanent Tool Approval | `security.enablePermanentToolApproval` | Enable the "Allow for all future sessions" option in tool confirmation dialogs. | `false` |
| Blocks extensions from Git | `security.blockGitExtensions` | Blocks installing and loading extensions from Git. | `false` |
| Extension Source Regex Allowlist | `security.allowedExtensions` | List of Regex patterns for allowed extensions. If nonempty, only extensions that match the patterns in this list are allowed. Overrides the blockGitExtensions setting. | `[]` |
| Folder Trust | `security.folderTrust.enabled` | Setting to track whether Folder trust is enabled. | `false` |
| Folder Trust | `security.folderTrust.enabled` | Setting to track whether Folder trust is enabled. | `true` |
| Enable Environment Variable Redaction | `security.environmentVariableRedaction.enabled` | Enable redaction of environment variables that may contain secrets. | `false` |
### Experimental
+2 -2
View File
@@ -792,7 +792,7 @@ their corresponding top-level category object in your `settings.json` file.
- **`security.folderTrust.enabled`** (boolean):
- **Description:** Setting to track whether Folder trust is enabled.
- **Default:** `false`
- **Default:** `true`
- **Requires restart:** Yes
- **`security.environmentVariableRedaction.allowed`** (array):
@@ -866,7 +866,7 @@ their corresponding top-level category object in your `settings.json` file.
- **`experimental.extensionConfig`** (boolean):
- **Description:** Enable requesting and fetching of extension settings.
- **Default:** `false`
- **Default:** `true`
- **Requires restart:** Yes
- **`experimental.enableEventDrivenScheduler`** (boolean):
+64 -7
View File
@@ -1,12 +1,12 @@
{
"name": "@google/gemini-cli",
"version": "0.28.0-nightly.20260128.adc8e11bb",
"version": "0.28.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@google/gemini-cli",
"version": "0.28.0-nightly.20260128.adc8e11bb",
"version": "0.28.0",
"workspaces": [
"packages/*"
],
@@ -26,9 +26,11 @@
"@types/minimatch": "^5.1.2",
"@types/mock-fs": "^4.13.4",
"@types/prompts": "^2.4.9",
"@types/proper-lockfile": "^4.1.4",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@types/shell-quote": "^1.7.5",
"@types/ws": "^8.18.1",
"@vitest/coverage-v8": "^3.1.1",
"@vitest/eslint-plugin": "^1.3.4",
"cross-env": "^7.0.3",
@@ -4102,6 +4104,16 @@
"kleur": "^3.0.3"
}
},
"node_modules/@types/proper-lockfile": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/@types/proper-lockfile/-/proper-lockfile-4.1.4.tgz",
"integrity": "sha512-uo2ABllncSqg9F1D4nugVl9v93RmjxF6LJzQLMLDdPaXCUIDPeOJ21Gbqi43xNKzBi/WQ0Q0dICqufzQbMjipQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/retry": "*"
}
},
"node_modules/@types/qs": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
@@ -4196,6 +4208,13 @@
"node": ">= 0.6"
}
},
"node_modules/@types/retry": {
"version": "0.12.5",
"resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.5.tgz",
"integrity": "sha512-3xSjTp3v03X/lSQLkczaN9UIEwJMoMCA1+Nb5HfbJEQWogdeQIyVtTvxPXDQjZ5zws8rFQfVfRdz03ARihPJgw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/sarif": {
"version": "2.1.7",
"resolved": "https://registry.npmjs.org/@types/sarif/-/sarif-2.1.7.tgz",
@@ -4326,6 +4345,16 @@
"boxen": "^7.1.1"
}
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/yargs": {
"version": "17.0.33",
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz",
@@ -14040,6 +14069,32 @@
"react-is": "^16.13.1"
}
},
"node_modules/proper-lockfile": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz",
"integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==",
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.4",
"retry": "^0.12.0",
"signal-exit": "^3.0.2"
}
},
"node_modules/proper-lockfile/node_modules/retry": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz",
"integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==",
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/proper-lockfile/node_modules/signal-exit": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
"license": "ISC"
},
"node_modules/proto-list": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz",
@@ -17999,7 +18054,7 @@
},
"packages/a2a-server": {
"name": "@google/gemini-cli-a2a-server",
"version": "0.28.0-nightly.20260128.adc8e11bb",
"version": "0.28.0",
"dependencies": {
"@a2a-js/sdk": "^0.3.8",
"@google-cloud/storage": "^7.16.0",
@@ -18055,7 +18110,7 @@
},
"packages/cli": {
"name": "@google/gemini-cli",
"version": "0.28.0-nightly.20260128.adc8e11bb",
"version": "0.28.0",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "^0.12.0",
@@ -18083,6 +18138,7 @@
"mnemonist": "^0.40.3",
"open": "^10.1.2",
"prompts": "^2.4.2",
"proper-lockfile": "^4.1.2",
"react": "^19.2.0",
"read-package-up": "^11.0.0",
"shell-quote": "^1.8.3",
@@ -18142,7 +18198,7 @@
},
"packages/core": {
"name": "@google/gemini-cli-core",
"version": "0.28.0-nightly.20260128.adc8e11bb",
"version": "0.28.0",
"license": "Apache-2.0",
"dependencies": {
"@a2a-js/sdk": "^0.3.8",
@@ -18184,6 +18240,7 @@
"mnemonist": "^0.40.3",
"open": "^10.1.2",
"picomatch": "^4.0.1",
"proper-lockfile": "^4.1.2",
"read-package-up": "^11.0.0",
"shell-quote": "^1.8.3",
"simple-git": "^3.28.0",
@@ -18300,7 +18357,7 @@
},
"packages/test-utils": {
"name": "@google/gemini-cli-test-utils",
"version": "0.28.0-nightly.20260128.adc8e11bb",
"version": "0.28.0",
"license": "Apache-2.0",
"dependencies": {
"@google/gemini-cli-core": "file:../core",
@@ -18317,7 +18374,7 @@
},
"packages/vscode-ide-companion": {
"name": "gemini-cli-vscode-ide-companion",
"version": "0.28.0-nightly.20260128.adc8e11bb",
"version": "0.28.0",
"license": "LICENSE",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.23.0",
+4 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.28.0-nightly.20260128.adc8e11bb",
"version": "0.28.0",
"engines": {
"node": ">=20.0.0"
},
@@ -14,7 +14,7 @@
"url": "git+https://github.com/google-gemini/gemini-cli.git"
},
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.28.0-nightly.20260128.adc8e11bb"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.28.0"
},
"scripts": {
"start": "cross-env NODE_ENV=development node scripts/start.js",
@@ -86,9 +86,11 @@
"@types/minimatch": "^5.1.2",
"@types/mock-fs": "^4.13.4",
"@types/prompts": "^2.4.9",
"@types/proper-lockfile": "^4.1.4",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@types/shell-quote": "^1.7.5",
"@types/ws": "^8.18.1",
"@vitest/coverage-v8": "^3.1.1",
"@vitest/eslint-plugin": "^1.3.4",
"cross-env": "^7.0.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-a2a-server",
"version": "0.28.0-nightly.20260128.adc8e11bb",
"version": "0.28.0",
"description": "Gemini CLI A2A Server",
"repository": {
"type": "git",
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.28.0-nightly.20260128.adc8e11bb",
"version": "0.28.0",
"description": "Gemini CLI",
"license": "Apache-2.0",
"repository": {
@@ -26,7 +26,7 @@
"dist"
],
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.28.0-nightly.20260128.adc8e11bb"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.28.0"
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.12.0",
@@ -54,6 +54,7 @@
"mnemonist": "^0.40.3",
"open": "^10.1.2",
"prompts": "^2.4.2",
"proper-lockfile": "^4.1.2",
"react": "^19.2.0",
"read-package-up": "^11.0.0",
"shell-quote": "^1.8.3",
+83 -4
View File
@@ -139,6 +139,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)
);
}),
};
});
@@ -152,6 +168,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'];
@@ -160,6 +178,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(() => {
@@ -169,6 +199,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', () => {
@@ -247,6 +287,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:
@@ -377,8 +427,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 {
@@ -387,7 +441,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
}
});
});
@@ -1516,14 +1570,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();
});
@@ -1556,12 +1625,12 @@ describe('loadCliConfig folderTrust', () => {
expect(config.getFolderTrust()).toBe(true);
});
it('should be false by default', async () => {
it('should be true by default', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments(createTestMergedSettings());
const settings = createTestMergedSettings();
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.getFolderTrust()).toBe(false);
expect(config.getFolderTrust()).toBe(true);
});
});
@@ -2559,6 +2628,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;
});
+15 -6
View File
@@ -33,12 +33,15 @@ import {
WEB_FETCH_TOOL_NAME,
getVersion,
PREVIEW_GEMINI_MODEL_AUTO,
type HookDefinition,
type HookEventName,
type OutputFormat,
coreEvents,
GEMINI_MODEL_ALIAS_AUTO,
getAdminErrorMessage,
isHeadlessMode,
} from '@google/gemini-cli-core';
import type {
HookDefinition,
HookEventName,
OutputFormat,
} from '@google/gemini-cli-core';
import {
type Settings,
@@ -348,7 +351,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.',
);
@@ -432,7 +435,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
@@ -588,7 +595,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);
@@ -108,6 +108,7 @@ describe('ExtensionManager Settings Scope', () => {
settings: createTestMergedSettings({
telemetry: { enabled: false },
experimental: { extensionConfig: true },
security: { folderTrust: { enabled: false } },
}),
});
@@ -146,6 +147,7 @@ describe('ExtensionManager Settings Scope', () => {
settings: createTestMergedSettings({
telemetry: { enabled: false },
experimental: { extensionConfig: true },
security: { folderTrust: { enabled: false } },
}),
});
@@ -182,6 +184,7 @@ describe('ExtensionManager Settings Scope', () => {
settings: createTestMergedSettings({
telemetry: { enabled: false },
experimental: { extensionConfig: true },
security: { folderTrust: { enabled: false } },
}),
});
+4 -1
View File
@@ -186,7 +186,10 @@ export class ExtensionManager extends ExtensionLoader {
)
) {
const trustedFolders = loadTrustedFolders();
trustedFolders.setValue(this.workspaceDir, TrustLevel.TRUST_FOLDER);
await trustedFolders.setValue(
this.workspaceDir,
TrustLevel.TRUST_FOLDER,
);
} else {
throw new Error(
`Could not install extension because the current workspace at ${this.workspaceDir} is not trusted.`,
@@ -5,23 +5,20 @@
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import * as path from 'node:path';
import * as os from 'node:os';
import * as fs from 'node:fs';
import { getMissingSettings } from './extensionSettings.js';
import type { ExtensionConfig } from '../extension.js';
import { ExtensionStorage } from './storage.js';
import {
KeychainTokenStorage,
debugLogger,
type ExtensionInstallMetadata,
type GeminiCLIExtension,
coreEvents,
} from '@google/gemini-cli-core';
import { EXTENSION_SETTINGS_FILENAME } from './variables.js';
import { ExtensionManager } from '../extension-manager.js';
import { createTestMergedSettings } from '../settings.js';
// --- Mocks ---
vi.mock('node:fs', async (importOriginal) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const actual = await importOriginal<any>();
@@ -29,11 +26,23 @@ vi.mock('node:fs', async (importOriginal) => {
...actual,
default: {
...actual.default,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
existsSync: vi.fn((...args: any[]) => actual.existsSync(...args)),
existsSync: vi.fn(),
statSync: vi.fn(),
lstatSync: vi.fn(),
realpathSync: vi.fn((p) => p),
},
existsSync: vi.fn(),
statSync: vi.fn(),
lstatSync: vi.fn(),
realpathSync: vi.fn((p) => p),
promises: {
...actual.promises,
mkdir: vi.fn(),
writeFile: vi.fn(),
rm: vi.fn(),
cp: vi.fn(),
readFile: vi.fn(),
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
existsSync: vi.fn((...args: any[]) => actual.existsSync(...args)),
};
});
@@ -49,183 +58,93 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
log: vi.fn(),
},
coreEvents: {
emitFeedback: vi.fn(), // Mock emitFeedback
emitFeedback: vi.fn(),
on: vi.fn(),
off: vi.fn(),
emitConsoleLog: vi.fn(),
},
loadSkillsFromDir: vi.fn().mockResolvedValue([]),
loadAgentsFromDirectory: vi
.fn()
.mockResolvedValue({ agents: [], errors: [] }),
};
});
// Mock os.homedir because ExtensionStorage uses it
vi.mock('./consent.js', () => ({
maybeRequestConsentOrFail: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('./extensionSettings.js', async (importOriginal) => {
const actual =
await importOriginal<typeof import('./extensionSettings.js')>();
return {
...actual,
getEnvContents: vi.fn().mockResolvedValue({}),
getMissingSettings: vi.fn(), // We will mock this implementation per test
};
});
vi.mock('../trustedFolders.js', () => ({
isWorkspaceTrusted: vi.fn().mockReturnValue({ isTrusted: true }), // Default to trusted to simplify flow
loadTrustedFolders: vi.fn().mockReturnValue({
setValue: vi.fn().mockResolvedValue(undefined),
}),
TrustLevel: { TRUST_FOLDER: 'TRUST_FOLDER' },
}));
// Mock ExtensionStorage to avoid real FS paths
vi.mock('./storage.js', () => ({
ExtensionStorage: class {
constructor(public name: string) {}
getExtensionDir() {
return `/mock/extensions/${this.name}`;
}
static getUserExtensionsDir() {
return '/mock/extensions';
}
static createTmpDir() {
return Promise.resolve('/mock/tmp');
}
},
}));
vi.mock('os', async (importOriginal) => {
const mockedOs = await importOriginal<typeof os>();
const mockedOs = await importOriginal<typeof import('node:os')>();
return {
...mockedOs,
homedir: vi.fn(),
homedir: vi.fn().mockReturnValue('/mock/home'),
};
});
describe('extensionUpdates', () => {
let tempHomeDir: string;
let tempWorkspaceDir: string;
let extensionDir: string;
let mockKeychainData: Record<string, Record<string, string>>;
beforeEach(() => {
vi.clearAllMocks();
mockKeychainData = {};
// Default fs mocks
vi.mocked(fs.promises.mkdir).mockResolvedValue(undefined);
vi.mocked(fs.promises.writeFile).mockResolvedValue(undefined);
vi.mocked(fs.promises.rm).mockResolvedValue(undefined);
vi.mocked(fs.promises.cp).mockResolvedValue(undefined);
// Mock Keychain
vi.mocked(KeychainTokenStorage).mockImplementation(
(serviceName: string) => {
if (!mockKeychainData[serviceName]) {
mockKeychainData[serviceName] = {};
}
const keychainData = mockKeychainData[serviceName];
return {
getSecret: vi
.fn()
.mockImplementation(
async (key: string) => keychainData[key] || null,
),
setSecret: vi
.fn()
.mockImplementation(async (key: string, value: string) => {
keychainData[key] = value;
}),
deleteSecret: vi.fn().mockImplementation(async (key: string) => {
delete keychainData[key];
}),
listSecrets: vi
.fn()
.mockImplementation(async () => Object.keys(keychainData)),
isAvailable: vi.fn().mockResolvedValue(true),
} as unknown as KeychainTokenStorage;
},
);
// Allow directories to exist by default to satisfy Config/WorkspaceContext checks
vi.mocked(fs.existsSync).mockReturnValue(true);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.mocked(fs.lstatSync).mockReturnValue({ isDirectory: () => true } as any);
vi.mocked(fs.realpathSync).mockImplementation((p) => p as string);
// Setup Temp Dirs
tempHomeDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'gemini-cli-test-home-'),
);
tempWorkspaceDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'gemini-cli-test-workspace-'),
);
extensionDir = path.join(tempHomeDir, '.gemini', 'extensions', 'test-ext');
// Mock ExtensionStorage to rely on our temp extension dir
vi.spyOn(ExtensionStorage.prototype, 'getExtensionDir').mockReturnValue(
extensionDir,
);
// Mock getEnvFilePath is checking extensionDir/variables.env? No, it used ExtensionStorage logic.
// getEnvFilePath in extensionSettings.ts:
// if workspace, process.cwd()/.env (we need to mock process.cwd or move tempWorkspaceDir there)
// if user, ExtensionStorage(name).getEnvFilePath() -> joins extensionDir + '.env'
fs.mkdirSync(extensionDir, { recursive: true });
vi.mocked(os.homedir).mockReturnValue(tempHomeDir);
vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir);
tempWorkspaceDir = '/mock/workspace';
});
afterEach(() => {
fs.rmSync(tempHomeDir, { recursive: true, force: true });
fs.rmSync(tempWorkspaceDir, { recursive: true, force: true });
vi.restoreAllMocks();
});
describe('getMissingSettings', () => {
it('should return empty list if all settings are present', async () => {
const config: ExtensionConfig = {
name: 'test-ext',
version: '1.0.0',
settings: [
{ name: 's1', description: 'd1', envVar: 'VAR1' },
{ name: 's2', description: 'd2', envVar: 'VAR2', sensitive: true },
],
};
const extensionId = '12345';
// Setup User Env
const userEnvPath = path.join(extensionDir, EXTENSION_SETTINGS_FILENAME);
fs.writeFileSync(userEnvPath, 'VAR1=val1');
// Setup Keychain
const userKeychain = new KeychainTokenStorage(
`Gemini CLI Extensions test-ext ${extensionId}`,
);
await userKeychain.setSecret('VAR2', 'val2');
const missing = await getMissingSettings(
config,
extensionId,
tempWorkspaceDir,
);
expect(missing).toEqual([]);
});
it('should identify missing non-sensitive settings', async () => {
const config: ExtensionConfig = {
name: 'test-ext',
version: '1.0.0',
settings: [{ name: 's1', description: 'd1', envVar: 'VAR1' }],
};
const extensionId = '12345';
const missing = await getMissingSettings(
config,
extensionId,
tempWorkspaceDir,
);
expect(missing).toHaveLength(1);
expect(missing[0].name).toBe('s1');
});
it('should identify missing sensitive settings', async () => {
const config: ExtensionConfig = {
name: 'test-ext',
version: '1.0.0',
settings: [
{ name: 's2', description: 'd2', envVar: 'VAR2', sensitive: true },
],
};
const extensionId = '12345';
const missing = await getMissingSettings(
config,
extensionId,
tempWorkspaceDir,
);
expect(missing).toHaveLength(1);
expect(missing[0].name).toBe('s2');
});
it('should respect settings present in workspace', async () => {
const config: ExtensionConfig = {
name: 'test-ext',
version: '1.0.0',
settings: [{ name: 's1', description: 'd1', envVar: 'VAR1' }],
};
const extensionId = '12345';
// Setup Workspace Env
const workspaceEnvPath = path.join(
tempWorkspaceDir,
EXTENSION_SETTINGS_FILENAME,
);
fs.writeFileSync(workspaceEnvPath, 'VAR1=val1');
const missing = await getMissingSettings(
config,
extensionId,
tempWorkspaceDir,
);
expect(missing).toEqual([]);
});
});
describe('ExtensionManager integration', () => {
it('should warn about missing settings after update', async () => {
// Mock ExtensionManager methods to avoid FS/Network usage
// 1. Setup Data
const newConfig: ExtensionConfig = {
name: 'test-ext',
version: '1.1.0',
@@ -239,31 +158,30 @@ describe('extensionUpdates', () => {
};
const installMetadata: ExtensionInstallMetadata = {
source: extensionDir,
source: '/mock/source',
type: 'local',
autoUpdate: true,
};
// 2. Setup Manager
const manager = new ExtensionManager({
workspaceDir: tempWorkspaceDir,
settings: createTestMergedSettings({
telemetry: { enabled: false },
experimental: { extensionConfig: true },
}),
requestConsent: vi.fn().mockResolvedValue(true),
requestSetting: null, // Simulate non-interactive
requestSetting: null,
});
// Mock methods called by installOrUpdateExtension
// 3. Mock Internal Manager Methods
vi.spyOn(manager, 'loadExtensionConfig').mockResolvedValue(newConfig);
vi.spyOn(manager, 'getExtensions').mockReturnValue([
{
name: 'test-ext',
version: '1.0.0',
installMetadata,
path: extensionDir,
// Mocks for other required props
path: '/mock/extensions/test-ext',
contextFiles: [],
mcpServers: {},
hooks: undefined,
@@ -275,23 +193,28 @@ describe('extensionUpdates', () => {
} as unknown as GeminiCLIExtension,
]);
vi.spyOn(manager, 'uninstallExtension').mockResolvedValue(undefined);
// Mock loadExtension to return something so the method doesn't crash at the end
// eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.spyOn(manager as any, 'loadExtension').mockResolvedValue(
{} as unknown as GeminiCLIExtension,
);
vi.spyOn(manager, 'enableExtension').mockResolvedValue(undefined);
vi.spyOn(manager as any, 'loadExtension').mockResolvedValue({
name: 'test-ext',
version: '1.1.0',
} as GeminiCLIExtension);
// Mock fs.promises for the operations inside installOrUpdateExtension
vi.spyOn(fs.promises, 'mkdir').mockResolvedValue(undefined);
vi.spyOn(fs.promises, 'writeFile').mockResolvedValue(undefined);
vi.spyOn(fs.promises, 'rm').mockResolvedValue(undefined);
vi.mocked(fs.existsSync).mockReturnValue(false); // No hooks
try {
await manager.installOrUpdateExtension(installMetadata, previousConfig);
} catch (_) {
// Ignore errors from copyExtension or others, we just want to verify the warning
}
// 4. Mock External Helpers
// This is the key fix: we explicitly mock `getMissingSettings` to return
// the result we expect, avoiding any real FS or logic execution during the update.
vi.mocked(getMissingSettings).mockResolvedValue([
{
name: 's1',
description: 'd1',
envVar: 'VAR1',
},
]);
// 5. Execute
await manager.installOrUpdateExtension(installMetadata, previousConfig);
// 6. Assert
expect(debugLogger.warn).toHaveBeenCalledWith(
expect.stringContaining(
'Extension "test-ext" has missing settings: s1',
+21 -11
View File
@@ -80,6 +80,7 @@ export enum Command {
UNFOCUS_BACKGROUND_SHELL = 'backgroundShell.unfocus',
UNFOCUS_BACKGROUND_SHELL_LIST = 'backgroundShell.listUnfocus',
SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING = 'backgroundShell.unfocusWarning',
SHOW_SHELL_INPUT_UNFOCUS_WARNING = 'shellInput.unfocusWarning',
// App Controls
SHOW_ERROR_DETAILS = 'app.showErrorDetails',
@@ -281,6 +282,7 @@ export const defaultKeyBindings: KeyBindingConfig = {
[Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING]: [
{ key: 'tab', shift: false },
],
[Command.SHOW_SHELL_INPUT_UNFOCUS_WARNING]: [{ key: 'tab', shift: false }],
[Command.BACKGROUND_SHELL_SELECT]: [{ key: 'return' }],
[Command.BACKGROUND_SHELL_ESCAPE]: [{ key: 'escape' }],
[Command.SHOW_MORE_LINES]: [
@@ -288,7 +290,7 @@ export const defaultKeyBindings: KeyBindingConfig = {
{ key: 's', ctrl: true },
],
[Command.FOCUS_SHELL_INPUT]: [{ key: 'tab', shift: false }],
[Command.UNFOCUS_SHELL_INPUT]: [{ key: 'tab' }],
[Command.UNFOCUS_SHELL_INPUT]: [{ key: 'tab', shift: true }],
[Command.CLEAR_SCREEN]: [{ key: 'l', ctrl: true }],
[Command.RESTART_APP]: [{ key: 'r' }],
[Command.SUSPEND_APP]: [{ key: 'z', ctrl: true }],
@@ -405,6 +407,7 @@ export const commandCategories: readonly CommandCategory[] = [
Command.UNFOCUS_BACKGROUND_SHELL,
Command.UNFOCUS_BACKGROUND_SHELL_LIST,
Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING,
Command.SHOW_SHELL_INPUT_UNFOCUS_WARNING,
Command.FOCUS_SHELL_INPUT,
Command.UNFOCUS_SHELL_INPUT,
Command.CLEAR_SCREEN,
@@ -496,16 +499,23 @@ export const commandDescriptions: Readonly<Record<Command, string>> = {
'Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only).',
[Command.SHOW_MORE_LINES]:
'Expand a height-constrained response to show additional lines when not in alternate buffer mode.',
[Command.BACKGROUND_SHELL_SELECT]: 'Enter',
[Command.BACKGROUND_SHELL_ESCAPE]: 'Esc',
[Command.TOGGLE_BACKGROUND_SHELL]: 'Ctrl+B',
[Command.TOGGLE_BACKGROUND_SHELL_LIST]: 'Ctrl+L',
[Command.KILL_BACKGROUND_SHELL]: 'Ctrl+K',
[Command.UNFOCUS_BACKGROUND_SHELL]: 'Shift+Tab',
[Command.UNFOCUS_BACKGROUND_SHELL_LIST]: 'Tab',
[Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING]: 'Tab',
[Command.FOCUS_SHELL_INPUT]: 'Focus the shell input from the gemini input.',
[Command.UNFOCUS_SHELL_INPUT]: 'Focus the Gemini input from the shell input.',
[Command.BACKGROUND_SHELL_SELECT]:
'Confirm selection in background shell list.',
[Command.BACKGROUND_SHELL_ESCAPE]: 'Dismiss background shell list.',
[Command.TOGGLE_BACKGROUND_SHELL]:
'Toggle current background shell visibility.',
[Command.TOGGLE_BACKGROUND_SHELL_LIST]: 'Toggle background shell list.',
[Command.KILL_BACKGROUND_SHELL]: 'Kill the active background shell.',
[Command.UNFOCUS_BACKGROUND_SHELL]:
'Move focus from background shell to Gemini.',
[Command.UNFOCUS_BACKGROUND_SHELL_LIST]:
'Move focus from background shell list to Gemini.',
[Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING]:
'Show warning when trying to unfocus background shell via Tab.',
[Command.SHOW_SHELL_INPUT_UNFOCUS_WARNING]:
'Show warning when trying to unfocus shell input via Tab.',
[Command.FOCUS_SHELL_INPUT]: 'Move focus from Gemini to the active shell.',
[Command.UNFOCUS_SHELL_INPUT]: 'Move focus from the shell back to Gemini.',
[Command.CLEAR_SCREEN]: 'Clear the terminal screen and redraw the UI.',
[Command.RESTART_APP]: 'Restart the application.',
[Command.SUSPEND_APP]: 'Suspend the application (not yet implemented).',
+315 -40
View File
@@ -67,13 +67,14 @@ import {
getSystemSettingsPath,
getSystemDefaultsPath,
type Settings,
saveSettings,
type SettingsFile,
saveSettings,
getDefaultsFromSchema,
loadEnvironment,
migrateDeprecatedSettings,
SettingScope,
LoadedSettings,
sanitizeEnvVar,
} from './settings.js';
import { FatalConfigError, GEMINI_DIR } from '@google/gemini-cli-core';
import { updateSettingsFilePreservingFormat } from '../utils/commentJson.js';
@@ -82,6 +83,7 @@ import {
MergeStrategy,
type SettingsSchema,
} from './settingsSchema.js';
import { createMockSettings } from '../test-utils/settings.js';
const MOCK_WORKSPACE_DIR = '/mock/workspace';
// Use the (mocked) GEMINI_DIR for consistency
@@ -1746,6 +1748,7 @@ describe('Settings Loading and Merging', () => {
isFolderTrustEnabled = true,
isWorkspaceTrustedValue = true as boolean | undefined,
}) {
delete process.env['GEMINI_API_KEY']; // reset
delete process.env['TESTTEST']; // reset
const geminiEnvPath = path.resolve(
path.join(MOCK_WORKSPACE_DIR, GEMINI_DIR, '.env'),
@@ -1779,7 +1782,8 @@ describe('Settings Loading and Merging', () => {
const normalizedP = path.resolve(p.toString());
if (normalizedP === path.resolve(USER_SETTINGS_PATH))
return JSON.stringify(userSettingsContent);
if (normalizedP === geminiEnvPath) return 'TESTTEST=1234';
if (normalizedP === geminiEnvPath)
return 'TESTTEST=1234\nGEMINI_API_KEY=test-key';
return '{}';
},
);
@@ -1793,6 +1797,7 @@ describe('Settings Loading and Merging', () => {
loadEnvironment(settings, MOCK_WORKSPACE_DIR, isWorkspaceTrusted);
expect(process.env['TESTTEST']).toEqual('1234');
expect(process.env['GEMINI_API_KEY']).toEqual('test-key');
});
it('does not load env files from untrusted spaces', () => {
@@ -1817,6 +1822,36 @@ describe('Settings Loading and Merging', () => {
loadEnvironment(settings, MOCK_WORKSPACE_DIR, mockTrustFn);
expect(process.env['TESTTEST']).not.toEqual('1234');
expect(process.env['GEMINI_API_KEY']).not.toEqual('test-key');
});
it('loads whitelisted env files from untrusted spaces if sandboxing is enabled', () => {
setup({ isFolderTrustEnabled: true, isWorkspaceTrustedValue: false });
const settings = loadSettings(MOCK_WORKSPACE_DIR);
settings.merged.tools.sandbox = true;
loadEnvironment(settings.merged, MOCK_WORKSPACE_DIR);
// GEMINI_API_KEY is in the whitelist, so it should be loaded.
expect(process.env['GEMINI_API_KEY']).toEqual('test-key');
// TESTTEST is NOT in the whitelist, so it should be blocked.
expect(process.env['TESTTEST']).not.toEqual('1234');
});
it('loads whitelisted env files from untrusted spaces if sandboxing is enabled via CLI flag', () => {
const originalArgv = [...process.argv];
process.argv.push('-s');
try {
setup({ isFolderTrustEnabled: true, isWorkspaceTrustedValue: false });
const settings = loadSettings(MOCK_WORKSPACE_DIR);
// Ensure sandbox is NOT in settings to test argv sniffing
settings.merged.tools.sandbox = undefined;
loadEnvironment(settings.merged, MOCK_WORKSPACE_DIR);
expect(process.env['GEMINI_API_KEY']).toEqual('test-key');
expect(process.env['TESTTEST']).not.toEqual('1234');
} finally {
process.argv = originalArgv;
}
});
});
@@ -1975,29 +2010,7 @@ describe('Settings Loading and Merging', () => {
},
};
const loadedSettings = new LoadedSettings(
{
path: getSystemSettingsPath(),
settings: {},
originalSettings: {},
},
{
path: getSystemDefaultsPath(),
settings: {},
originalSettings: {},
},
{
path: USER_SETTINGS_PATH,
settings: userSettingsContent as unknown as Settings,
originalSettings: userSettingsContent as unknown as Settings,
},
{
path: MOCK_WORKSPACE_SETTINGS_PATH,
settings: {},
originalSettings: {},
},
true,
);
const loadedSettings = createMockSettings(userSettingsContent);
const setValueSpy = vi.spyOn(loadedSettings, 'setValue');
@@ -2166,11 +2179,8 @@ describe('Settings Loading and Merging', () => {
describe('saveSettings', () => {
it('should save settings using updateSettingsFilePreservingFormat', () => {
const mockUpdateSettings = vi.mocked(updateSettingsFilePreservingFormat);
const settingsFile = {
path: '/mock/settings.json',
settings: { ui: { theme: 'dark' } },
originalSettings: { ui: { theme: 'dark' } },
} as unknown as SettingsFile;
const settingsFile = createMockSettings({ ui: { theme: 'dark' } }).user;
settingsFile.path = '/mock/settings.json';
saveSettings(settingsFile);
@@ -2184,11 +2194,8 @@ describe('Settings Loading and Merging', () => {
const mockFsMkdirSync = vi.mocked(fs.mkdirSync);
mockFsExistsSync.mockReturnValue(false);
const settingsFile = {
path: '/mock/new/dir/settings.json',
settings: {},
originalSettings: {},
} as unknown as SettingsFile;
const settingsFile = createMockSettings({}).user;
settingsFile.path = '/mock/new/dir/settings.json';
saveSettings(settingsFile);
@@ -2205,11 +2212,8 @@ describe('Settings Loading and Merging', () => {
throw error;
});
const settingsFile = {
path: '/mock/settings.json',
settings: {},
originalSettings: {},
} as unknown as SettingsFile;
const settingsFile = createMockSettings({}).user;
settingsFile.path = '/mock/settings.json';
saveSettings(settingsFile);
@@ -2412,6 +2416,277 @@ describe('Settings Loading and Merging', () => {
});
});
});
describe('Security and Sandbox', () => {
let originalArgv: string[];
let originalEnv: NodeJS.ProcessEnv;
beforeEach(() => {
originalArgv = [...process.argv];
originalEnv = { ...process.env };
// Clear relevant env vars
delete process.env['GEMINI_API_KEY'];
delete process.env['GOOGLE_API_KEY'];
delete process.env['GOOGLE_CLOUD_PROJECT'];
delete process.env['GOOGLE_CLOUD_LOCATION'];
delete process.env['CLOUD_SHELL'];
delete process.env['MALICIOUS_VAR'];
delete process.env['FOO'];
vi.resetAllMocks();
vi.mocked(fs.existsSync).mockReturnValue(false);
});
afterEach(() => {
process.argv = originalArgv;
process.env = originalEnv;
});
describe('sandbox detection', () => {
it('should detect sandbox when -s is a real flag', () => {
process.argv = ['node', 'gemini', '-s', 'some prompt'];
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: false,
source: 'file',
});
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue(
'FOO=bar\nGEMINI_API_KEY=secret',
);
loadEnvironment(
createMockSettings({ tools: { sandbox: false } }).merged,
MOCK_WORKSPACE_DIR,
);
// If sandboxed and untrusted, FOO should NOT be loaded, but GEMINI_API_KEY should be.
expect(process.env['FOO']).toBeUndefined();
expect(process.env['GEMINI_API_KEY']).toBe('secret');
});
it('should detect sandbox when --sandbox is a real flag', () => {
process.argv = ['node', 'gemini', '--sandbox', 'prompt'];
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: false,
source: 'file',
});
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue('GEMINI_API_KEY=secret');
loadEnvironment(
createMockSettings({ tools: { sandbox: false } }).merged,
MOCK_WORKSPACE_DIR,
);
expect(process.env['GEMINI_API_KEY']).toBe('secret');
});
it('should ignore sandbox flags if they appear after --', () => {
process.argv = ['node', 'gemini', '--', '-s', 'some prompt'];
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: false,
source: 'file',
});
vi.mocked(fs.existsSync).mockImplementation((path) =>
path.toString().endsWith('.env'),
);
vi.mocked(fs.readFileSync).mockReturnValue('GEMINI_API_KEY=secret');
loadEnvironment(
createMockSettings({ tools: { sandbox: false } }).merged,
MOCK_WORKSPACE_DIR,
);
expect(process.env['GEMINI_API_KEY']).toBeUndefined();
});
it('should NOT be tricked by positional arguments that look like flags', () => {
process.argv = ['node', 'gemini', 'my -s prompt'];
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: false,
source: 'file',
});
vi.mocked(fs.existsSync).mockImplementation((path) =>
path.toString().endsWith('.env'),
);
vi.mocked(fs.readFileSync).mockReturnValue('GEMINI_API_KEY=secret');
loadEnvironment(
createMockSettings({ tools: { sandbox: false } }).merged,
MOCK_WORKSPACE_DIR,
);
expect(process.env['GEMINI_API_KEY']).toBeUndefined();
});
});
describe('env var sanitization', () => {
it('should strictly enforce whitelist in untrusted/sandboxed mode', () => {
process.argv = ['node', 'gemini', '-s', 'prompt'];
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: false,
source: 'file',
});
vi.mocked(fs.existsSync).mockImplementation((path) =>
path.toString().endsWith('.env'),
);
vi.mocked(fs.readFileSync).mockReturnValue(`
GEMINI_API_KEY=secret-key
MALICIOUS_VAR=should-be-ignored
GOOGLE_API_KEY=another-secret
`);
loadEnvironment(
createMockSettings({ tools: { sandbox: false } }).merged,
MOCK_WORKSPACE_DIR,
);
expect(process.env['GEMINI_API_KEY']).toBe('secret-key');
expect(process.env['GOOGLE_API_KEY']).toBe('another-secret');
expect(process.env['MALICIOUS_VAR']).toBeUndefined();
});
it('should sanitize shell injection characters in whitelisted env vars in untrusted mode', () => {
process.argv = ['node', 'gemini', '--sandbox', 'prompt'];
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: false,
source: 'file',
});
vi.mocked(fs.existsSync).mockImplementation((path) =>
path.toString().endsWith('.env'),
);
const maliciousPayload = 'key-$(whoami)-`id`-&|;><*?[]{}';
vi.mocked(fs.readFileSync).mockReturnValue(
`GEMINI_API_KEY=${maliciousPayload}`,
);
loadEnvironment(
createMockSettings({ tools: { sandbox: false } }).merged,
MOCK_WORKSPACE_DIR,
);
// sanitizeEnvVar: value.replace(/[^a-zA-Z0-9\-_./]/g, '')
expect(process.env['GEMINI_API_KEY']).toBe('key-whoami-id-');
});
it('should allow . and / in whitelisted env vars but sanitize other characters in untrusted mode', () => {
process.argv = ['node', 'gemini', '--sandbox', 'prompt'];
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: false,
source: 'file',
});
vi.mocked(fs.existsSync).mockImplementation((path) =>
path.toString().endsWith('.env'),
);
const complexPayload = 'secret-123/path.to/somewhere;rm -rf /';
vi.mocked(fs.readFileSync).mockReturnValue(
`GEMINI_API_KEY=${complexPayload}`,
);
loadEnvironment(
createMockSettings({ tools: { sandbox: false } }).merged,
MOCK_WORKSPACE_DIR,
);
expect(process.env['GEMINI_API_KEY']).toBe(
'secret-123/path.to/somewhererm-rf/',
);
});
it('should NOT sanitize variables from trusted sources', () => {
process.argv = ['node', 'gemini', 'prompt'];
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: true,
source: 'file',
});
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue('FOO=$(bar)');
loadEnvironment(
createMockSettings({ tools: { sandbox: false } }).merged,
MOCK_WORKSPACE_DIR,
);
// Trusted source, no sanitization
expect(process.env['FOO']).toBe('$(bar)');
});
it('should load environment variables normally when workspace is TRUSTED even if "sandboxed"', () => {
process.argv = ['node', 'gemini', '-s', 'prompt'];
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: true,
source: 'file',
});
vi.mocked(fs.existsSync).mockImplementation((path) =>
path.toString().endsWith('.env'),
);
vi.mocked(fs.readFileSync).mockReturnValue(`
GEMINI_API_KEY=un-sanitized;key!
MALICIOUS_VAR=allowed-because-trusted
`);
loadEnvironment(
createMockSettings({ tools: { sandbox: false } }).merged,
MOCK_WORKSPACE_DIR,
);
expect(process.env['GEMINI_API_KEY']).toBe('un-sanitized;key!');
expect(process.env['MALICIOUS_VAR']).toBe('allowed-because-trusted');
});
it('should sanitize value in sanitizeEnvVar helper', () => {
expect(sanitizeEnvVar('$(calc)')).toBe('calc');
expect(sanitizeEnvVar('`rm -rf /`')).toBe('rm-rf/');
expect(sanitizeEnvVar('normal-project-123')).toBe('normal-project-123');
expect(sanitizeEnvVar('us-central1')).toBe('us-central1');
});
});
describe('Cloud Shell security', () => {
it('should handle Cloud Shell special defaults securely when untrusted', () => {
process.env['CLOUD_SHELL'] = 'true';
process.argv = ['node', 'gemini', '-s', 'prompt'];
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: false,
source: 'file',
});
// No .env file
vi.mocked(fs.existsSync).mockReturnValue(false);
loadEnvironment(
createMockSettings({ tools: { sandbox: false } }).merged,
MOCK_WORKSPACE_DIR,
);
expect(process.env['GOOGLE_CLOUD_PROJECT']).toBe('cloudshell-gca');
});
it('should sanitize GOOGLE_CLOUD_PROJECT in Cloud Shell when loaded from .env in untrusted mode', () => {
process.env['CLOUD_SHELL'] = 'true';
process.argv = ['node', 'gemini', '-s', 'prompt'];
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: false,
source: 'file',
});
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue(
'GOOGLE_CLOUD_PROJECT=attacker-project;inject',
);
loadEnvironment(
createMockSettings({ tools: { sandbox: false } }).merged,
MOCK_WORKSPACE_DIR,
);
expect(process.env['GOOGLE_CLOUD_PROJECT']).toBe(
'attacker-projectinject',
);
});
});
});
});
describe('LoadedSettings Isolation and Serializability', () => {
+56 -11
View File
@@ -77,6 +77,21 @@ export const USER_SETTINGS_PATH = Storage.getGlobalSettingsPath();
export const USER_SETTINGS_DIR = path.dirname(USER_SETTINGS_PATH);
export const DEFAULT_EXCLUDED_ENV_VARS = ['DEBUG', 'DEBUG_MODE'];
const AUTH_ENV_VAR_WHITELIST = [
'GEMINI_API_KEY',
'GOOGLE_API_KEY',
'GOOGLE_CLOUD_PROJECT',
'GOOGLE_CLOUD_LOCATION',
];
/**
* Sanitizes an environment variable value to prevent shell injection.
* Restricts values to a safe character set: alphanumeric, -, _, ., /
*/
export function sanitizeEnvVar(value: string): string {
return value.replace(/[^a-zA-Z0-9\-_./]/g, '');
}
export function getSystemSettingsPath(): string {
if (process.env['GEMINI_CLI_SYSTEM_SETTINGS_PATH']) {
return process.env['GEMINI_CLI_SYSTEM_SETTINGS_PATH'];
@@ -439,26 +454,30 @@ function findEnvFile(startDir: string): string | null {
}
}
export function setUpCloudShellEnvironment(envFilePath: string | null): void {
export function setUpCloudShellEnvironment(
envFilePath: string | null,
isTrusted: boolean,
isSandboxed: boolean,
): void {
// Special handling for GOOGLE_CLOUD_PROJECT in Cloud Shell:
// Because GOOGLE_CLOUD_PROJECT in Cloud Shell tracks the project
// set by the user using "gcloud config set project" we do not want to
// use its value. So, unless the user overrides GOOGLE_CLOUD_PROJECT in
// one of the .env files, we set the Cloud Shell-specific default here.
let value = 'cloudshell-gca';
if (envFilePath && fs.existsSync(envFilePath)) {
const envFileContent = fs.readFileSync(envFilePath);
const parsedEnv = dotenv.parse(envFileContent);
if (parsedEnv['GOOGLE_CLOUD_PROJECT']) {
// .env file takes precedence in Cloud Shell
process.env['GOOGLE_CLOUD_PROJECT'] = parsedEnv['GOOGLE_CLOUD_PROJECT'];
} else {
// If not in .env, set to default and override global
process.env['GOOGLE_CLOUD_PROJECT'] = 'cloudshell-gca';
value = parsedEnv['GOOGLE_CLOUD_PROJECT'];
if (!isTrusted && isSandboxed) {
value = sanitizeEnvVar(value);
}
}
} else {
// If no .env file, set to default and override global
process.env['GOOGLE_CLOUD_PROJECT'] = 'cloudshell-gca';
}
process.env['GOOGLE_CLOUD_PROJECT'] = value;
}
export function loadEnvironment(
@@ -469,13 +488,29 @@ export function loadEnvironment(
const envFilePath = findEnvFile(workspaceDir);
const trustResult = isWorkspaceTrustedFn(settings, workspaceDir);
if (trustResult.isTrusted !== true) {
const isTrusted = trustResult.isTrusted ?? false;
// Check settings OR check process.argv directly since this might be called
// before arguments are fully parsed. This is a best-effort sniffing approach
// that happens early in the CLI lifecycle. It is designed to detect the
// sandbox flag before the full command-line parser is initialized to ensure
// security constraints are applied when loading environment variables.
const args = process.argv.slice(2);
const doubleDashIndex = args.indexOf('--');
const relevantArgs =
doubleDashIndex === -1 ? args : args.slice(0, doubleDashIndex);
const isSandboxed =
!!settings.tools?.sandbox ||
relevantArgs.includes('-s') ||
relevantArgs.includes('--sandbox');
if (trustResult.isTrusted !== true && !isSandboxed) {
return;
}
// Cloud Shell environment variable handling
if (process.env['CLOUD_SHELL'] === 'true') {
setUpCloudShellEnvironment(envFilePath);
setUpCloudShellEnvironment(envFilePath, isTrusted, isSandboxed);
}
if (envFilePath) {
@@ -491,6 +526,16 @@ export function loadEnvironment(
for (const key in parsedEnv) {
if (Object.hasOwn(parsedEnv, key)) {
let value = parsedEnv[key];
// If the workspace is untrusted but we are sandboxed, only allow whitelisted variables.
if (!isTrusted && isSandboxed) {
if (!AUTH_ENV_VAR_WHITELIST.includes(key)) {
continue;
}
// Sanitize the value for untrusted sources
value = sanitizeEnvVar(value);
}
// If it's a project .env file, skip loading excluded variables.
if (isProjectEnvFile && excludedVars.includes(key)) {
continue;
@@ -498,7 +543,7 @@ export function loadEnvironment(
// Load variable only if it's not already set in the environment.
if (!Object.hasOwn(process.env, key)) {
process.env[key] = parsedEnv[key];
process.env[key] = value;
}
}
}
@@ -294,7 +294,7 @@ describe('SettingsSchema', () => {
expect(
getSettingsSchema().security.properties.folderTrust.properties.enabled
.default,
).toBe(false);
).toBe(true);
expect(
getSettingsSchema().security.properties.folderTrust.properties.enabled
.showInDialog,
+2 -2
View File
@@ -1312,7 +1312,7 @@ const SETTINGS_SCHEMA = {
label: 'Folder Trust',
category: 'Security',
requiresRestart: true,
default: false,
default: true,
description: 'Setting to track whether Folder trust is enabled.',
showInDialog: true,
},
@@ -1486,7 +1486,7 @@ const SETTINGS_SCHEMA = {
label: 'Extension Configuration',
category: 'Experimental',
requiresRestart: true,
default: false,
default: true,
description: 'Enable requesting and fetching of extension settings.',
showInDialog: false,
},
File diff suppressed because it is too large Load Diff
+98 -21
View File
@@ -6,6 +6,8 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as crypto from 'node:crypto';
import { lock } from 'proper-lockfile';
import {
FatalConfigError,
getErrorMessage,
@@ -13,10 +15,14 @@ import {
ideContextStore,
GEMINI_DIR,
homedir,
isHeadlessMode,
coreEvents,
} from '@google/gemini-cli-core';
import type { Settings } from './settings.js';
import stripJsonComments from 'strip-json-comments';
const { promises: fsPromises } = fs;
export const TRUSTED_FOLDERS_FILENAME = 'trustedFolders.json';
export function getUserSettingsDir(): string {
@@ -67,6 +73,13 @@ export interface TrustResult {
const realPathCache = new Map<string, string>();
/**
* Parses the trusted folders JSON content, stripping comments.
*/
function parseTrustedFoldersJson(content: string): unknown {
return JSON.parse(stripJsonComments(content));
}
/**
* FOR TESTING PURPOSES ONLY.
* Clears the real path cache.
@@ -150,19 +163,67 @@ export class LoadedTrustedFolders {
return undefined;
}
setValue(path: string, trustLevel: TrustLevel): void {
const originalTrustLevel = this.user.config[path];
this.user.config[path] = trustLevel;
async setValue(folderPath: string, trustLevel: TrustLevel): Promise<void> {
if (this.errors.length > 0) {
const errorMessages = this.errors.map(
(error) => `Error in ${error.path}: ${error.message}`,
);
throw new FatalConfigError(
`Cannot update trusted folders because the configuration file is invalid:\n${errorMessages.join('\n')}\nPlease fix the file manually before trying to update it.`,
);
}
const dirPath = path.dirname(this.user.path);
if (!fs.existsSync(dirPath)) {
await fsPromises.mkdir(dirPath, { recursive: true });
}
// lockfile requires the file to exist
if (!fs.existsSync(this.user.path)) {
await fsPromises.writeFile(this.user.path, JSON.stringify({}, null, 2), {
mode: 0o600,
});
}
const release = await lock(this.user.path, {
retries: {
retries: 10,
minTimeout: 100,
},
});
try {
saveTrustedFolders(this.user);
} catch (e) {
// Revert the in-memory change if the save failed.
if (originalTrustLevel === undefined) {
delete this.user.config[path];
} else {
this.user.config[path] = originalTrustLevel;
// Re-read the file to handle concurrent updates
const content = await fsPromises.readFile(this.user.path, 'utf-8');
let config: Record<string, TrustLevel>;
try {
config = parseTrustedFoldersJson(content) as Record<string, TrustLevel>;
} catch (error) {
coreEvents.emitFeedback(
'error',
`Failed to parse trusted folders file at ${this.user.path}. The file may be corrupted.`,
error,
);
config = {};
}
throw e;
const originalTrustLevel = config[folderPath];
config[folderPath] = trustLevel;
this.user.config[folderPath] = trustLevel;
try {
saveTrustedFolders({ ...this.user, config });
} catch (e) {
// Revert the in-memory change if the save failed.
if (originalTrustLevel === undefined) {
delete this.user.config[folderPath];
} else {
this.user.config[folderPath] = originalTrustLevel;
}
throw e;
}
} finally {
await release();
}
}
}
@@ -190,10 +251,7 @@ export function loadTrustedFolders(): LoadedTrustedFolders {
try {
if (fs.existsSync(userPath)) {
const content = fs.readFileSync(userPath, 'utf-8');
const parsed = JSON.parse(stripJsonComments(content)) as Record<
string,
string
>;
const parsed = parseTrustedFoldersJson(content) as Record<string, string>;
if (
typeof parsed !== 'object' ||
@@ -241,16 +299,31 @@ export function saveTrustedFolders(
fs.mkdirSync(dirPath, { recursive: true });
}
fs.writeFileSync(
trustedFoldersFile.path,
JSON.stringify(trustedFoldersFile.config, null, 2),
{ encoding: 'utf-8', mode: 0o600 },
);
const content = JSON.stringify(trustedFoldersFile.config, null, 2);
const tempPath = `${trustedFoldersFile.path}.tmp.${crypto.randomUUID()}`;
try {
fs.writeFileSync(tempPath, content, {
encoding: 'utf-8',
mode: 0o600,
});
fs.renameSync(tempPath, trustedFoldersFile.path);
} catch (error) {
// Clean up temp file if it was created but rename failed
if (fs.existsSync(tempPath)) {
try {
fs.unlinkSync(tempPath);
} catch {
// Ignore cleanup errors
}
}
throw error;
}
}
/** Is folder trust feature enabled per the current applied settings */
export function isFolderTrustEnabled(settings: Settings): boolean {
const folderTrustSetting = settings.security?.folderTrust?.enabled ?? false;
const folderTrustSetting = settings.security?.folderTrust?.enabled ?? true;
return folderTrustSetting;
}
@@ -282,6 +355,10 @@ export function isWorkspaceTrusted(
workspaceDir: string = process.cwd(),
trustConfig?: Record<string, TrustLevel>,
): TrustResult {
if (isHeadlessMode()) {
return { isTrusted: true, source: undefined };
}
if (!isFolderTrustEnabled(settings)) {
return { isTrusted: true, source: undefined };
}
+27 -18
View File
@@ -13,7 +13,7 @@ import {
} from './deferred.js';
import { ExitCodes } from '@google/gemini-cli-core';
import type { ArgumentsCamelCase, CommandModule } from 'yargs';
import type { MergedSettings } from './config/settings.js';
import { createMockSettings } from './test-utils/settings.js';
import type { MockInstance } from 'vitest';
const { mockRunExitCleanup, mockCoreEvents } = vi.hoisted(() => ({
@@ -46,14 +46,9 @@ describe('deferred', () => {
setDeferredCommand(undefined as unknown as DeferredCommand); // Reset deferred command
});
const createMockSettings = (adminSettings: unknown = {}): MergedSettings =>
({
admin: adminSettings,
}) as unknown as MergedSettings;
describe('runDeferredCommand', () => {
it('should do nothing if no deferred command is set', async () => {
await runDeferredCommand(createMockSettings());
await runDeferredCommand(createMockSettings().merged);
expect(mockCoreEvents.emitFeedback).not.toHaveBeenCalled();
expect(mockExit).not.toHaveBeenCalled();
});
@@ -66,7 +61,9 @@ describe('deferred', () => {
commandName: 'mcp',
});
const settings = createMockSettings({ mcp: { enabled: true } });
const settings = createMockSettings({
merged: { admin: { mcp: { enabled: true } } },
}).merged;
await runDeferredCommand(settings);
expect(mockHandler).toHaveBeenCalled();
expect(mockRunExitCleanup).toHaveBeenCalled();
@@ -80,7 +77,9 @@ describe('deferred', () => {
commandName: 'mcp',
});
const settings = createMockSettings({ mcp: { enabled: false } });
const settings = createMockSettings({
merged: { admin: { mcp: { enabled: false } } },
}).merged;
await runDeferredCommand(settings);
expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith(
@@ -98,7 +97,9 @@ describe('deferred', () => {
commandName: 'extensions',
});
const settings = createMockSettings({ extensions: { enabled: false } });
const settings = createMockSettings({
merged: { admin: { extensions: { enabled: false } } },
}).merged;
await runDeferredCommand(settings);
expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith(
@@ -116,7 +117,9 @@ describe('deferred', () => {
commandName: 'skills',
});
const settings = createMockSettings({ skills: { enabled: false } });
const settings = createMockSettings({
merged: { admin: { skills: { enabled: false } } },
}).merged;
await runDeferredCommand(settings);
expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith(
@@ -135,7 +138,7 @@ describe('deferred', () => {
commandName: 'mcp',
});
const settings = createMockSettings({}); // No admin settings
const settings = createMockSettings({}).merged; // No admin settings
await runDeferredCommand(settings);
expect(mockHandler).toHaveBeenCalled();
@@ -163,7 +166,7 @@ describe('deferred', () => {
expect(originalHandler).not.toHaveBeenCalled();
// Now manually run it to verify it captured correctly
await runDeferredCommand(createMockSettings());
await runDeferredCommand(createMockSettings().merged);
expect(originalHandler).toHaveBeenCalledWith(argv);
expect(mockExit).toHaveBeenCalledWith(ExitCodes.SUCCESS);
});
@@ -181,7 +184,9 @@ describe('deferred', () => {
const deferredMcp = defer(commandModule, 'mcp');
await deferredMcp.handler({} as ArgumentsCamelCase);
const mcpSettings = createMockSettings({ mcp: { enabled: false } });
const mcpSettings = createMockSettings({
merged: { admin: { mcp: { enabled: false } } },
}).merged;
await runDeferredCommand(mcpSettings);
expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith(
@@ -205,10 +210,14 @@ describe('deferred', () => {
// confirming it didn't capture 'mcp', 'extensions', or 'skills'
// and defaulted to 'unknown' (or something else safe).
const settings = createMockSettings({
mcp: { enabled: false },
extensions: { enabled: false },
skills: { enabled: false },
});
merged: {
admin: {
mcp: { enabled: false },
extensions: { enabled: false },
skills: { enabled: false },
},
},
}).merged;
await runDeferredCommand(settings);
+2 -15
View File
@@ -10,7 +10,7 @@ import type React from 'react';
import { vi } from 'vitest';
import { act, useState } from 'react';
import os from 'node:os';
import { LoadedSettings, type Settings } from '../config/settings.js';
import { LoadedSettings } from '../config/settings.js';
import { KeypressProvider } from '../ui/contexts/KeypressContext.js';
import { SettingsContext } from '../ui/contexts/SettingsContext.js';
import { ShellFocusContext } from '../ui/contexts/ShellFocusContext.js';
@@ -32,6 +32,7 @@ import { TerminalProvider } from '../ui/contexts/TerminalContext.js';
import { makeFakeConfig, type Config } from '@google/gemini-cli-core';
import { FakePersistentState } from './persistentStateFake.js';
import { AppContext, type AppState } from '../ui/contexts/AppContext.js';
import { createMockSettings } from './settings.js';
export const persistentStateMock = new FakePersistentState();
@@ -135,20 +136,6 @@ export const mockSettings = new LoadedSettings(
[],
);
export const createMockSettings = (
overrides: Partial<Settings>,
): LoadedSettings => {
const settings = overrides as Settings;
return new LoadedSettings(
{ path: '', settings: {}, originalSettings: {} },
{ path: '', settings: {}, originalSettings: {} },
{ path: '', settings, originalSettings: settings },
{ path: '', settings: {}, originalSettings: {} },
true,
[],
);
};
// A minimal mock UIState to satisfy the context provider.
// Tests that need specific UIState values should provide their own.
const baseMockUiState = {
+79
View File
@@ -0,0 +1,79 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
import {
LoadedSettings,
createTestMergedSettings,
type SettingsError,
} from '../config/settings.js';
export interface MockSettingsFile {
settings: any;
originalSettings: any;
path: string;
}
interface CreateMockSettingsOptions {
system?: MockSettingsFile;
systemDefaults?: MockSettingsFile;
user?: MockSettingsFile;
workspace?: MockSettingsFile;
isTrusted?: boolean;
errors?: SettingsError[];
merged?: any;
[key: string]: any;
}
/**
* Creates a mock LoadedSettings object for testing.
*
* @param overrides - Partial settings or LoadedSettings properties to override.
* If 'merged' is provided, it overrides the computed merged settings.
* Any functions in overrides are assigned directly to the LoadedSettings instance.
*/
export const createMockSettings = (
overrides: CreateMockSettingsOptions = {},
): LoadedSettings => {
const {
system,
systemDefaults,
user,
workspace,
isTrusted,
errors,
merged: mergedOverride,
...settingsOverrides
} = overrides;
const loaded = new LoadedSettings(
(system as any) || { path: '', settings: {}, originalSettings: {} },
(systemDefaults as any) || { path: '', settings: {}, originalSettings: {} },
(user as any) || {
path: '',
settings: settingsOverrides,
originalSettings: settingsOverrides,
},
(workspace as any) || { path: '', settings: {}, originalSettings: {} },
isTrusted ?? true,
errors || [],
);
if (mergedOverride) {
// @ts-expect-error - overriding private field for testing
loaded._merged = createTestMergedSettings(mergedOverride);
}
// Assign any function overrides (e.g., vi.fn() for methods)
for (const key in overrides) {
if (typeof overrides[key] === 'function') {
(loaded as any)[key] = overrides[key];
}
}
return loaded;
};
+154
View File
@@ -1940,6 +1940,160 @@ describe('AppContainer State Management', () => {
unmount();
});
});
describe('Focus Handling (Tab / Shift+Tab)', () => {
beforeEach(() => {
// Mock activePtyId to enable focus
mockedUseGeminiStream.mockReturnValue({
...DEFAULT_GEMINI_STREAM_MOCK,
activePtyId: 1,
});
});
it('should focus shell input on Tab', async () => {
await setupKeypressTest();
pressKey({ name: 'tab', shift: false });
expect(capturedUIState.embeddedShellFocused).toBe(true);
unmount();
});
it('should unfocus shell input on Shift+Tab', async () => {
await setupKeypressTest();
// Focus first
pressKey({ name: 'tab', shift: false });
expect(capturedUIState.embeddedShellFocused).toBe(true);
// Unfocus via Shift+Tab
pressKey({ name: 'tab', shift: true });
expect(capturedUIState.embeddedShellFocused).toBe(false);
unmount();
});
it('should auto-unfocus when activePtyId becomes null', async () => {
// Start with active pty and focused
mockedUseGeminiStream.mockReturnValue({
...DEFAULT_GEMINI_STREAM_MOCK,
activePtyId: 1,
});
const renderResult = render(getAppContainer());
await act(async () => {
vi.advanceTimersByTime(0);
});
// Focus it
act(() => {
handleGlobalKeypress({
name: 'tab',
shift: false,
alt: false,
ctrl: false,
cmd: false,
} as Key);
});
expect(capturedUIState.embeddedShellFocused).toBe(true);
// Now mock activePtyId becoming null
mockedUseGeminiStream.mockReturnValue({
...DEFAULT_GEMINI_STREAM_MOCK,
activePtyId: null,
});
// Rerender to trigger useEffect
await act(async () => {
renderResult.rerender(getAppContainer());
});
expect(capturedUIState.embeddedShellFocused).toBe(false);
renderResult.unmount();
});
it('should focus background shell on Tab when already visible (not toggle it off)', async () => {
const mockToggleBackgroundShell = vi.fn();
mockedUseGeminiStream.mockReturnValue({
...DEFAULT_GEMINI_STREAM_MOCK,
activePtyId: null,
isBackgroundShellVisible: true,
backgroundShells: new Map([[123, { pid: 123, status: 'running' }]]),
toggleBackgroundShell: mockToggleBackgroundShell,
});
await setupKeypressTest();
// Initially not focused
expect(capturedUIState.embeddedShellFocused).toBe(false);
// Press Tab
pressKey({ name: 'tab', shift: false });
// Should be focused
expect(capturedUIState.embeddedShellFocused).toBe(true);
// Should NOT have toggled (closed) the shell
expect(mockToggleBackgroundShell).not.toHaveBeenCalled();
unmount();
});
});
describe('Background Shell Toggling (CTRL+B)', () => {
it('should toggle background shell on Ctrl+B even if visible but not focused', async () => {
const mockToggleBackgroundShell = vi.fn();
mockedUseGeminiStream.mockReturnValue({
...DEFAULT_GEMINI_STREAM_MOCK,
activePtyId: null,
isBackgroundShellVisible: true,
backgroundShells: new Map([[123, { pid: 123, status: 'running' }]]),
toggleBackgroundShell: mockToggleBackgroundShell,
});
await setupKeypressTest();
// Initially not focused, but visible
expect(capturedUIState.embeddedShellFocused).toBe(false);
// Press Ctrl+B
pressKey({ name: 'b', ctrl: true });
// Should have toggled (closed) the shell
expect(mockToggleBackgroundShell).toHaveBeenCalled();
// Should be unfocused
expect(capturedUIState.embeddedShellFocused).toBe(false);
unmount();
});
it('should show and focus background shell on Ctrl+B if hidden', async () => {
const mockToggleBackgroundShell = vi.fn();
const geminiStreamMock = {
...DEFAULT_GEMINI_STREAM_MOCK,
activePtyId: null,
isBackgroundShellVisible: false,
backgroundShells: new Map([[123, { pid: 123, status: 'running' }]]),
toggleBackgroundShell: mockToggleBackgroundShell,
};
mockedUseGeminiStream.mockReturnValue(geminiStreamMock);
await setupKeypressTest();
// Update the mock state when toggled to simulate real behavior
mockToggleBackgroundShell.mockImplementation(() => {
geminiStreamMock.isBackgroundShellVisible = true;
});
// Press Ctrl+B
pressKey({ name: 'b', ctrl: true });
// Should have toggled (shown) the shell
expect(mockToggleBackgroundShell).toHaveBeenCalled();
// Should be focused
expect(capturedUIState.embeddedShellFocused).toBe(true);
unmount();
});
});
});
describe('Copy Mode (CTRL+S)', () => {
+57 -66
View File
@@ -1288,24 +1288,26 @@ Logging in with Google... Restarting Gemini CLI to continue.
}, WARNING_PROMPT_DURATION_MS);
}, []);
useEffect(() => {
const handleSelectionWarning = () => {
handleWarning('Press Ctrl-S to enter selection mode to copy text.');
};
const handlePasteTimeout = () => {
handleWarning('Paste Timed out. Possibly due to slow connection.');
};
appEvents.on(AppEvent.SelectionWarning, handleSelectionWarning);
appEvents.on(AppEvent.PasteTimeout, handlePasteTimeout);
return () => {
appEvents.off(AppEvent.SelectionWarning, handleSelectionWarning);
appEvents.off(AppEvent.PasteTimeout, handlePasteTimeout);
// Handle timeout cleanup on unmount
useEffect(
() => () => {
if (warningTimeoutRef.current) {
clearTimeout(warningTimeoutRef.current);
}
if (tabFocusTimeoutRef.current) {
clearTimeout(tabFocusTimeoutRef.current);
}
},
[],
);
useEffect(() => {
const handlePasteTimeout = () => {
handleWarning('Paste Timed out. Possibly due to slow connection.');
};
appEvents.on(AppEvent.PasteTimeout, handlePasteTimeout);
return () => {
appEvents.off(AppEvent.PasteTimeout, handlePasteTimeout);
};
}, [handleWarning]);
@@ -1500,71 +1502,60 @@ Logging in with Google... Restarting Gemini CLI to continue.
setConstrainHeight(false);
return true;
} else if (
keyMatchers[Command.FOCUS_SHELL_INPUT](key) &&
(keyMatchers[Command.FOCUS_SHELL_INPUT](key) ||
keyMatchers[Command.UNFOCUS_BACKGROUND_SHELL_LIST](key)) &&
(activePtyId || (isBackgroundShellVisible && backgroundShells.size > 0))
) {
if (key.name === 'tab' && key.shift) {
// Always change focus
if (embeddedShellFocused) {
const capturedTime = lastOutputTimeRef.current;
if (tabFocusTimeoutRef.current)
clearTimeout(tabFocusTimeoutRef.current);
tabFocusTimeoutRef.current = setTimeout(() => {
if (lastOutputTimeRef.current === capturedTime) {
setEmbeddedShellFocused(false);
} else {
handleWarning('Use Shift+Tab to unfocus');
}
}, 150);
return false;
}
const isIdle = Date.now() - lastOutputTimeRef.current >= 100;
if (isIdle && !activePtyId && !isBackgroundShellVisible) {
if (tabFocusTimeoutRef.current)
clearTimeout(tabFocusTimeoutRef.current);
toggleBackgroundShell();
setEmbeddedShellFocused(true);
if (backgroundShells.size > 1) setIsBackgroundShellListOpen(true);
return true;
}
setEmbeddedShellFocused(true);
return true;
} else if (
keyMatchers[Command.UNFOCUS_SHELL_INPUT](key) ||
keyMatchers[Command.UNFOCUS_BACKGROUND_SHELL](key)
) {
if (embeddedShellFocused) {
setEmbeddedShellFocused(false);
return true;
}
if (embeddedShellFocused) {
handleWarning('Press Shift+Tab to focus out.');
return true;
}
const now = Date.now();
// If the shell hasn't produced output in the last 100ms, it's considered idle.
const isIdle = now - lastOutputTimeRef.current >= 100;
if (isIdle && !activePtyId) {
if (tabFocusTimeoutRef.current) {
clearTimeout(tabFocusTimeoutRef.current);
}
toggleBackgroundShell();
if (!isBackgroundShellVisible) {
// We are about to show it, so focus it
setEmbeddedShellFocused(true);
if (backgroundShells.size > 1) {
setIsBackgroundShellListOpen(true);
}
} else {
// We are about to hide it
tabFocusTimeoutRef.current = setTimeout(() => {
tabFocusTimeoutRef.current = null;
// If the shell produced output since the tab press, we assume it handled the tab
// (e.g. autocomplete) so we should not toggle focus.
if (lastOutputTimeRef.current > now) {
handleWarning('Press Shift+Tab to focus out.');
return;
}
setEmbeddedShellFocused(false);
}, 100);
}
return true;
}
// Not idle, just focus it
setEmbeddedShellFocused(true);
return true;
return false;
} else if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL](key)) {
if (activePtyId) {
backgroundCurrentShell();
// After backgrounding, we explicitly do NOT show or focus the background UI.
} else {
if (isBackgroundShellVisible && !embeddedShellFocused) {
toggleBackgroundShell();
// Toggle focus based on intent: if we were hiding, unfocus; if showing, focus.
if (!isBackgroundShellVisible && backgroundShells.size > 0) {
setEmbeddedShellFocused(true);
} else {
toggleBackgroundShell();
// Toggle focus based on intent: if we were hiding, unfocus; if showing, focus.
if (!isBackgroundShellVisible && backgroundShells.size > 0) {
setEmbeddedShellFocused(true);
if (backgroundShells.size > 1) {
setIsBackgroundShellListOpen(true);
}
} else {
setEmbeddedShellFocused(false);
if (backgroundShells.size > 1) {
setIsBackgroundShellListOpen(true);
}
} else {
setEmbeddedShellFocused(false);
}
}
return true;
@@ -1607,7 +1598,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
],
);
useKeypress(handleGlobalKeypress, { isActive: true });
useKeypress(handleGlobalKeypress, { isActive: true, priority: true });
useEffect(() => {
// Respect hideWindowTitle settings
@@ -86,6 +86,11 @@ describe('directoryCommand', () => {
settings: {
merged: {
memoryDiscoveryMaxDirs: 1000,
security: {
folderTrust: {
enabled: false,
},
},
},
},
},
@@ -405,55 +405,4 @@ describe('<BackgroundShellDisplay />', () => {
expect(lastFrame()).toMatchSnapshot();
});
it('unfocuses the shell when Shift+Tab is pressed', async () => {
render(
<ScrollProvider>
<BackgroundShellDisplay
shells={mockShells}
activePid={shell1.pid}
width={80}
height={24}
isFocused={true}
isListOpenProp={false}
/>
</ScrollProvider>,
);
await act(async () => {
await delay(0);
});
act(() => {
simulateKey({ name: 'tab', shift: true });
});
expect(mockSetEmbeddedShellFocused).toHaveBeenCalledWith(false);
});
it('shows a warning when Tab is pressed', async () => {
render(
<ScrollProvider>
<BackgroundShellDisplay
shells={mockShells}
activePid={shell1.pid}
width={80}
height={24}
isFocused={true}
isListOpenProp={false}
/>
</ScrollProvider>,
);
await act(async () => {
await delay(0);
});
act(() => {
simulateKey({ name: 'tab' });
});
expect(mockHandleWarning).toHaveBeenCalledWith(
'Press Shift+Tab to focus out.',
);
expect(mockSetEmbeddedShellFocused).not.toHaveBeenCalled();
});
});
@@ -18,7 +18,7 @@ import { cpLen, cpSlice, getCachedStringWidth } from '../utils/textUtils.js';
import { type BackgroundShell } from '../hooks/shellCommandProcessor.js';
import { Command, keyMatchers } from '../keyMatchers.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { commandDescriptions } from '../../config/keyBindings.js';
import { formatCommand } from '../utils/keybindingUtils.js';
import {
ScrollableList,
type ScrollableListRef,
@@ -64,8 +64,6 @@ export const BackgroundShellDisplay = ({
dismissBackgroundShell,
setActiveBackgroundShellPid,
setIsBackgroundShellListOpen,
handleWarning,
setEmbeddedShellFocused,
} = useUIActions();
const activeShell = shells.get(activePid);
const [output, setOutput] = useState<string | AnsiOutput>(
@@ -138,27 +136,6 @@ export const BackgroundShellDisplay = ({
(key) => {
if (!activeShell) return;
// Handle Shift+Tab or Tab (in list) to focus out
if (
keyMatchers[Command.UNFOCUS_BACKGROUND_SHELL](key) ||
(isListOpenProp &&
keyMatchers[Command.UNFOCUS_BACKGROUND_SHELL_LIST](key))
) {
setEmbeddedShellFocused(false);
return true;
}
// Handle Tab to warn but propagate
if (
!isListOpenProp &&
keyMatchers[Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING](key)
) {
handleWarning(
`Press ${commandDescriptions[Command.UNFOCUS_BACKGROUND_SHELL]} to focus out.`,
);
// Fall through to allow Tab to be sent to the shell
}
if (isListOpenProp) {
// Navigation (Up/Down/Enter) is handled by RadioButtonSelect
// We only handle special keys not consumed by RadioButtonSelect or overriding them if needed
@@ -188,7 +165,7 @@ export const BackgroundShellDisplay = ({
}
if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL](key)) {
return true;
return false;
}
if (keyMatchers[Command.KILL_BACKGROUND_SHELL](key)) {
@@ -216,7 +193,27 @@ export const BackgroundShellDisplay = ({
{ isActive: isFocused && !!activeShell },
);
const helpText = `${commandDescriptions[Command.TOGGLE_BACKGROUND_SHELL]} Hide | ${commandDescriptions[Command.KILL_BACKGROUND_SHELL]} Kill | ${commandDescriptions[Command.TOGGLE_BACKGROUND_SHELL_LIST]} List`;
const helpTextParts = [
{ label: 'Close', command: Command.TOGGLE_BACKGROUND_SHELL },
{ label: 'Kill', command: Command.KILL_BACKGROUND_SHELL },
{ label: 'List', command: Command.TOGGLE_BACKGROUND_SHELL_LIST },
];
const helpTextStr = helpTextParts
.map((p) => `${p.label} (${formatCommand(p.command)})`)
.join(' | ');
const renderHelpText = () => (
<Text>
{helpTextParts.map((p, i) => (
<Text key={p.label}>
{i > 0 ? ' | ' : ''}
{p.label} (
<Text color={theme.text.accent}>{formatCommand(p.command)}</Text>)
</Text>
))}
</Text>
);
const renderTabs = () => {
const shellList = Array.from(shells.values()).filter(
@@ -230,7 +227,7 @@ export const BackgroundShellDisplay = ({
const availableWidth =
width -
TAB_DISPLAY_HORIZONTAL_PADDING -
getCachedStringWidth(helpText) -
getCachedStringWidth(helpTextStr) -
pidInfoWidth;
let currentWidth = 0;
@@ -272,7 +269,7 @@ export const BackgroundShellDisplay = ({
}
if (shellList.length > tabs.length && !isListOpenProp) {
const overflowLabel = ` ... (${commandDescriptions[Command.TOGGLE_BACKGROUND_SHELL_LIST]}) `;
const overflowLabel = ` ... (${formatCommand(Command.TOGGLE_BACKGROUND_SHELL_LIST)}) `;
const overflowWidth = getCachedStringWidth(overflowLabel);
// If we only have one tab, ensure we don't show the overflow if it's too cramped
@@ -324,7 +321,7 @@ export const BackgroundShellDisplay = ({
<Box flexDirection="column" height="100%" width="100%">
<Box flexShrink={0} marginBottom={1} paddingTop={1}>
<Text bold>
{`Select Process (${commandDescriptions[Command.BACKGROUND_SHELL_SELECT]} to select, ${commandDescriptions[Command.BACKGROUND_SHELL_ESCAPE]} to cancel):`}
{`Select Process (${formatCommand(Command.BACKGROUND_SHELL_SELECT)} to select, ${formatCommand(Command.KILL_BACKGROUND_SHELL)} to kill, ${formatCommand(Command.BACKGROUND_SHELL_ESCAPE)} to cancel):`}
</Text>
</Box>
<Box flexGrow={1} width="100%">
@@ -450,7 +447,7 @@ export const BackgroundShellDisplay = ({
(PID: {activeShell?.pid}) {isFocused ? '(Focused)' : ''}
</Text>
</Box>
<Text color={theme.text.accent}>{helpText}</Text>
{renderHelpText()}
</Box>
<Box flexGrow={1} overflow="hidden" paddingX={CONTENT_PADDING_X}>
{isListOpenProp ? renderProcessList() : renderOutput()}
@@ -4,10 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {
renderWithProviders,
createMockSettings,
} from '../../test-utils/render.js';
import { renderWithProviders } from '../../test-utils/render.js';
import { createMockSettings } from '../../test-utils/settings.js';
import { CliSpinner } from './CliSpinner.js';
import { debugState } from '../debug.js';
import { describe, it, expect, beforeEach } from 'vitest';
@@ -15,6 +15,7 @@ import {
} from '../contexts/UIActionsContext.js';
import { ConfigContext } from '../contexts/ConfigContext.js';
import { SettingsContext } from '../contexts/SettingsContext.js';
import { createMockSettings } from '../../test-utils/settings.js';
// Mock VimModeContext hook
vi.mock('../contexts/VimModeContext.js', () => ({
useVimMode: vi.fn(() => ({
@@ -24,7 +25,6 @@ vi.mock('../contexts/VimModeContext.js', () => ({
}));
import { ApprovalMode } from '@google/gemini-cli-core';
import { StreamingState } from '../types.js';
import { mergeSettings } from '../../config/settings.js';
// Mock child components
vi.mock('./LoadingIndicator.js', () => ({
@@ -168,21 +168,6 @@ const createMockConfig = (overrides = {}) => ({
...overrides,
});
const createMockSettings = (merged = {}) => {
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
return {
merged: {
...defaultMergedSettings,
ui: {
...defaultMergedSettings.ui,
hideFooter: false,
showMemoryUsage: false,
...merged,
},
},
};
};
/* eslint-disable @typescript-eslint/no-explicit-any */
const renderComposer = (
uiState: UIState,
@@ -207,7 +192,7 @@ describe('Composer', () => {
describe('Footer Display Settings', () => {
it('renders Footer by default when hideFooter is false', () => {
const uiState = createMockUIState();
const settings = createMockSettings({ hideFooter: false });
const settings = createMockSettings({ ui: { hideFooter: false } });
const { lastFrame } = renderComposer(uiState, settings);
@@ -216,7 +201,7 @@ describe('Composer', () => {
it('does NOT render Footer when hideFooter is true', () => {
const uiState = createMockUIState();
const settings = createMockSettings({ hideFooter: true });
const settings = createMockSettings({ ui: { hideFooter: true } });
const { lastFrame } = renderComposer(uiState, settings);
@@ -245,8 +230,10 @@ describe('Composer', () => {
getDebugMode: vi.fn(() => true),
});
const settings = createMockSettings({
hideFooter: false,
showMemoryUsage: true,
ui: {
hideFooter: false,
showMemoryUsage: true,
},
});
// Mock vim mode for this test
const { useVimMode } = await import('../contexts/VimModeContext.js');
@@ -67,7 +67,7 @@ describe('ConsentPrompt', () => {
unmount();
});
it('calls onConfirm with true when "Yes" is selected', () => {
it('calls onConfirm with true when "Yes" is selected', async () => {
const prompt = 'Are you sure?';
const { unmount } = render(
<ConsentPrompt
@@ -78,7 +78,7 @@ describe('ConsentPrompt', () => {
);
const onSelect = MockedRadioButtonSelect.mock.calls[0][0].onSelect;
act(() => {
await act(async () => {
onSelect(true);
});
@@ -86,7 +86,7 @@ describe('ConsentPrompt', () => {
unmount();
});
it('calls onConfirm with false when "No" is selected', () => {
it('calls onConfirm with false when "No" is selected', async () => {
const prompt = 'Are you sure?';
const { unmount } = render(
<ConsentPrompt
@@ -97,7 +97,7 @@ describe('ConsentPrompt', () => {
);
const onSelect = MockedRadioButtonSelect.mock.calls[0][0].onSelect;
act(() => {
await act(async () => {
onSelect(false);
});
@@ -101,9 +101,7 @@ describe('FolderTrustDialog', () => {
);
// Unmount immediately (before 250ms)
act(() => {
unmount();
});
unmount();
await vi.advanceTimersByTimeAsync(250);
expect(relaunchApp).not.toHaveBeenCalled();
@@ -5,10 +5,8 @@
*/
import { describe, it, expect, vi } from 'vitest';
import {
renderWithProviders,
createMockSettings,
} from '../../test-utils/render.js';
import { renderWithProviders } from '../../test-utils/render.js';
import { createMockSettings } from '../../test-utils/settings.js';
import { Footer } from './Footer.js';
import { tildeifyPath, ToolCallDecision } from '@google/gemini-cli-core';
import type { SessionStatsState } from '../contexts/SessionContext.js';
@@ -4,10 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {
renderWithProviders,
createMockSettings,
} from '../../test-utils/render.js';
import { renderWithProviders } from '../../test-utils/render.js';
import { createMockSettings } from '../../test-utils/settings.js';
import { waitFor } from '../../test-utils/async.js';
import { act, useState } from 'react';
import type { InputPromptProps } from './InputPrompt.js';
@@ -924,15 +924,19 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
return true;
}
if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL](key)) {
return false;
}
if (keyMatchers[Command.FOCUS_SHELL_INPUT](key)) {
// If we got here, Autocomplete didn't handle the key (e.g. no suggestions).
if (
activePtyId ||
(backgroundShells.size > 0 && backgroundShellHeight > 0)
) {
setEmbeddedShellFocused(true);
return true;
}
return true;
return false;
}
// Fall back to the text buffer's default input handling for all other keys
@@ -46,22 +46,26 @@ describe('LogoutConfirmationDialog', () => {
expect(mockCall.isFocused).toBe(true);
});
it('should call onSelect with LOGIN when Login is selected', () => {
it('should call onSelect with LOGIN when Login is selected', async () => {
const onSelect = vi.fn();
renderWithProviders(<LogoutConfirmationDialog onSelect={onSelect} />);
const mockCall = vi.mocked(RadioButtonSelect).mock.calls[0][0];
mockCall.onSelect(LogoutChoice.LOGIN);
await act(async () => {
mockCall.onSelect(LogoutChoice.LOGIN);
});
expect(onSelect).toHaveBeenCalledWith(LogoutChoice.LOGIN);
});
it('should call onSelect with EXIT when Exit is selected', () => {
it('should call onSelect with EXIT when Exit is selected', async () => {
const onSelect = vi.fn();
renderWithProviders(<LogoutConfirmationDialog onSelect={onSelect} />);
const mockCall = vi.mocked(RadioButtonSelect).mock.calls[0][0];
mockCall.onSelect(LogoutChoice.EXIT);
await act(async () => {
mockCall.onSelect(LogoutChoice.EXIT);
});
expect(onSelect).toHaveBeenCalledWith(LogoutChoice.EXIT);
});
@@ -125,7 +125,10 @@ export const MultiFolderTrustDialog: React.FC<MultiFolderTrustDialogProps> = ({
try {
const expandedPath = path.resolve(expandHomeDir(dir));
if (choice === MultiFolderTrustChoice.YES_AND_REMEMBER) {
trustedFolders.setValue(expandedPath, TrustLevel.TRUST_FOLDER);
await trustedFolders.setValue(
expandedPath,
TrustLevel.TRUST_FOLDER,
);
}
workspaceContext.addDirectory(expandedPath);
added.push(dir);
@@ -69,13 +69,14 @@ export function PermissionsModifyTrustDialog({
return true;
}
if (needsRestart && key.name === 'r') {
const success = commitTrustLevelChange();
if (success) {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
relaunchApp();
} else {
onExit();
}
void (async () => {
const success = await commitTrustLevelChange();
if (success) {
void relaunchApp();
} else {
onExit();
}
})();
return true;
}
return false;
@@ -26,6 +26,7 @@ import { waitFor } from '../../test-utils/async.js';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { SettingsDialog } from './SettingsDialog.js';
import { LoadedSettings, SettingScope } from '../../config/settings.js';
import { createMockSettings } from '../../test-utils/settings.js';
import { VimModeProvider } from '../contexts/VimModeContext.js';
import { KeypressProvider } from '../contexts/KeypressContext.js';
import { act } from 'react';
@@ -58,56 +59,6 @@ enum TerminalKeys {
BACKSPACE = '\u0008',
}
const createMockSettings = (
userSettings = {},
systemSettings = {},
workspaceSettings = {},
) =>
new LoadedSettings(
{
settings: { ui: { customThemes: {} }, mcpServers: {}, ...systemSettings },
originalSettings: {
ui: { customThemes: {} },
mcpServers: {},
...systemSettings,
},
path: '/system/settings.json',
},
{
settings: {},
originalSettings: {},
path: '/system/system-defaults.json',
},
{
settings: {
ui: { customThemes: {} },
mcpServers: {},
...userSettings,
},
originalSettings: {
ui: { customThemes: {} },
mcpServers: {},
...userSettings,
},
path: '/user/settings.json',
},
{
settings: {
ui: { customThemes: {} },
mcpServers: {},
...workspaceSettings,
},
originalSettings: {
ui: { customThemes: {} },
mcpServers: {},
...workspaceSettings,
},
path: '/workspace/settings.json',
},
true,
[],
);
vi.mock('../../config/settingsSchema.js', async (importOriginal) => {
const original =
await importOriginal<typeof import('../../config/settingsSchema.js')>();
@@ -639,11 +590,23 @@ describe('SettingsDialog', () => {
});
it('should show different values for different scopes', () => {
const settings = createMockSettings(
{ vimMode: true }, // User settings
{ vimMode: false }, // System settings
{ autoUpdate: false }, // Workspace settings
);
const settings = createMockSettings({
user: {
settings: { vimMode: true },
originalSettings: { vimMode: true },
path: '',
},
system: {
settings: { vimMode: false },
originalSettings: { vimMode: false },
path: '',
},
workspace: {
settings: { autoUpdate: false },
originalSettings: { autoUpdate: false },
path: '',
},
});
const onSelect = vi.fn();
const { lastFrame } = renderDialog(settings, onSelect);
@@ -733,11 +696,23 @@ describe('SettingsDialog', () => {
describe('Specific Settings Behavior', () => {
it('should show correct display values for settings with different states', () => {
const settings = createMockSettings(
{ vimMode: true, hideTips: false }, // User settings
{ hideWindowTitle: true }, // System settings
{ ideMode: false }, // Workspace settings
);
const settings = createMockSettings({
user: {
settings: { vimMode: true, hideTips: false },
originalSettings: { vimMode: true, hideTips: false },
path: '',
},
system: {
settings: { hideWindowTitle: true },
originalSettings: { hideWindowTitle: true },
path: '',
},
workspace: {
settings: { ideMode: false },
originalSettings: { ideMode: false },
path: '',
},
});
const onSelect = vi.fn();
const { lastFrame } = renderDialog(settings, onSelect);
@@ -794,11 +769,13 @@ describe('SettingsDialog', () => {
describe('Settings Display Values', () => {
it('should show correct values for inherited settings', () => {
const settings = createMockSettings(
{},
{ vimMode: true, hideWindowTitle: false }, // System settings
{},
);
const settings = createMockSettings({
system: {
settings: { vimMode: true, hideWindowTitle: false },
originalSettings: { vimMode: true, hideWindowTitle: false },
path: '',
},
});
const onSelect = vi.fn();
const { lastFrame } = renderDialog(settings, onSelect);
@@ -809,11 +786,18 @@ describe('SettingsDialog', () => {
});
it('should show override indicator for overridden settings', () => {
const settings = createMockSettings(
{ vimMode: false }, // User overrides
{ vimMode: true }, // System default
{},
);
const settings = createMockSettings({
user: {
settings: { vimMode: false },
originalSettings: { vimMode: false },
path: '',
},
system: {
settings: { vimMode: true },
originalSettings: { vimMode: true },
path: '',
},
});
const onSelect = vi.fn();
const { lastFrame } = renderDialog(settings, onSelect);
@@ -983,11 +967,13 @@ describe('SettingsDialog', () => {
describe('Error Recovery', () => {
it('should handle malformed settings gracefully', () => {
// Create settings with potentially problematic values
const settings = createMockSettings(
{ vimMode: null as unknown as boolean }, // Invalid value
{},
{},
);
const settings = createMockSettings({
user: {
settings: { vimMode: null as unknown as boolean },
originalSettings: { vimMode: null as unknown as boolean },
path: '',
},
});
const onSelect = vi.fn();
const { lastFrame } = renderDialog(settings, onSelect);
@@ -1198,11 +1184,13 @@ describe('SettingsDialog', () => {
stdin.write('\r'); // Commit
});
settings = createMockSettings(
{ 'a.string.setting': 'new value' },
{},
{},
);
settings = createMockSettings({
user: {
settings: { 'a.string.setting': 'new value' },
originalSettings: { 'a.string.setting': 'new value' },
path: '',
},
});
rerender(
<KeypressProvider>
<SettingsDialog settings={settings} onSelect={onSelect} />
@@ -1550,11 +1538,23 @@ describe('SettingsDialog', () => {
])(
'should render $name correctly',
({ userSettings, systemSettings, workspaceSettings, stdinActions }) => {
const settings = createMockSettings(
userSettings,
systemSettings,
workspaceSettings,
);
const settings = createMockSettings({
user: {
settings: userSettings,
originalSettings: userSettings,
path: '',
},
system: {
settings: systemSettings,
originalSettings: systemSettings,
path: '',
},
workspace: {
settings: workspaceSettings,
originalSettings: workspaceSettings,
path: '',
},
});
const onSelect = vi.fn();
const { lastFrame, stdin } = renderDialog(settings, onSelect);
@@ -8,6 +8,12 @@ import { render } from '../../test-utils/render.js';
import { ShellInputPrompt } from './ShellInputPrompt.js';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ShellExecutionService } from '@google/gemini-cli-core';
import { useUIActions, type UIActions } from '../contexts/UIActionsContext.js';
// Mock useUIActions
vi.mock('../contexts/UIActionsContext.js', () => ({
useUIActions: vi.fn(),
}));
// Mock useKeypress
const mockUseKeypress = vi.fn();
@@ -31,9 +37,13 @@ vi.mock('@google/gemini-cli-core', async () => {
describe('ShellInputPrompt', () => {
const mockWriteToPty = vi.mocked(ShellExecutionService.writeToPty);
const mockScrollPty = vi.mocked(ShellExecutionService.scrollPty);
const mockHandleWarning = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(useUIActions).mockReturnValue({
handleWarning: mockHandleWarning,
} as Partial<UIActions> as UIActions);
});
it('renders nothing', () => {
@@ -43,6 +53,23 @@ describe('ShellInputPrompt', () => {
expect(lastFrame()).toBe('');
});
it('sends tab to pty', () => {
render(<ShellInputPrompt activeShellPtyId={1} focus={true} />);
const handler = mockUseKeypress.mock.calls[0][0];
handler({
name: 'tab',
shift: false,
alt: false,
ctrl: false,
cmd: false,
sequence: '\t',
});
expect(mockWriteToPty).toHaveBeenCalledWith(1, '\t');
});
it.each([
['a', 'a'],
['b', 'b'],
@@ -40,6 +40,11 @@ export const ShellInputPrompt: React.FC<ShellInputPromptProps> = ({
return false;
}
// Allow unfocus to bubble up
if (keyMatchers[Command.UNFOCUS_SHELL_INPUT](key)) {
return false;
}
if (key.ctrl && key.shift && key.name === 'up') {
ShellExecutionService.scrollPty(activeShellPtyId, -1);
return true;
@@ -11,6 +11,7 @@ import { StatusDisplay } from './StatusDisplay.js';
import { UIStateContext, type UIState } from '../contexts/UIStateContext.js';
import { ConfigContext } from '../contexts/ConfigContext.js';
import { SettingsContext } from '../contexts/SettingsContext.js';
import { createMockSettings } from '../../test-utils/settings.js';
import type { TextBuffer } from './shared/text-buffer.js';
// Mock child components to simplify testing
@@ -65,14 +66,6 @@ const createMockConfig = (overrides = {}) => ({
...overrides,
});
const createMockSettings = (merged = {}) => ({
merged: {
hooksConfig: { notifications: true },
ui: { hideContextSummary: false },
...merged,
},
});
/* eslint-disable @typescript-eslint/no-explicit-any */
const renderStatusDisplay = (
props: { hideContextSummary: boolean } = { hideContextSummary: false },
@@ -8,52 +8,10 @@ import { renderWithProviders } from '../../test-utils/render.js';
import { waitFor } from '../../test-utils/async.js';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { ThemeDialog } from './ThemeDialog.js';
import { LoadedSettings } from '../../config/settings.js';
import { createMockSettings } from '../../test-utils/settings.js';
import { DEFAULT_THEME, themeManager } from '../themes/theme-manager.js';
import { act } from 'react';
const createMockSettings = (
userSettings = {},
workspaceSettings = {},
systemSettings = {},
): LoadedSettings =>
new LoadedSettings(
{
settings: { ui: { customThemes: {} }, ...systemSettings },
originalSettings: { ui: { customThemes: {} }, ...systemSettings },
path: '/system/settings.json',
},
{
settings: {},
originalSettings: {},
path: '/system/system-defaults.json',
},
{
settings: {
ui: { customThemes: {} },
...userSettings,
},
originalSettings: {
ui: { customThemes: {} },
...userSettings,
},
path: '/user/settings.json',
},
{
settings: {
ui: { customThemes: {} },
...workspaceSettings,
},
originalSettings: {
ui: { customThemes: {} },
...workspaceSettings,
},
path: '/workspace/settings.json',
},
true,
[],
);
describe('ThemeDialog Snapshots', () => {
const baseProps = {
onSelect: vi.fn(),
@@ -2,16 +2,16 @@
exports[`<BackgroundShellDisplay /> > highlights the focused state 1`] = `
"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
│ 1: npm star... (PID: 1001) (Focused) Ctrl+B Hide | Ctrl+K Kill | Ctrl+L List │
│ 1: npm sta... (PID: 1001) (Focused) Close (Ctrl+B) | Kill (Ctrl+K) | List (Ctrl+L)
│ Starting server... │
└──────────────────────────────────────────────────────────────────────────────────────────────────┘"
`;
exports[`<BackgroundShellDisplay /> > keeps exit code status color even when selected 1`] = `
"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
│ 1: npm star... (PID: 1003) (Focused) Ctrl+B Hide | Ctrl+K Kill | Ctrl+L List │
│ 1: npm sta... (PID: 1003) (Focused) Close (Ctrl+B) | Kill (Ctrl+K) | List (Ctrl+L)
│ │
│ Select Process (Enter to select, Esc to cancel):
│ Select Process (Enter to select, Ctrl+K to kill, Esc to cancel):
│ │
│ 1. npm start (PID: 1001) │
│ 2. tail -f log.txt (PID: 1002) │
@@ -21,23 +21,23 @@ exports[`<BackgroundShellDisplay /> > keeps exit code status color even when sel
exports[`<BackgroundShellDisplay /> > renders tabs for multiple shells 1`] = `
"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
│ 1: npm start 2: tail -f log.txt (PID: 1001) Ctrl+B Hide | Ctrl+K Kill | Ctrl+L List │
│ 1: npm start 2: tail -f lo... (PID: 1001) Close (Ctrl+B) | Kill (Ctrl+K) | List (Ctrl+L)
│ Starting server... │
└──────────────────────────────────────────────────────────────────────────────────────────────────┘"
`;
exports[`<BackgroundShellDisplay /> > renders the output of the active shell 1`] = `
"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
│ 1: npm ... 2: tail... (PID: 1001) Ctrl+B Hide | Ctrl+K Kill | Ctrl+L List │
│ 1: ... 2: ... (PID: 1001) Close (Ctrl+B) | Kill (Ctrl+K) | List (Ctrl+L)
│ Starting server... │
└──────────────────────────────────────────────────────────────────────────────────────────────────┘"
`;
exports[`<BackgroundShellDisplay /> > renders the process list when isListOpenProp is true 1`] = `
"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
│ 1: npm star... (PID: 1001) (Focused) Ctrl+B Hide | Ctrl+K Kill | Ctrl+L List │
│ 1: npm sta... (PID: 1001) (Focused) Close (Ctrl+B) | Kill (Ctrl+K) | List (Ctrl+L)
│ │
│ Select Process (Enter to select, Esc to cancel):
│ Select Process (Enter to select, Ctrl+K to kill, Esc to cancel):
│ │
│ ● 1. npm start (PID: 1001) │
│ 2. tail -f log.txt (PID: 1002) │
@@ -46,9 +46,9 @@ exports[`<BackgroundShellDisplay /> > renders the process list when isListOpenPr
exports[`<BackgroundShellDisplay /> > scrolls to active shell when list opens 1`] = `
"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
│ 1: npm star... (PID: 1002) (Focused) Ctrl+B Hide | Ctrl+K Kill | Ctrl+L List │
│ 1: npm sta... (PID: 1002) (Focused) Close (Ctrl+B) | Kill (Ctrl+K) | List (Ctrl+L)
│ │
│ Select Process (Enter to select, Esc to cancel):
│ Select Process (Enter to select, Ctrl+K to kill, Esc to cancel):
│ │
│ 1. npm start (PID: 1001) │
│ ● 2. tail -f log.txt (PID: 1002) │
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import React, { act } from 'react';
import React from 'react';
import {
ShellToolMessage,
type ShellToolMessageProps,
@@ -77,16 +77,6 @@ describe('<ShellToolMessage />', () => {
setEmbeddedShellFocused: mockSetEmbeddedShellFocused,
};
// Helper to render with context
const renderWithContext = (
ui: React.ReactElement,
streamingState: StreamingState,
) =>
renderWithProviders(ui, {
uiActions,
uiState: { streamingState },
});
beforeEach(() => {
vi.clearAllMocks();
});
@@ -140,40 +130,5 @@ describe('<ShellToolMessage />', () => {
expect(mockSetEmbeddedShellFocused).toHaveBeenCalledWith(true);
});
});
it('resets focus when shell finishes', async () => {
let updateStatus: (s: ToolCallStatus) => void = () => {};
const Wrapper = () => {
const [status, setStatus] = React.useState(ToolCallStatus.Executing);
updateStatus = setStatus;
return (
<ShellToolMessage
{...shellProps}
status={status}
embeddedShellFocused={true}
activeShellPtyId={1}
ptyId={1}
/>
);
};
const { lastFrame } = renderWithContext(<Wrapper />, StreamingState.Idle);
// Verify it is initially focused
await waitFor(() => {
expect(lastFrame()).toContain('(Focused)');
});
// Now update status to Success
await act(async () => {
updateStatus(ToolCallStatus.Success);
});
// Should call setEmbeddedShellFocused(false) because isThisShellFocused became false
await waitFor(() => {
expect(mockSetEmbeddedShellFocused).toHaveBeenCalledWith(false);
});
});
});
});
@@ -89,20 +89,6 @@ export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
useMouseClick(contentRef, handleFocus, { isActive: !!isThisShellFocusable });
const wasFocusedRef = React.useRef(false);
React.useEffect(() => {
if (isThisShellFocused) {
wasFocusedRef.current = true;
} else if (wasFocusedRef.current) {
if (embeddedShellFocused) {
setEmbeddedShellFocused(false);
}
wasFocusedRef.current = false;
}
}, [isThisShellFocused, embeddedShellFocused, setEmbeddedShellFocused]);
const { shouldShowFocusHint } = useFocusHint(
isThisShellFocusable,
isThisShellFocused,
@@ -10,10 +10,8 @@ import type {
ToolCallConfirmationDetails,
Config,
} from '@google/gemini-cli-core';
import {
renderWithProviders,
createMockSettings,
} from '../../../test-utils/render.js';
import { renderWithProviders } from '../../../test-utils/render.js';
import { createMockSettings } from '../../../test-utils/settings.js';
import { useToolActions } from '../../contexts/ToolActionsContext.js';
vi.mock('../../contexts/ToolActionsContext.js', async (importOriginal) => {
@@ -4,10 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {
renderWithProviders,
createMockSettings,
} from '../../../test-utils/render.js';
import { renderWithProviders } from '../../../test-utils/render.js';
import { createMockSettings } from '../../../test-utils/settings.js';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { ToolGroupMessage } from './ToolGroupMessage.js';
import type { IndividualToolCallDisplay } from '../../types.js';
@@ -77,7 +77,7 @@ describe('Focus Hint', () => {
// Now it SHOULD contain the focus hint
expect(lastFrame()).toMatchSnapshot('after-delay-no-output');
expect(lastFrame()).toContain('(tab to focus)');
expect(lastFrame()).toContain('(Tab to focus)');
});
it('shows focus hint after delay with output', async () => {
@@ -95,7 +95,7 @@ describe('Focus Hint', () => {
});
expect(lastFrame()).toMatchSnapshot('after-delay-with-output');
expect(lastFrame()).toContain('(tab to focus)');
expect(lastFrame()).toContain('(Tab to focus)');
});
});
@@ -116,7 +116,7 @@ describe('Focus Hint', () => {
// The focus hint should be visible
expect(lastFrame()).toMatchSnapshot('long-description');
expect(lastFrame()).toContain('(tab to focus)');
expect(lastFrame()).toContain('(Tab to focus)');
// The name should still be visible
expect(lastFrame()).toContain(SHELL_COMMAND_NAME);
});
@@ -22,6 +22,8 @@ import {
type ToolResultDisplay,
} from '@google/gemini-cli-core';
import { useInactivityTimer } from '../../hooks/useInactivityTimer.js';
import { formatCommand } from '../../utils/keybindingUtils.js';
import { Command } from '../../../config/keyBindings.js';
export const STATUS_INDICATOR_WIDTH = 3;
@@ -117,7 +119,9 @@ export const FocusHint: React.FC<{
return (
<Box marginLeft={1} flexShrink={0}>
<Text color={theme.text.accent}>
{isThisShellFocused ? '(Focused)' : '(tab to focus)'}
{isThisShellFocused
? `(${formatCommand(Command.UNFOCUS_SHELL_INPUT)} to unfocus)`
: `(${formatCommand(Command.FOCUS_SHELL_INPUT)} to focus)`}
</Text>
</Box>
);
@@ -2,7 +2,7 @@
exports[`Focus Hint > 'ShellToolMessage' > shows focus hint after delay even with NO output > after-delay-no-output 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ Shell Command A tool for testing (tab to focus) │
│ Shell Command A tool for testing (Tab to focus) │
│ │"
`;
@@ -14,7 +14,7 @@ exports[`Focus Hint > 'ShellToolMessage' > shows focus hint after delay even wit
exports[`Focus Hint > 'ShellToolMessage' > shows focus hint after delay with output > after-delay-with-output 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ Shell Command A tool for testing (tab to focus) │
│ Shell Command A tool for testing (Tab to focus) │
│ │"
`;
@@ -26,7 +26,7 @@ exports[`Focus Hint > 'ShellToolMessage' > shows focus hint after delay with out
exports[`Focus Hint > 'ToolMessage' > shows focus hint after delay even with NO output > after-delay-no-output 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ Shell Command A tool for testing (tab to focus) │
│ Shell Command A tool for testing (Tab to focus) │
│ │"
`;
@@ -38,7 +38,7 @@ exports[`Focus Hint > 'ToolMessage' > shows focus hint after delay even with NO
exports[`Focus Hint > 'ToolMessage' > shows focus hint after delay with output > after-delay-with-output 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ Shell Command A tool for testing (tab to focus) │
│ Shell Command A tool for testing (Tab to focus) │
│ │"
`;
@@ -50,6 +50,6 @@ exports[`Focus Hint > 'ToolMessage' > shows focus hint after delay with output >
exports[`Focus Hint > handles long descriptions by shrinking them to show the focus hint > long-description 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ Shell Command AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA… (tab to focus) │
│ Shell Command AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA… (Tab to focus) │
│ │"
`;
+7 -2
View File
@@ -104,10 +104,15 @@ export function shellReducer(
}
shell.output = newOutput;
const nextState = { ...state, lastShellOutputTime: Date.now() };
if (state.isBackgroundShellVisible) {
return { ...state, backgroundShells: new Map(state.backgroundShells) };
return {
...nextState,
backgroundShells: new Map(state.backgroundShells),
};
}
return state;
return nextState;
}
case 'SYNC_BACKGROUND_SHELLS': {
return { ...state, backgroundShells: new Map(state.backgroundShells) };
@@ -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', () => {
@@ -114,7 +151,7 @@ describe('useFolderTrust', () => {
renderHook(() => useFolderTrust(mockSettings, onTrustChange, addItem));
expect(addItem).toHaveBeenCalledWith(
{
text: 'This folder is not trusted. Some features may be disabled. Use the `/permissions` command to change the trust level.',
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.',
type: 'info',
},
expect.any(Number),
@@ -149,7 +186,9 @@ describe('useFolderTrust', () => {
});
await act(async () => {
result.current.handleFolderTrustSelect(FolderTrustChoice.TRUST_FOLDER);
await result.current.handleFolderTrustSelect(
FolderTrustChoice.TRUST_FOLDER,
);
});
await waitFor(() => {
@@ -173,7 +212,9 @@ describe('useFolderTrust', () => {
);
await act(async () => {
result.current.handleFolderTrustSelect(FolderTrustChoice.TRUST_PARENT);
await result.current.handleFolderTrustSelect(
FolderTrustChoice.TRUST_PARENT,
);
});
await waitFor(() => {
@@ -197,7 +238,9 @@ describe('useFolderTrust', () => {
);
await act(async () => {
result.current.handleFolderTrustSelect(FolderTrustChoice.DO_NOT_TRUST);
await result.current.handleFolderTrustSelect(
FolderTrustChoice.DO_NOT_TRUST,
);
});
await waitFor(() => {
@@ -221,7 +264,7 @@ describe('useFolderTrust', () => {
);
await act(async () => {
result.current.handleFolderTrustSelect(
await result.current.handleFolderTrustSelect(
'invalid_choice' as FolderTrustChoice,
);
});
@@ -253,7 +296,9 @@ describe('useFolderTrust', () => {
});
await act(async () => {
result.current.handleFolderTrustSelect(FolderTrustChoice.TRUST_FOLDER);
await result.current.handleFolderTrustSelect(
FolderTrustChoice.TRUST_FOLDER,
);
});
await waitFor(() => {
@@ -272,7 +317,9 @@ describe('useFolderTrust', () => {
);
await act(async () => {
result.current.handleFolderTrustSelect(FolderTrustChoice.TRUST_FOLDER);
await result.current.handleFolderTrustSelect(
FolderTrustChoice.TRUST_FOLDER,
);
});
await waitFor(() => {
@@ -294,8 +341,10 @@ describe('useFolderTrust', () => {
useFolderTrust(mockSettings, onTrustChange, addItem),
);
act(() => {
result.current.handleFolderTrustSelect(FolderTrustChoice.TRUST_FOLDER);
await act(async () => {
await result.current.handleFolderTrustSelect(
FolderTrustChoice.TRUST_FOLDER,
);
});
await vi.runAllTimersAsync();
@@ -306,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),
);
});
});
});
+34 -16
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 = (
@@ -27,28 +27,46 @@ export const useFolderTrust = (
const [isRestarting, setIsRestarting] = useState(false);
const startupMessageSent = useRef(false);
const folderTrust = settings.merged.security.folderTrust.enabled;
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;
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(
(choice: FolderTrustChoice) => {
async (choice: FolderTrustChoice) => {
const trustLevelMap: Record<FolderTrustChoice, TrustLevel> = {
[FolderTrustChoice.TRUST_FOLDER]: TrustLevel.TRUST_FOLDER,
[FolderTrustChoice.TRUST_PARENT]: TrustLevel.TRUST_PARENT,
@@ -62,7 +80,7 @@ export const useFolderTrust = (
const trustedFolders = loadTrustedFolders();
try {
trustedFolders.setValue(cwd, trustLevel);
await trustedFolders.setValue(cwd, trustLevel);
} catch (_e) {
coreEvents.emitFeedback(
'error',
@@ -474,12 +474,6 @@ export const useGeminiStream = (
const activePtyId = activeShellPtyId || activeToolPtyId;
useEffect(() => {
if (!activePtyId) {
setShellInputFocused(false);
}
}, [activePtyId, setShellInputFocused]);
const prevActiveShellPtyIdRef = useRef<number | null>(null);
useEffect(() => {
if (
@@ -142,7 +142,7 @@ describe('usePermissionsModifyTrust', () => {
expect(result.current.isInheritedTrustFromParent).toBe(false);
});
it('should set needsRestart but not save when trust changes', () => {
it('should set needsRestart but not save when trust changes', async () => {
const mockSetValue = vi.fn();
mockedLoadTrustedFolders.mockReturnValue({
user: { config: {} },
@@ -157,15 +157,15 @@ describe('usePermissionsModifyTrust', () => {
usePermissionsModifyTrust(mockOnExit, mockAddItem, mockedCwd()),
);
act(() => {
result.current.updateTrustLevel(TrustLevel.TRUST_FOLDER);
await act(async () => {
await result.current.updateTrustLevel(TrustLevel.TRUST_FOLDER);
});
expect(result.current.needsRestart).toBe(true);
expect(mockSetValue).not.toHaveBeenCalled();
});
it('should save immediately if trust does not change', () => {
it('should save immediately if trust does not change', async () => {
const mockSetValue = vi.fn();
mockedLoadTrustedFolders.mockReturnValue({
user: { config: {} },
@@ -181,8 +181,8 @@ describe('usePermissionsModifyTrust', () => {
usePermissionsModifyTrust(mockOnExit, mockAddItem, mockedCwd()),
);
act(() => {
result.current.updateTrustLevel(TrustLevel.TRUST_PARENT);
await act(async () => {
await result.current.updateTrustLevel(TrustLevel.TRUST_PARENT);
});
expect(result.current.needsRestart).toBe(false);
@@ -193,7 +193,7 @@ describe('usePermissionsModifyTrust', () => {
expect(mockOnExit).toHaveBeenCalled();
});
it('should commit the pending trust level change', () => {
it('should commit the pending trust level change', async () => {
const mockSetValue = vi.fn();
mockedLoadTrustedFolders.mockReturnValue({
user: { config: {} },
@@ -208,14 +208,14 @@ describe('usePermissionsModifyTrust', () => {
usePermissionsModifyTrust(mockOnExit, mockAddItem, mockedCwd()),
);
act(() => {
result.current.updateTrustLevel(TrustLevel.TRUST_FOLDER);
await act(async () => {
await result.current.updateTrustLevel(TrustLevel.TRUST_FOLDER);
});
expect(result.current.needsRestart).toBe(true);
act(() => {
result.current.commitTrustLevelChange();
await act(async () => {
await result.current.commitTrustLevelChange();
});
expect(mockSetValue).toHaveBeenCalledWith(
@@ -224,7 +224,7 @@ describe('usePermissionsModifyTrust', () => {
);
});
it('should add warning when setting DO_NOT_TRUST but still trusted by parent', () => {
it('should add warning when setting DO_NOT_TRUST but still trusted by parent', async () => {
mockedLoadTrustedFolders.mockReturnValue({
user: { config: {} },
setValue: vi.fn(),
@@ -238,8 +238,8 @@ describe('usePermissionsModifyTrust', () => {
usePermissionsModifyTrust(mockOnExit, mockAddItem, mockedCwd()),
);
act(() => {
result.current.updateTrustLevel(TrustLevel.DO_NOT_TRUST);
await act(async () => {
await result.current.updateTrustLevel(TrustLevel.DO_NOT_TRUST);
});
expect(mockAddItem).toHaveBeenCalledWith(
@@ -251,7 +251,7 @@ describe('usePermissionsModifyTrust', () => {
);
});
it('should add warning when setting DO_NOT_TRUST but still trusted by IDE', () => {
it('should add warning when setting DO_NOT_TRUST but still trusted by IDE', async () => {
mockedLoadTrustedFolders.mockReturnValue({
user: { config: {} },
setValue: vi.fn(),
@@ -265,8 +265,8 @@ describe('usePermissionsModifyTrust', () => {
usePermissionsModifyTrust(mockOnExit, mockAddItem, mockedCwd()),
);
act(() => {
result.current.updateTrustLevel(TrustLevel.DO_NOT_TRUST);
await act(async () => {
await result.current.updateTrustLevel(TrustLevel.DO_NOT_TRUST);
});
expect(mockAddItem).toHaveBeenCalledWith(
@@ -299,7 +299,7 @@ describe('usePermissionsModifyTrust', () => {
expect(result.current.isInheritedTrustFromIde).toBe(false);
});
it('should save immediately without needing a restart', () => {
it('should save immediately without needing a restart', async () => {
const mockSetValue = vi.fn();
mockedLoadTrustedFolders.mockReturnValue({
user: { config: {} },
@@ -314,8 +314,8 @@ describe('usePermissionsModifyTrust', () => {
usePermissionsModifyTrust(mockOnExit, mockAddItem, otherDirectory),
);
act(() => {
result.current.updateTrustLevel(TrustLevel.TRUST_FOLDER);
await act(async () => {
await result.current.updateTrustLevel(TrustLevel.TRUST_FOLDER);
});
expect(result.current.needsRestart).toBe(false);
@@ -326,7 +326,7 @@ describe('usePermissionsModifyTrust', () => {
expect(mockOnExit).toHaveBeenCalled();
});
it('should not add a warning when setting DO_NOT_TRUST', () => {
it('should not add a warning when setting DO_NOT_TRUST', async () => {
mockedLoadTrustedFolders.mockReturnValue({
user: { config: {} },
setValue: vi.fn(),
@@ -340,15 +340,15 @@ describe('usePermissionsModifyTrust', () => {
usePermissionsModifyTrust(mockOnExit, mockAddItem, otherDirectory),
);
act(() => {
result.current.updateTrustLevel(TrustLevel.DO_NOT_TRUST);
await act(async () => {
await result.current.updateTrustLevel(TrustLevel.DO_NOT_TRUST);
});
expect(mockAddItem).not.toHaveBeenCalled();
});
});
it('should emit feedback when setValue throws in updateTrustLevel', () => {
it('should emit feedback when setValue throws in updateTrustLevel', async () => {
const mockSetValue = vi.fn().mockImplementation(() => {
throw new Error('test error');
});
@@ -368,8 +368,8 @@ describe('usePermissionsModifyTrust', () => {
usePermissionsModifyTrust(mockOnExit, mockAddItem, mockedCwd()),
);
act(() => {
result.current.updateTrustLevel(TrustLevel.TRUST_PARENT);
await act(async () => {
await result.current.updateTrustLevel(TrustLevel.TRUST_PARENT);
});
expect(emitFeedbackSpy).toHaveBeenCalledWith(
@@ -379,7 +379,7 @@ describe('usePermissionsModifyTrust', () => {
expect(mockOnExit).toHaveBeenCalled();
});
it('should emit feedback when setValue throws in commitTrustLevelChange', () => {
it('should emit feedback when setValue throws in commitTrustLevelChange', async () => {
const mockSetValue = vi.fn().mockImplementation(() => {
throw new Error('test error');
});
@@ -398,12 +398,12 @@ describe('usePermissionsModifyTrust', () => {
usePermissionsModifyTrust(mockOnExit, mockAddItem, mockedCwd()),
);
act(() => {
result.current.updateTrustLevel(TrustLevel.TRUST_FOLDER);
await act(async () => {
await result.current.updateTrustLevel(TrustLevel.TRUST_FOLDER);
});
act(() => {
const success = result.current.commitTrustLevelChange();
await act(async () => {
const success = await result.current.commitTrustLevelChange();
expect(success).toBe(false);
});
@@ -88,15 +88,16 @@ export const usePermissionsModifyTrust = (
);
const [needsRestart, setNeedsRestart] = useState(false);
const isFolderTrustEnabled = !!settings.merged.security.folderTrust.enabled;
const isFolderTrustEnabled =
settings.merged.security.folderTrust.enabled ?? true;
const updateTrustLevel = useCallback(
(trustLevel: TrustLevel) => {
async (trustLevel: TrustLevel) => {
// If we are not editing the current workspace, the logic is simple:
// just save the setting and exit. No restart or warnings are needed.
if (!isCurrentWorkspace) {
const folders = loadTrustedFolders();
folders.setValue(cwd, trustLevel);
await folders.setValue(cwd, trustLevel);
onExit();
return;
}
@@ -139,7 +140,7 @@ export const usePermissionsModifyTrust = (
} else {
const folders = loadTrustedFolders();
try {
folders.setValue(cwd, trustLevel);
await folders.setValue(cwd, trustLevel);
} catch (_e) {
coreEvents.emitFeedback(
'error',
@@ -152,11 +153,11 @@ export const usePermissionsModifyTrust = (
[cwd, settings.merged, onExit, addItem, isCurrentWorkspace],
);
const commitTrustLevelChange = useCallback(() => {
const commitTrustLevelChange = useCallback(async () => {
if (pendingTrustLevel) {
const folders = loadTrustedFolders();
try {
folders.setValue(cwd, pendingTrustLevel);
await folders.setValue(cwd, pendingTrustLevel);
return true;
} catch (_e) {
coreEvents.emitFeedback(
@@ -0,0 +1,53 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { formatKeyBinding, formatCommand } from './keybindingUtils.js';
import { Command } from '../../config/keyBindings.js';
describe('keybindingUtils', () => {
describe('formatKeyBinding', () => {
it('formats simple keys', () => {
expect(formatKeyBinding({ key: 'a' })).toBe('A');
expect(formatKeyBinding({ key: 'return' })).toBe('Enter');
expect(formatKeyBinding({ key: 'escape' })).toBe('Esc');
});
it('formats modifiers', () => {
expect(formatKeyBinding({ key: 'c', ctrl: true })).toBe('Ctrl+C');
expect(formatKeyBinding({ key: 'z', cmd: true })).toBe('Cmd+Z');
expect(formatKeyBinding({ key: 'up', shift: true })).toBe('Shift+Up');
expect(formatKeyBinding({ key: 'left', alt: true })).toBe('Alt+Left');
});
it('formats multiple modifiers in order', () => {
expect(formatKeyBinding({ key: 'z', ctrl: true, shift: true })).toBe(
'Ctrl+Shift+Z',
);
expect(
formatKeyBinding({
key: 'a',
ctrl: true,
alt: true,
shift: true,
cmd: true,
}),
).toBe('Ctrl+Alt+Shift+Cmd+A');
});
});
describe('formatCommand', () => {
it('formats default commands', () => {
expect(formatCommand(Command.QUIT)).toBe('Ctrl+C');
expect(formatCommand(Command.SUBMIT)).toBe('Enter');
expect(formatCommand(Command.TOGGLE_BACKGROUND_SHELL)).toBe('Ctrl+B');
});
it('returns empty string for unknown commands', () => {
expect(formatCommand('unknown.command' as unknown as Command)).toBe('');
});
});
});
@@ -0,0 +1,65 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
type Command,
type KeyBinding,
type KeyBindingConfig,
defaultKeyBindings,
} from '../../config/keyBindings.js';
/**
* Maps internal key names to user-friendly display names.
*/
const KEY_NAME_MAP: Record<string, string> = {
return: 'Enter',
escape: 'Esc',
backspace: 'Backspace',
delete: 'Delete',
up: 'Up',
down: 'Down',
left: 'Left',
right: 'Right',
pageup: 'Page Up',
pagedown: 'Page Down',
home: 'Home',
end: 'End',
tab: 'Tab',
space: 'Space',
};
/**
* Formats a single KeyBinding into a human-readable string (e.g., "Ctrl+C").
*/
export function formatKeyBinding(binding: KeyBinding): string {
const parts: string[] = [];
if (binding.ctrl) parts.push('Ctrl');
if (binding.alt) parts.push('Alt');
if (binding.shift) parts.push('Shift');
if (binding.cmd) parts.push('Cmd');
const keyName = KEY_NAME_MAP[binding.key] || binding.key.toUpperCase();
parts.push(keyName);
return parts.join('+');
}
/**
* Formats the primary keybinding for a command.
*/
export function formatCommand(
command: Command,
config: KeyBindingConfig = defaultKeyBindings,
): string {
const bindings = config[command];
if (!bindings || bindings.length === 0) {
return '';
}
// Use the first binding as the primary one for display
return formatKeyBinding(bindings[0]);
}
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-core",
"version": "0.28.0-nightly.20260128.adc8e11bb",
"version": "0.28.0",
"description": "Gemini CLI Core",
"license": "Apache-2.0",
"repository": {
@@ -60,6 +60,7 @@
"mnemonist": "^0.40.3",
"open": "^10.1.2",
"picomatch": "^4.0.1",
"proper-lockfile": "^4.1.2",
"read-package-up": "^11.0.0",
"shell-quote": "^1.8.3",
"simple-git": "^3.28.0",
+8 -3
View File
@@ -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);
});
-4
View File
@@ -1777,10 +1777,6 @@ export class Config {
* @returns true if the path is allowed, false otherwise.
*/
isPathAllowed(absolutePath: string): boolean {
if (this.interactive && path.isAbsolute(absolutePath)) {
return true;
}
const realpath = (p: string) => {
let resolved: string;
try {
+1
View File
@@ -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';
+33 -8
View File
@@ -42,6 +42,7 @@ import { AuthProviderType } from '../config/config.js';
import { GoogleCredentialProvider } from '../mcp/google-auth-provider.js';
import { ServiceAccountImpersonationProvider } from '../mcp/sa-impersonation-provider.js';
import { DiscoveredMCPTool } from './mcp-tool.js';
import { XcodeMcpBridgeFixTransport } from './xcode-mcp-fix-transport.js';
import type { CallableTool, FunctionCall, Part, Tool } from '@google/genai';
import { basename } from 'node:path';
@@ -1871,7 +1872,7 @@ export async function createTransport(
}
if (mcpServerConfig.command) {
const transport = new StdioClientTransport({
let transport: Transport = new StdioClientTransport({
command: mcpServerConfig.command,
args: mcpServerConfig.args || [],
env: sanitizeEnvironment(
@@ -1894,14 +1895,38 @@ export async function createTransport(
cwd: mcpServerConfig.cwd,
stderr: 'pipe',
});
// Fix for Xcode 26.3 mcpbridge non-compliant responses
// It returns JSON in `content` instead of `structuredContent`
if (
mcpServerConfig.command === 'xcrun' &&
mcpServerConfig.args?.includes('mcpbridge')
) {
transport = new XcodeMcpBridgeFixTransport(transport);
}
if (debugMode) {
transport.stderr!.on('data', (data) => {
const stderrStr = data.toString().trim();
debugLogger.debug(
`[DEBUG] [MCP STDERR (${mcpServerName})]: `,
stderrStr,
);
});
// The `XcodeMcpBridgeFixTransport` wrapper hides the underlying `StdioClientTransport`,
// which exposes `stderr` for debug logging. We need to unwrap it to attach the listener.
const underlyingTransport =
transport instanceof XcodeMcpBridgeFixTransport
? // eslint-disable-next-line @typescript-eslint/no-explicit-any
(transport as any).transport
: transport;
if (
underlyingTransport instanceof StdioClientTransport &&
underlyingTransport.stderr
) {
underlyingTransport.stderr.on('data', (data) => {
const stderrStr = data.toString().trim();
debugLogger.debug(
`[DEBUG] [MCP STDERR (${mcpServerName})]: `,
stderrStr,
);
});
}
}
return transport;
}
@@ -0,0 +1,120 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { EventEmitter } from 'node:events';
import { XcodeMcpBridgeFixTransport } from './xcode-mcp-fix-transport.js';
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
// Mock Transport that simulates the mcpbridge behavior
class MockBadMcpBridgeTransport extends EventEmitter implements Transport {
onclose?: () => void;
onerror?: (error: Error) => void;
onmessage?: (message: JSONRPCMessage) => void;
async start() {}
async close() {}
async send(_message: JSONRPCMessage) {}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
emitMessage(msg: any) {
this.onmessage?.(msg);
}
}
describe('Xcode MCP Bridge Fix', () => {
it('intercepts and fixes the non-compliant mcpbridge response', async () => {
const mockTransport = new MockBadMcpBridgeTransport();
const fixTransport = new XcodeMcpBridgeFixTransport(mockTransport);
// We need to capture what the fixTransport emits to its listeners
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const messages: any[] = [];
fixTransport.onmessage = (msg) => {
messages.push(msg);
};
await fixTransport.start();
// SCENARIO 1: Bad response from Xcode
// It has `content` stringified JSON, but misses `structuredContent`
const badPayload = {
jsonrpc: '2.0',
id: 1,
result: {
content: [
{
type: 'text',
text: JSON.stringify({
windows: [{ title: 'HelloWorld', path: '/path/to/project' }],
}),
},
],
// Missing: structuredContent
},
};
mockTransport.emitMessage(badPayload);
// Verify the message received by the client (listener of fixTransport)
const fixedMsg = messages.find((m) => m.id === 1);
expect(fixedMsg).toBeDefined();
expect(fixedMsg.result.structuredContent).toBeDefined();
expect(fixedMsg.result.structuredContent.windows[0].title).toBe(
'HelloWorld',
);
// SCENARIO 2: Good response (should be untouched)
const goodPayload = {
jsonrpc: '2.0',
id: 2,
result: {
content: [{ type: 'text', text: 'normal text' }],
structuredContent: { some: 'data' },
},
};
mockTransport.emitMessage(goodPayload);
const goodMsg = messages.find((m) => m.id === 2);
expect(goodMsg).toBeDefined();
expect(goodMsg.result.structuredContent).toEqual({ some: 'data' });
});
it('ignores responses that cannot be parsed as JSON', async () => {
const mockTransport = new MockBadMcpBridgeTransport();
const fixTransport = new XcodeMcpBridgeFixTransport(mockTransport);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const messages: any[] = [];
fixTransport.onmessage = (msg) => {
messages.push(msg);
};
await fixTransport.start();
const nonJsonPayload = {
jsonrpc: '2.0',
id: 3,
result: {
content: [
{
type: 'text',
text: "Just some plain text that isn't JSON",
},
],
},
};
mockTransport.emitMessage(nonJsonPayload);
const msg = messages.find((m) => m.id === 3);
expect(msg).toBeDefined();
expect(msg.result.structuredContent).toBeUndefined();
expect(msg.result.content[0].text).toBe(
"Just some plain text that isn't JSON",
);
});
});
@@ -0,0 +1,101 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
import type {
JSONRPCMessage,
JSONRPCResponse,
} from '@modelcontextprotocol/sdk/types.js';
import { EventEmitter } from 'node:events';
/**
* A wrapper transport that intercepts messages from Xcode's mcpbridge and fixes
* non-compliant responses.
*
* Issue: Xcode 26.3's mcpbridge returns tool results in `content` but misses
* `structuredContent` when the tool has an output schema.
*
* Fix: Parse the text content as JSON and populate `structuredContent`.
*/
export class XcodeMcpBridgeFixTransport
extends EventEmitter
implements Transport
{
constructor(private readonly transport: Transport) {
super();
// Forward messages from the underlying transport
this.transport.onmessage = (message) => {
this.handleMessage(message);
};
this.transport.onclose = () => {
this.onclose?.();
};
this.transport.onerror = (error) => {
this.onerror?.(error);
};
}
// Transport interface implementation
onclose?: () => void;
onerror?: (error: Error) => void;
onmessage?: (message: JSONRPCMessage) => void;
async start(): Promise<void> {
await this.transport.start();
}
async close(): Promise<void> {
await this.transport.close();
}
async send(message: JSONRPCMessage): Promise<void> {
await this.transport.send(message);
}
private handleMessage(message: JSONRPCMessage) {
if (this.isJsonResponse(message)) {
this.fixStructuredContent(message);
}
this.onmessage?.(message);
}
private isJsonResponse(message: JSONRPCMessage): message is JSONRPCResponse {
return 'result' in message || 'error' in message;
}
private fixStructuredContent(response: JSONRPCResponse) {
if (!('result' in response)) return;
// We can cast because we verified 'result' is in response,
// but TS might still be picky if the type is a strict union.
// Let's treat it safely.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = response.result as any;
// Check if we have content but missing structuredContent
if (
result.content &&
Array.isArray(result.content) &&
result.content.length > 0 &&
!result.structuredContent
) {
const firstItem = result.content[0];
if (firstItem.type === 'text' && typeof firstItem.text === 'string') {
try {
// Attempt to parse the text as JSON
const parsed = JSON.parse(firstItem.text);
// If successful, populate structuredContent
result.structuredContent = parsed;
} catch (_) {
// Ignored: Content is likely plain text, not JSON.
}
}
}
}
}
+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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-test-utils",
"version": "0.28.0-nightly.20260128.adc8e11bb",
"version": "0.28.0",
"private": true,
"main": "src/index.ts",
"license": "Apache-2.0",
+62 -28
View File
@@ -272,6 +272,27 @@ export class InteractiveRun {
}
}
function isObject(item: any): item is Record<string, any> {
return !!(item && typeof item === 'object' && !Array.isArray(item));
}
function deepMerge(target: any, source: any): any {
if (!isObject(target) || !isObject(source)) {
return source;
}
const output = { ...target };
Object.keys(source).forEach((key) => {
const targetValue = target[key];
const sourceValue = source[key];
if (isObject(targetValue) && isObject(sourceValue)) {
output[key] = deepMerge(targetValue, sourceValue);
} else {
output[key] = sourceValue;
}
});
return output;
}
export class TestRig {
testDir: string | null = null;
homeDir: string | null = null;
@@ -316,44 +337,56 @@ export class TestRig {
const projectGeminiDir = join(this.testDir!, GEMINI_DIR);
mkdirSync(projectGeminiDir, { recursive: true });
const userGeminiDir = join(this.homeDir!, GEMINI_DIR);
mkdirSync(userGeminiDir, { recursive: true });
// In sandbox mode, use an absolute path for telemetry inside the container
// The container mounts the test directory at the same path as the host
const telemetryPath = join(this.homeDir!, 'telemetry.log'); // Always use home directory for telemetry
const settings = {
general: {
// Nightly releases sometimes becomes out of sync with local code and
// triggers auto-update, which causes tests to fail.
disableAutoUpdate: true,
previewFeatures: false,
},
telemetry: {
enabled: true,
target: 'local',
otlpEndpoint: '',
outfile: telemetryPath,
},
security: {
auth: {
selectedType: 'gemini-api-key',
const settings = deepMerge(
{
general: {
// Nightly releases sometimes becomes out of sync with local code and
// triggers auto-update, which causes tests to fail.
disableAutoUpdate: true,
previewFeatures: false,
},
telemetry: {
enabled: true,
target: 'local',
otlpEndpoint: '',
outfile: telemetryPath,
},
security: {
auth: {
selectedType: 'gemini-api-key',
},
folderTrust: {
enabled: false,
},
},
ui: {
useAlternateBuffer: true,
},
model: {
name: DEFAULT_GEMINI_MODEL,
},
sandbox:
env['GEMINI_SANDBOX'] !== 'false' ? env['GEMINI_SANDBOX'] : false,
// Don't show the IDE connection dialog when running from VsCode
ide: { enabled: false, hasSeenNudge: true },
},
ui: {
useAlternateBuffer: true,
},
model: {
name: DEFAULT_GEMINI_MODEL,
},
sandbox:
env['GEMINI_SANDBOX'] !== 'false' ? env['GEMINI_SANDBOX'] : false,
// Don't show the IDE connection dialog when running from VsCode
ide: { enabled: false, hasSeenNudge: true },
...overrideSettings, // Allow tests to override/add settings
};
overrideSettings ?? {},
);
writeFileSync(
join(projectGeminiDir, 'settings.json'),
JSON.stringify(settings, null, 2),
);
writeFileSync(
join(userGeminiDir, 'settings.json'),
JSON.stringify(settings, null, 2),
);
}
createFile(fileName: string, content: string) {
@@ -412,6 +445,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];
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "gemini-cli-vscode-ide-companion",
"displayName": "Gemini CLI Companion",
"description": "Enable Gemini CLI with direct access to your IDE workspace.",
"version": "0.28.0-nightly.20260128.adc8e11bb",
"version": "0.28.0",
"publisher": "google",
"icon": "assets/icon.png",
"repository": {
+4 -4
View File
@@ -1307,8 +1307,8 @@
"enabled": {
"title": "Folder Trust",
"description": "Setting to track whether Folder trust is enabled.",
"markdownDescription": "Setting to track whether Folder trust is enabled.\n\n- Category: `Security`\n- Requires restart: `yes`\n- Default: `false`",
"default": false,
"markdownDescription": "Setting to track whether Folder trust is enabled.\n\n- Category: `Security`\n- Requires restart: `yes`\n- Default: `true`",
"default": true,
"type": "boolean"
}
},
@@ -1445,8 +1445,8 @@
"extensionConfig": {
"title": "Extension Configuration",
"description": "Enable requesting and fetching of extension settings.",
"markdownDescription": "Enable requesting and fetching of extension settings.\n\n- Category: `Experimental`\n- Requires restart: `yes`\n- Default: `false`",
"default": false,
"markdownDescription": "Enable requesting and fetching of extension settings.\n\n- Category: `Experimental`\n- Requires restart: `yes`\n- Default: `true`",
"default": true,
"type": "boolean"
},
"enableEventDrivenScheduler": {