Compare commits

..

5 Commits

Author SHA1 Message Date
Christian Gunderman f93b9f849c Revert "feat(core): refine Edit and WriteFile tool schemas for Gemini 3 (#19476)"
This reverts commit fb1b1b451d.
2026-02-26 22:20:01 +00:00
Christian Gunderman 55d0bf5f8e Fix build. 2026-02-25 06:27:48 +00:00
Christian Gunderman 81217cc906 fix: add devtools reference to cli tsconfig 2026-02-25 06:27:36 +00:00
Christian Gunderman 4325e434f9 Fix build. 2026-02-25 06:27:24 +00:00
Adib234 29e8f2abf4 feat(plan): add integration tests for plan mode (#20214) 2026-02-25 04:56:16 +00:00
42 changed files with 359 additions and 1048 deletions
+143
View File
@@ -0,0 +1,143 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { TestRig, checkModelOutputContent } from './test-helper.js';
describe('Plan Mode', () => {
let rig: TestRig;
beforeEach(() => {
rig = new TestRig();
});
afterEach(async () => await rig.cleanup());
it('should allow read-only tools but deny write tools in plan mode', async () => {
await rig.setup(
'should allow read-only tools but deny write tools in plan mode',
{
settings: {
experimental: { plan: true },
tools: {
core: [
'run_shell_command',
'list_directory',
'write_file',
'read_file',
],
},
},
},
);
// We use a prompt that asks for both a read-only action and a write action.
// "List files" (read-only) followed by "touch denied.txt" (write).
const result = await rig.run({
approvalMode: 'plan',
stdin:
'Please list the files in the current directory, and then attempt to create a new file named "denied.txt" using a shell command.',
});
const lsCallFound = await rig.waitForToolCall('list_directory');
expect(lsCallFound, 'Expected list_directory to be called').toBe(true);
const shellCallFound = await rig.waitForToolCall('run_shell_command');
expect(shellCallFound, 'Expected run_shell_command to fail').toBe(false);
const toolLogs = rig.readToolLogs();
const lsLog = toolLogs.find((l) => l.toolRequest.name === 'list_directory');
expect(
toolLogs.find((l) => l.toolRequest.name === 'run_shell_command'),
).toBeUndefined();
expect(lsLog?.toolRequest.success).toBe(true);
checkModelOutputContent(result, {
expectedContent: ['Plan Mode', 'read-only'],
testName: 'Plan Mode restrictions test',
});
});
it('should allow write_file only in the plans directory in plan mode', async () => {
await rig.setup(
'should allow write_file only in the plans directory in plan mode',
{
settings: {
experimental: { plan: true },
tools: {
core: ['write_file', 'read_file', 'list_directory'],
allowed: ['write_file'],
},
general: { defaultApprovalMode: 'plan' },
},
},
);
// We ask the agent to create a plan for a feature, which should trigger a write_file in the plans directory.
// Verify that write_file outside of plan directory fails
await rig.run({
approvalMode: 'plan',
stdin:
'Create a file called plan.md in the plans directory. Then create a file called hello.txt in the current directory',
});
const toolLogs = rig.readToolLogs();
const writeLogs = toolLogs.filter(
(l) => l.toolRequest.name === 'write_file',
);
const planWrite = writeLogs.find(
(l) =>
l.toolRequest.args.includes('plans') &&
l.toolRequest.args.includes('plan.md'),
);
const blockedWrite = writeLogs.find((l) =>
l.toolRequest.args.includes('hello.txt'),
);
// Model is undeterministic, sometimes a blocked write appears in tool logs and sometimes it doesn't
if (blockedWrite) {
expect(blockedWrite?.toolRequest.success).toBe(false);
}
expect(planWrite?.toolRequest.success).toBe(true);
});
it('should be able to enter plan mode from default mode', async () => {
await rig.setup('should be able to enter plan mode from default mode', {
settings: {
experimental: { plan: true },
tools: {
core: ['enter_plan_mode'],
allowed: ['enter_plan_mode'],
},
},
});
// Start in default mode and ask to enter plan mode.
await rig.run({
approvalMode: 'default',
stdin:
'I want to perform a complex refactoring. Please enter plan mode so we can design it first.',
});
const enterPlanCallFound = await rig.waitForToolCall(
'enter_plan_mode',
10000,
);
expect(enterPlanCallFound, 'Expected enter_plan_mode to be called').toBe(
true,
);
const toolLogs = rig.readToolLogs();
const enterLog = toolLogs.find(
(l) => l.toolRequest.name === 'enter_plan_mode',
);
expect(enterLog?.toolRequest.success).toBe(true);
});
});
+36 -10
View File
@@ -1,12 +1,12 @@
{
"name": "@google/gemini-cli",
"version": "0.31.0-preview.3",
"version": "0.30.0-nightly.20260210.a2174751d",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@google/gemini-cli",
"version": "0.31.0-preview.3",
"version": "0.30.0-nightly.20260210.a2174751d",
"workspaces": [
"packages/*"
],
@@ -2150,6 +2150,7 @@
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz",
"integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@hono/node-server": "^1.19.9",
"ajv": "^8.17.1",
@@ -2292,6 +2293,7 @@
"integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@octokit/auth-token": "^6.0.0",
"@octokit/graphql": "^9.0.2",
@@ -2472,6 +2474,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=8.0.0"
}
@@ -2521,6 +2524,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.5.0.tgz",
"integrity": "sha512-ka4H8OM6+DlUhSAZpONu0cPBtPPTQKxbxVzC4CzVx5+K4JnroJVBtDzLAMx4/3CDTJXRvVFhpFjtl4SaiTNoyQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/semantic-conventions": "^1.29.0"
},
@@ -2895,6 +2899,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.5.0.tgz",
"integrity": "sha512-F8W52ApePshpoSrfsSk1H2yJn9aKjCrbpQF1M9Qii0GHzbfVeFUB+rc3X4aggyZD8x9Gu3Slua+s6krmq6Dt8g==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/core": "2.5.0",
"@opentelemetry/semantic-conventions": "^1.29.0"
@@ -2928,6 +2933,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.5.0.tgz",
"integrity": "sha512-BeJLtU+f5Gf905cJX9vXFQorAr6TAfK3SPvTFqP+scfIpDQEJfRaGJWta7sJgP+m4dNtBf9y3yvBKVAZZtJQVA==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/core": "2.5.0",
"@opentelemetry/resources": "2.5.0"
@@ -2982,6 +2988,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.5.0.tgz",
"integrity": "sha512-VzRf8LzotASEyNDUxTdaJ9IRJ1/h692WyArDBInf5puLCjxbICD6XkHgpuudis56EndyS7LYFmtTMny6UABNdQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/core": "2.5.0",
"@opentelemetry/resources": "2.5.0",
@@ -4178,6 +4185,7 @@
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -4451,6 +4459,7 @@
"integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.56.1",
"@typescript-eslint/types": "8.56.1",
@@ -5298,6 +5307,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -7851,6 +7861,7 @@
"integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -8483,6 +8494,7 @@
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
"peer": true,
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
@@ -9777,6 +9789,7 @@
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.2.tgz",
"integrity": "sha512-gJnaDHXKDayjt8ue0n8Gs0A007yKXj4Xzb8+cNjZeYsSzzwKc0Lr+OZgYwVfB0pHfUs17EPoLvrOsEaJ9mj+Tg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=16.9.0"
}
@@ -10056,6 +10069,7 @@
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.11.tgz",
"integrity": "sha512-93LQlzT7vvZ1XJcmOMwN4s+6W334QegendeHOMnEJBlhnpIzr8bws6/aOEHG8ZCuVD/vNeeea5m1msHIdAY6ig==",
"license": "MIT",
"peer": true,
"dependencies": {
"@alcalzone/ansi-tokenize": "^0.2.1",
"ansi-escapes": "^7.0.0",
@@ -13705,6 +13719,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -13715,6 +13730,7 @@
"integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"shell-quote": "^1.6.1",
"ws": "^7"
@@ -15674,6 +15690,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -15897,7 +15914,8 @@
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD"
"license": "0BSD",
"peer": true
},
"node_modules/tsx": {
"version": "4.20.3",
@@ -15905,6 +15923,7 @@
"integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "~0.25.0",
"get-tsconfig": "^4.7.5"
@@ -16064,6 +16083,7 @@
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -16272,6 +16292,7 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz",
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@@ -16385,6 +16406,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -16397,6 +16419,7 @@
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4",
@@ -17041,6 +17064,7 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
@@ -17056,7 +17080,7 @@
},
"packages/a2a-server": {
"name": "@google/gemini-cli-a2a-server",
"version": "0.31.0-preview.3",
"version": "0.30.0-nightly.20260210.a2174751d",
"dependencies": {
"@a2a-js/sdk": "^0.3.8",
"@google-cloud/storage": "^7.16.0",
@@ -17114,11 +17138,12 @@
},
"packages/cli": {
"name": "@google/gemini-cli",
"version": "0.31.0-preview.3",
"version": "0.30.0-nightly.20260210.a2174751d",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "^0.12.0",
"@google/gemini-cli-core": "file:../core",
"@google/gemini-cli-devtools": "file:../devtools",
"@google/genai": "1.41.0",
"@iarna/toml": "^2.2.5",
"@modelcontextprotocol/sdk": "^1.23.0",
@@ -17197,7 +17222,7 @@
},
"packages/core": {
"name": "@google/gemini-cli-core",
"version": "0.31.0-preview.3",
"version": "0.30.0-nightly.20260210.a2174751d",
"license": "Apache-2.0",
"dependencies": {
"@a2a-js/sdk": "^0.3.8",
@@ -17440,6 +17465,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -17462,7 +17488,7 @@
},
"packages/devtools": {
"name": "@google/gemini-cli-devtools",
"version": "0.31.0-preview.3",
"version": "0.30.0-nightly.20260210.a2174751d",
"license": "Apache-2.0",
"dependencies": {
"ws": "^8.16.0"
@@ -17477,7 +17503,7 @@
},
"packages/sdk": {
"name": "@google/gemini-cli-sdk",
"version": "0.31.0-preview.3",
"version": "0.29.0-nightly.20260203.71f46f116",
"license": "Apache-2.0",
"dependencies": {
"@google/gemini-cli-core": "file:../core",
@@ -17494,7 +17520,7 @@
},
"packages/test-utils": {
"name": "@google/gemini-cli-test-utils",
"version": "0.31.0-preview.3",
"version": "0.30.0-nightly.20260210.a2174751d",
"license": "Apache-2.0",
"dependencies": {
"@google/gemini-cli-core": "file:../core",
@@ -17511,7 +17537,7 @@
},
"packages/vscode-ide-companion": {
"name": "gemini-cli-vscode-ide-companion",
"version": "0.31.0-preview.3",
"version": "0.30.0-nightly.20260210.a2174751d",
"license": "LICENSE",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.23.0",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.31.0-preview.3",
"version": "0.30.0-nightly.20260210.a2174751d",
"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.31.0-preview.3"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.30.0-nightly.20260210.a2174751d"
},
"scripts": {
"start": "cross-env NODE_ENV=development node scripts/start.js",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-a2a-server",
"version": "0.31.0-preview.3",
"version": "0.30.0-nightly.20260210.a2174751d",
"description": "Gemini CLI A2A Server",
"repository": {
"type": "git",
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.31.0-preview.3",
"version": "0.30.0-nightly.20260210.a2174751d",
"description": "Gemini CLI",
"license": "Apache-2.0",
"repository": {
@@ -26,11 +26,12 @@
"dist"
],
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.31.0-preview.3"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.30.0-nightly.20260210.a2174751d"
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.12.0",
"@google/gemini-cli-core": "file:../core",
"@google/gemini-cli-devtools": "file:../devtools",
"@google/genai": "1.41.0",
"@iarna/toml": "^2.2.5",
"@modelcontextprotocol/sdk": "^1.23.0",
+29 -89
View File
@@ -8,13 +8,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import {
resolveWorkspacePolicyState,
autoAcceptWorkspacePolicies,
setAutoAcceptWorkspacePolicies,
disableWorkspacePolicies,
setDisableWorkspacePolicies,
} from './policy.js';
import { resolveWorkspacePolicyState } from './policy.js';
import { writeToStderr } from '@google/gemini-cli-core';
// Mock debugLogger to avoid noise in test output
@@ -47,9 +41,6 @@ describe('resolveWorkspacePolicyState', () => {
fs.mkdirSync(workspaceDir);
policiesDir = path.join(workspaceDir, '.gemini', 'policies');
// Enable policies for these tests to verify loading logic
setDisableWorkspacePolicies(false);
vi.clearAllMocks();
});
@@ -72,30 +63,29 @@ describe('resolveWorkspacePolicyState', () => {
});
});
it('should have disableWorkspacePolicies set to true by default', () => {
// We explicitly set it to false in beforeEach for other tests,
// so here we test that setting it to true works.
setDisableWorkspacePolicies(true);
expect(disableWorkspacePolicies).toBe(true);
});
it('should return policy directory if integrity matches', async () => {
// Set up policies directory with a file
fs.mkdirSync(policiesDir, { recursive: true });
fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []');
// First call to establish integrity (interactive auto-accept)
// First call to establish integrity (interactive accept)
const firstResult = await resolveWorkspacePolicyState({
cwd: workspaceDir,
trustedFolder: true,
interactive: true,
});
expect(firstResult.workspacePoliciesDir).toBe(policiesDir);
expect(firstResult.policyUpdateConfirmationRequest).toBeUndefined();
expect(writeToStderr).not.toHaveBeenCalled();
expect(firstResult.policyUpdateConfirmationRequest).toBeDefined();
// Establish integrity manually as if accepted
const { PolicyIntegrityManager } = await import('@google/gemini-cli-core');
const integrityManager = new PolicyIntegrityManager();
await integrityManager.acceptIntegrity(
'workspace',
workspaceDir,
firstResult.policyUpdateConfirmationRequest!.newHash,
);
// Second call should match
const result = await resolveWorkspacePolicyState({
cwd: workspaceDir,
trustedFolder: true,
@@ -117,33 +107,26 @@ describe('resolveWorkspacePolicyState', () => {
expect(result.policyUpdateConfirmationRequest).toBeUndefined();
});
it('should return confirmation request if changed in interactive mode when AUTO_ACCEPT is false', async () => {
const originalValue = autoAcceptWorkspacePolicies;
setAutoAcceptWorkspacePolicies(false);
it('should return confirmation request if changed in interactive mode', async () => {
fs.mkdirSync(policiesDir, { recursive: true });
fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []');
try {
fs.mkdirSync(policiesDir, { recursive: true });
fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []');
const result = await resolveWorkspacePolicyState({
cwd: workspaceDir,
trustedFolder: true,
interactive: true,
});
const result = await resolveWorkspacePolicyState({
cwd: workspaceDir,
trustedFolder: true,
interactive: true,
});
expect(result.workspacePoliciesDir).toBeUndefined();
expect(result.policyUpdateConfirmationRequest).toEqual({
scope: 'workspace',
identifier: workspaceDir,
policyDir: policiesDir,
newHash: expect.any(String),
});
} finally {
setAutoAcceptWorkspacePolicies(originalValue);
}
expect(result.workspacePoliciesDir).toBeUndefined();
expect(result.policyUpdateConfirmationRequest).toEqual({
scope: 'workspace',
identifier: workspaceDir,
policyDir: policiesDir,
newHash: expect.any(String),
});
});
it('should warn and auto-accept if changed in non-interactive mode when AUTO_ACCEPT is true', async () => {
it('should warn and auto-accept if changed in non-interactive mode', async () => {
fs.mkdirSync(policiesDir, { recursive: true });
fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []');
@@ -160,30 +143,6 @@ describe('resolveWorkspacePolicyState', () => {
);
});
it('should warn and auto-accept if changed in non-interactive mode when AUTO_ACCEPT is false', async () => {
const originalValue = autoAcceptWorkspacePolicies;
setAutoAcceptWorkspacePolicies(false);
try {
fs.mkdirSync(policiesDir, { recursive: true });
fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []');
const result = await resolveWorkspacePolicyState({
cwd: workspaceDir,
trustedFolder: true,
interactive: false,
});
expect(result.workspacePoliciesDir).toBe(policiesDir);
expect(result.policyUpdateConfirmationRequest).toBeUndefined();
expect(writeToStderr).toHaveBeenCalledWith(
expect.stringContaining('Automatically accepting and loading'),
);
} finally {
setAutoAcceptWorkspacePolicies(originalValue);
}
});
it('should not return workspace policies if cwd is the home directory', async () => {
const policiesDir = path.join(tempDir, '.gemini', 'policies');
fs.mkdirSync(policiesDir, { recursive: true });
@@ -200,26 +159,7 @@ describe('resolveWorkspacePolicyState', () => {
expect(result.policyUpdateConfirmationRequest).toBeUndefined();
});
it('should return empty state if disableWorkspacePolicies is true even if folder is trusted', async () => {
setDisableWorkspacePolicies(true);
// Set up policies directory with a file
fs.mkdirSync(policiesDir, { recursive: true });
fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []');
const result = await resolveWorkspacePolicyState({
cwd: workspaceDir,
trustedFolder: true,
interactive: true,
});
expect(result).toEqual({
workspacePoliciesDir: undefined,
policyUpdateConfirmationRequest: undefined,
});
});
it('should return empty state if cwd is a symlink to the home directory', async () => {
it('should not return workspace policies if cwd is a symlink to the home directory', async () => {
const policiesDir = path.join(tempDir, '.gemini', 'policies');
fs.mkdirSync(policiesDir, { recursive: true });
fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []');
+8 -43
View File
@@ -17,38 +17,9 @@ import {
Storage,
type PolicyUpdateConfirmationRequest,
writeToStderr,
debugLogger,
} from '@google/gemini-cli-core';
import { type Settings } from './settings.js';
/**
* Temporary flag to automatically accept workspace policies to reduce friction.
* Exported as 'let' to allow monkey patching in tests via the setter.
*/
export let autoAcceptWorkspacePolicies = true;
/**
* Sets the autoAcceptWorkspacePolicies flag.
* Used primarily for testing purposes.
*/
export function setAutoAcceptWorkspacePolicies(value: boolean) {
autoAcceptWorkspacePolicies = value;
}
/**
* Temporary flag to disable workspace level policies altogether.
* Exported as 'let' to allow monkey patching in tests via the setter.
*/
export let disableWorkspacePolicies = true;
/**
* Sets the disableWorkspacePolicies flag.
* Used primarily for testing purposes.
*/
export function setDisableWorkspacePolicies(value: boolean) {
disableWorkspacePolicies = value;
}
export async function createPolicyEngineConfig(
settings: Settings,
approvalMode: ApprovalMode,
@@ -95,7 +66,7 @@ export async function resolveWorkspacePolicyState(options: {
| PolicyUpdateConfirmationRequest
| undefined;
if (trustedFolder && !disableWorkspacePolicies) {
if (trustedFolder) {
const storage = new Storage(cwd);
// If we are in the home directory (or rather, our target Gemini dir is the global one),
@@ -120,8 +91,8 @@ export async function resolveWorkspacePolicyState(options: {
) {
// No workspace policies found
workspacePoliciesDir = undefined;
} else if (interactive && !autoAcceptWorkspacePolicies) {
// Policies changed or are new, and we are in interactive mode and auto-accept is disabled
} else if (interactive) {
// Policies changed or are new, and we are in interactive mode
policyUpdateConfirmationRequest = {
scope: 'workspace',
identifier: cwd,
@@ -129,23 +100,17 @@ export async function resolveWorkspacePolicyState(options: {
newHash: integrityResult.hash,
};
} else {
// Non-interactive mode or auto-accept is enabled: automatically accept/load
// Non-interactive mode: warn and automatically accept/load
await integrityManager.acceptIntegrity(
'workspace',
cwd,
integrityResult.hash,
);
workspacePoliciesDir = potentialWorkspacePoliciesDir;
if (!interactive) {
writeToStderr(
'WARNING: Workspace policies changed or are new. Automatically accepting and loading them.\n',
);
} else {
debugLogger.warn(
'Workspace policies changed or are new. Automatically accepting and loading them.',
);
}
// debugLogger.warn here doesn't show up in the terminal. It is showing up only in debug mode on the debug console
writeToStderr(
'WARNING: Workspace policies changed or are new. Automatically accepting and loading them in non-interactive mode.\n',
);
}
}
@@ -10,7 +10,6 @@ import { loadCliConfig, type CliArgs } from './config.js';
import { createTestMergedSettings } from './settings.js';
import * as ServerConfig from '@google/gemini-cli-core';
import { isWorkspaceTrusted } from './trustedFolders.js';
import * as Policy from './policy.js';
// Mock dependencies
vi.mock('./trustedFolders.js', () => ({
@@ -54,7 +53,6 @@ describe('Workspace-Level Policy CLI Integration', () => {
beforeEach(() => {
vi.clearAllMocks();
Policy.setDisableWorkspacePolicies(false);
// Default to MATCH for existing tests
mockCheckIntegrity.mockResolvedValue({
status: 'match',
@@ -166,7 +164,7 @@ describe('Workspace-Level Policy CLI Integration', () => {
);
});
it('should automatically accept and load workspacePoliciesDir if integrity MISMATCH in interactive mode when AUTO_ACCEPT is true', async () => {
it('should set policyUpdateConfirmationRequest if integrity MISMATCH in interactive mode', async () => {
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: true,
source: 'file',
@@ -188,23 +186,24 @@ describe('Workspace-Level Policy CLI Integration', () => {
cwd: MOCK_CWD,
});
expect(config.getPolicyUpdateConfirmationRequest()).toBeUndefined();
expect(mockAcceptIntegrity).toHaveBeenCalledWith(
'workspace',
MOCK_CWD,
'new-hash',
);
expect(config.getPolicyUpdateConfirmationRequest()).toEqual({
scope: 'workspace',
identifier: MOCK_CWD,
policyDir: expect.stringContaining(path.join('.gemini', 'policies')),
newHash: 'new-hash',
});
// In interactive mode without accept flag, it waits for user confirmation (handled by UI),
// so it currently DOES NOT pass the directory to createPolicyEngineConfig yet.
// The UI will handle the confirmation and reload/update.
expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith(
expect.objectContaining({
workspacePoliciesDir: expect.stringContaining(
path.join('.gemini', 'policies'),
),
workspacePoliciesDir: undefined,
}),
expect.anything(),
);
});
it('should automatically accept and load workspacePoliciesDir if integrity is NEW in interactive mode when AUTO_ACCEPT is true', async () => {
it('should set policyUpdateConfirmationRequest if integrity is NEW with files (first time seen) in interactive mode', async () => {
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: true,
source: 'file',
@@ -223,65 +222,18 @@ describe('Workspace-Level Policy CLI Integration', () => {
cwd: MOCK_CWD,
});
expect(config.getPolicyUpdateConfirmationRequest()).toBeUndefined();
expect(mockAcceptIntegrity).toHaveBeenCalledWith(
'workspace',
MOCK_CWD,
'new-hash',
);
expect(config.getPolicyUpdateConfirmationRequest()).toEqual({
scope: 'workspace',
identifier: MOCK_CWD,
policyDir: expect.stringContaining(path.join('.gemini', 'policies')),
newHash: 'new-hash',
});
expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith(
expect.objectContaining({
workspacePoliciesDir: expect.stringContaining(
path.join('.gemini', 'policies'),
),
workspacePoliciesDir: undefined,
}),
expect.anything(),
);
});
it('should set policyUpdateConfirmationRequest if integrity MISMATCH in interactive mode when AUTO_ACCEPT is false', async () => {
// Monkey patch autoAcceptWorkspacePolicies using setter
const originalValue = Policy.autoAcceptWorkspacePolicies;
Policy.setAutoAcceptWorkspacePolicies(false);
try {
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: true,
source: 'file',
});
mockCheckIntegrity.mockResolvedValue({
status: 'mismatch',
hash: 'new-hash',
fileCount: 1,
});
vi.mocked(ServerConfig.isHeadlessMode).mockReturnValue(false); // Interactive
const settings = createTestMergedSettings();
const argv = {
query: 'test',
promptInteractive: 'test',
} as unknown as CliArgs;
const config = await loadCliConfig(settings, 'test-session', argv, {
cwd: MOCK_CWD,
});
expect(config.getPolicyUpdateConfirmationRequest()).toEqual({
scope: 'workspace',
identifier: MOCK_CWD,
policyDir: expect.stringContaining(path.join('.gemini', 'policies')),
newHash: 'new-hash',
});
expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith(
expect.objectContaining({
workspacePoliciesDir: undefined,
}),
expect.anything(),
);
} finally {
// Restore for other tests
Policy.setAutoAcceptWorkspacePolicies(originalValue);
}
});
});
+5 -48
View File
@@ -17,6 +17,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
getErrorMessage: (e: unknown) => (e as Error).message,
};
});
@@ -31,7 +32,7 @@ describe('auth', () => {
it('should return null if authType is undefined', async () => {
const result = await performInitialAuth(mockConfig, undefined);
expect(result).toEqual({ authError: null, accountSuspensionInfo: null });
expect(result).toBeNull();
expect(mockConfig.refreshAuth).not.toHaveBeenCalled();
});
@@ -40,7 +41,7 @@ describe('auth', () => {
mockConfig,
AuthType.LOGIN_WITH_GOOGLE,
);
expect(result).toEqual({ authError: null, accountSuspensionInfo: null });
expect(result).toBeNull();
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
AuthType.LOGIN_WITH_GOOGLE,
);
@@ -53,10 +54,7 @@ describe('auth', () => {
mockConfig,
AuthType.LOGIN_WITH_GOOGLE,
);
expect(result).toEqual({
authError: 'Failed to login. Message: Auth failed',
accountSuspensionInfo: null,
});
expect(result).toBe('Failed to login. Message: Auth failed');
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
AuthType.LOGIN_WITH_GOOGLE,
);
@@ -70,48 +68,7 @@ describe('auth', () => {
mockConfig,
AuthType.LOGIN_WITH_GOOGLE,
);
expect(result).toEqual({ authError: null, accountSuspensionInfo: null });
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
AuthType.LOGIN_WITH_GOOGLE,
);
});
it('should return accountSuspensionInfo for 403 TOS_VIOLATION error', async () => {
vi.mocked(mockConfig.refreshAuth).mockRejectedValue({
response: {
data: {
error: {
code: 403,
message:
'This service has been disabled for violation of Terms of Service.',
details: [
{
'@type': 'type.googleapis.com/google.rpc.ErrorInfo',
reason: 'TOS_VIOLATION',
domain: 'example.googleapis.com',
metadata: {
appeal_url: 'https://example.com/appeal',
appeal_url_link_text: 'Appeal Here',
},
},
],
},
},
},
});
const result = await performInitialAuth(
mockConfig,
AuthType.LOGIN_WITH_GOOGLE,
);
expect(result).toEqual({
authError: null,
accountSuspensionInfo: {
message:
'This service has been disabled for violation of Terms of Service.',
appealUrl: 'https://example.com/appeal',
appealLinkText: 'Appeal Here',
},
});
expect(result).toBeNull();
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
AuthType.LOGIN_WITH_GOOGLE,
);
+6 -28
View File
@@ -9,28 +9,20 @@ import {
type Config,
getErrorMessage,
ValidationRequiredError,
isAccountSuspendedError,
} from '@google/gemini-cli-core';
import type { AccountSuspensionInfo } from '../ui/contexts/UIStateContext.js';
export interface InitialAuthResult {
authError: string | null;
accountSuspensionInfo: AccountSuspensionInfo | null;
}
/**
* Handles the initial authentication flow.
* @param config The application config.
* @param authType The selected auth type.
* @returns The auth result with error message and account suspension status.
* @returns An error message if authentication fails, otherwise null.
*/
export async function performInitialAuth(
config: Config,
authType: AuthType | undefined,
): Promise<InitialAuthResult> {
): Promise<string | null> {
if (!authType) {
return { authError: null, accountSuspensionInfo: null };
return null;
}
try {
@@ -41,24 +33,10 @@ export async function performInitialAuth(
if (e instanceof ValidationRequiredError) {
// Don't treat validation required as a fatal auth error during startup.
// This allows the React UI to load and show the ValidationDialog.
return { authError: null, accountSuspensionInfo: null };
return null;
}
const suspendedError = isAccountSuspendedError(e);
if (suspendedError) {
return {
authError: null,
accountSuspensionInfo: {
message: suspendedError.message,
appealUrl: suspendedError.appealUrl,
appealLinkText: suspendedError.appealLinkText,
},
};
}
return {
authError: `Failed to login. Message: ${getErrorMessage(e)}`,
accountSuspensionInfo: null,
};
return `Failed to login. Message: ${getErrorMessage(e)}`;
}
return { authError: null, accountSuspensionInfo: null };
return null;
}
+2 -10
View File
@@ -72,10 +72,7 @@ describe('initializer', () => {
vi.mocked(IdeClient.getInstance).mockResolvedValue(
mockIdeClient as unknown as IdeClient,
);
vi.mocked(performInitialAuth).mockResolvedValue({
authError: null,
accountSuspensionInfo: null,
});
vi.mocked(performInitialAuth).mockResolvedValue(null);
vi.mocked(validateTheme).mockReturnValue(null);
});
@@ -87,7 +84,6 @@ describe('initializer', () => {
expect(result).toEqual({
authError: null,
accountSuspensionInfo: null,
themeError: null,
shouldOpenAuthDialog: false,
geminiMdFileCount: 5,
@@ -107,7 +103,6 @@ describe('initializer', () => {
expect(result).toEqual({
authError: null,
accountSuspensionInfo: null,
themeError: null,
shouldOpenAuthDialog: false,
geminiMdFileCount: 5,
@@ -121,10 +116,7 @@ describe('initializer', () => {
});
it('should handle auth error', async () => {
vi.mocked(performInitialAuth).mockResolvedValue({
authError: 'Auth failed',
accountSuspensionInfo: null,
});
vi.mocked(performInitialAuth).mockResolvedValue('Auth failed');
const result = await initializeApp(
mockConfig as unknown as Config,
mockSettings,
+1 -4
View File
@@ -17,11 +17,9 @@ import {
import { type LoadedSettings } from '../config/settings.js';
import { performInitialAuth } from './auth.js';
import { validateTheme } from './theme.js';
import type { AccountSuspensionInfo } from '../ui/contexts/UIStateContext.js';
export interface InitializationResult {
authError: string | null;
accountSuspensionInfo: AccountSuspensionInfo | null;
themeError: string | null;
shouldOpenAuthDialog: boolean;
geminiMdFileCount: number;
@@ -39,7 +37,7 @@ export async function initializeApp(
settings: LoadedSettings,
): Promise<InitializationResult> {
const authHandle = startupProfiler.start('authenticate');
const { authError, accountSuspensionInfo } = await performInitialAuth(
const authError = await performInitialAuth(
config,
settings.merged.security.auth.selectedType,
);
@@ -62,7 +60,6 @@ export async function initializeApp(
return {
authError,
accountSuspensionInfo,
themeError,
shouldOpenAuthDialog,
geminiMdFileCount: config.getGeminiMdFileCount(),
-1
View File
@@ -1201,7 +1201,6 @@ describe('startInteractiveUI', () => {
const mockWorkspaceRoot = '/root';
const mockInitializationResult = {
authError: null,
accountSuspensionInfo: null,
themeError: null,
shouldOpenAuthDialog: false,
geminiMdFileCount: 0,
-3
View File
@@ -63,8 +63,6 @@ vi.mock('../ui/auth/useAuth.js', () => ({
onAuthError: vi.fn(),
apiKeyDefaultValue: 'test-api-key',
reloadApiKey: vi.fn().mockResolvedValue('test-api-key'),
accountSuspensionInfo: null,
setAccountSuspensionInfo: vi.fn(),
}),
validateAuthMethodWithSettings: () => null,
}));
@@ -294,7 +292,6 @@ export class AppRig {
version="test-version"
initializationResult={{
authError: null,
accountSuspensionInfo: null,
themeError: null,
shouldOpenAuthDialog: false,
geminiMdFileCount: 0,
-2
View File
@@ -574,8 +574,6 @@ const mockUIActions: UIActions = {
onHintSubmit: vi.fn(),
handleRestart: vi.fn(),
handleNewAgentsSelect: vi.fn(),
getPreferredEditor: vi.fn(),
clearAccountSuspension: vi.fn(),
};
let capturedOverflowState: OverflowState | undefined;
+1 -17
View File
@@ -692,14 +692,7 @@ export const AppContainer = (props: AppContainerProps) => {
onAuthError,
apiKeyDefaultValue,
reloadApiKey,
accountSuspensionInfo,
setAccountSuspensionInfo,
} = useAuthCommand(
settings,
config,
initializationResult.authError,
initializationResult.accountSuspensionInfo,
);
} = useAuthCommand(settings, config, initializationResult.authError);
const [authContext, setAuthContext] = useState<{ requiresRestart?: boolean }>(
{},
);
@@ -2230,7 +2223,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
isAuthenticating,
isConfigInitialized,
authError,
accountSuspensionInfo,
isAuthDialogOpen,
isAwaitingApiKeyInput: authState === AuthState.AwaitingApiKeyInput,
apiKeyDefaultValue,
@@ -2359,7 +2351,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
isAuthenticating,
isConfigInitialized,
authError,
accountSuspensionInfo,
isAuthDialogOpen,
editorError,
isEditorDialogOpen,
@@ -2563,11 +2554,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
}
setNewAgents(null);
},
getPreferredEditor,
clearAccountSuspension: () => {
setAccountSuspensionInfo(null);
setAuthState(AuthState.Updating);
},
}),
[
handleThemeSelect,
@@ -2616,11 +2602,9 @@ Logging in with Google... Restarting Gemini CLI to continue.
setActiveBackgroundShellPid,
setIsBackgroundShellListOpen,
setAuthContext,
setAccountSuspensionInfo,
newAgents,
config,
historyManager,
getPreferredEditor,
],
);
@@ -1,241 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { renderWithProviders } from '../../test-utils/render.js';
import { waitFor } from '../../test-utils/async.js';
import { BannedAccountDialog } from './BannedAccountDialog.js';
import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js';
import { useKeypress } from '../hooks/useKeypress.js';
import {
openBrowserSecurely,
shouldLaunchBrowser,
} from '@google/gemini-cli-core';
import { Text } from 'ink';
import { runExitCleanup } from '../../utils/cleanup.js';
import type { AccountSuspensionInfo } from '../contexts/UIStateContext.js';
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
openBrowserSecurely: vi.fn(),
shouldLaunchBrowser: vi.fn().mockReturnValue(true),
};
});
vi.mock('../../utils/cleanup.js', () => ({
runExitCleanup: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('../hooks/useKeypress.js', () => ({
useKeypress: vi.fn(),
}));
vi.mock('../components/shared/RadioButtonSelect.js', () => ({
RadioButtonSelect: vi.fn(({ items }) => (
<>
{items.map((item: { value: string; label: string }) => (
<Text key={item.value}>{item.label}</Text>
))}
</>
)),
}));
const mockedRadioButtonSelect = RadioButtonSelect as Mock;
const mockedUseKeypress = useKeypress as Mock;
const mockedOpenBrowser = openBrowserSecurely as Mock;
const mockedShouldLaunchBrowser = shouldLaunchBrowser as Mock;
const mockedRunExitCleanup = runExitCleanup as Mock;
const DEFAULT_SUSPENSION_INFO: AccountSuspensionInfo = {
message:
'This service has been disabled in this account for violation of Terms of Service. Please submit an appeal to continue using this product.',
appealUrl: 'https://example.com/appeal',
appealLinkText: 'Appeal Here',
};
describe('BannedAccountDialog', () => {
let onExit: Mock;
let onChangeAuth: Mock;
beforeEach(() => {
vi.resetAllMocks();
mockedShouldLaunchBrowser.mockReturnValue(true);
mockedOpenBrowser.mockResolvedValue(undefined);
mockedRunExitCleanup.mockResolvedValue(undefined);
onExit = vi.fn();
onChangeAuth = vi.fn();
});
it('renders the suspension message from accountSuspensionInfo', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<BannedAccountDialog
accountSuspensionInfo={DEFAULT_SUSPENSION_INFO}
onExit={onExit}
onChangeAuth={onChangeAuth}
/>,
);
await waitUntilReady();
const frame = lastFrame();
expect(frame).toContain('Account Suspended');
expect(frame).toContain('violation of Terms of Service');
expect(frame).toContain('Escape to exit');
unmount();
});
it('renders menu options with appeal link text from response', async () => {
const { waitUntilReady, unmount } = renderWithProviders(
<BannedAccountDialog
accountSuspensionInfo={DEFAULT_SUSPENSION_INFO}
onExit={onExit}
onChangeAuth={onChangeAuth}
/>,
);
await waitUntilReady();
const items = mockedRadioButtonSelect.mock.calls[0][0].items;
expect(items).toHaveLength(3);
expect(items[0].label).toBe('Appeal Here');
expect(items[1].label).toBe('Change authentication');
expect(items[2].label).toBe('Exit');
unmount();
});
it('hides form option when no appealUrl is provided', async () => {
const infoWithoutUrl: AccountSuspensionInfo = {
message: 'Account suspended.',
};
const { waitUntilReady, unmount } = renderWithProviders(
<BannedAccountDialog
accountSuspensionInfo={infoWithoutUrl}
onExit={onExit}
onChangeAuth={onChangeAuth}
/>,
);
await waitUntilReady();
const items = mockedRadioButtonSelect.mock.calls[0][0].items;
expect(items).toHaveLength(2);
expect(items[0].label).toBe('Change authentication');
expect(items[1].label).toBe('Exit');
unmount();
});
it('uses default label when appealLinkText is not provided', async () => {
const infoWithoutLinkText: AccountSuspensionInfo = {
message: 'Account suspended.',
appealUrl: 'https://example.com/appeal',
};
const { waitUntilReady, unmount } = renderWithProviders(
<BannedAccountDialog
accountSuspensionInfo={infoWithoutLinkText}
onExit={onExit}
onChangeAuth={onChangeAuth}
/>,
);
await waitUntilReady();
const items = mockedRadioButtonSelect.mock.calls[0][0].items;
expect(items[0].label).toBe('Open the Google Form');
unmount();
});
it('opens browser when appeal option is selected', async () => {
const { waitUntilReady, unmount } = renderWithProviders(
<BannedAccountDialog
accountSuspensionInfo={DEFAULT_SUSPENSION_INFO}
onExit={onExit}
onChangeAuth={onChangeAuth}
/>,
);
await waitUntilReady();
const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0];
await onSelect('open_form');
expect(mockedOpenBrowser).toHaveBeenCalledWith(
'https://example.com/appeal',
);
expect(onExit).not.toHaveBeenCalled();
unmount();
});
it('shows URL when browser cannot be launched', async () => {
mockedShouldLaunchBrowser.mockReturnValue(false);
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<BannedAccountDialog
accountSuspensionInfo={DEFAULT_SUSPENSION_INFO}
onExit={onExit}
onChangeAuth={onChangeAuth}
/>,
);
await waitUntilReady();
const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0];
onSelect('open_form');
await waitFor(() => {
expect(lastFrame()).toContain('Please open this URL in a browser');
});
expect(mockedOpenBrowser).not.toHaveBeenCalled();
unmount();
});
it('calls onExit when "Exit" is selected', async () => {
const { waitUntilReady, unmount } = renderWithProviders(
<BannedAccountDialog
accountSuspensionInfo={DEFAULT_SUSPENSION_INFO}
onExit={onExit}
onChangeAuth={onChangeAuth}
/>,
);
await waitUntilReady();
const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0];
await onSelect('exit');
expect(mockedRunExitCleanup).toHaveBeenCalled();
expect(onExit).toHaveBeenCalled();
unmount();
});
it('calls onChangeAuth when "Change authentication" is selected', async () => {
const { waitUntilReady, unmount } = renderWithProviders(
<BannedAccountDialog
accountSuspensionInfo={DEFAULT_SUSPENSION_INFO}
onExit={onExit}
onChangeAuth={onChangeAuth}
/>,
);
await waitUntilReady();
const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0];
onSelect('change_auth');
expect(onChangeAuth).toHaveBeenCalled();
expect(onExit).not.toHaveBeenCalled();
unmount();
});
it('exits on escape key', async () => {
const { waitUntilReady, unmount } = renderWithProviders(
<BannedAccountDialog
accountSuspensionInfo={DEFAULT_SUSPENSION_INFO}
onExit={onExit}
onChangeAuth={onChangeAuth}
/>,
);
await waitUntilReady();
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
const result = keypressHandler({ name: 'escape' });
expect(result).toBe(true);
unmount();
});
it('renders snapshot correctly', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<BannedAccountDialog
accountSuspensionInfo={DEFAULT_SUSPENSION_INFO}
onExit={onExit}
onChangeAuth={onChangeAuth}
/>,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
});
@@ -1,138 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useCallback, useMemo, useState } from 'react';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js';
import { useKeypress } from '../hooks/useKeypress.js';
import {
openBrowserSecurely,
shouldLaunchBrowser,
} from '@google/gemini-cli-core';
import { runExitCleanup } from '../../utils/cleanup.js';
import type { AccountSuspensionInfo } from '../contexts/UIStateContext.js';
interface BannedAccountDialogProps {
accountSuspensionInfo: AccountSuspensionInfo;
onExit: () => void;
onChangeAuth: () => void;
}
export function BannedAccountDialog({
accountSuspensionInfo,
onExit,
onChangeAuth,
}: BannedAccountDialogProps): React.JSX.Element {
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const appealUrl = accountSuspensionInfo.appealUrl;
const appealLinkText =
accountSuspensionInfo.appealLinkText ?? 'Open the Google Form';
const items = useMemo(() => {
const menuItems = [];
if (appealUrl) {
menuItems.push({
label: appealLinkText,
value: 'open_form' as const,
key: 'open_form',
});
}
menuItems.push(
{
label: 'Change authentication',
value: 'change_auth' as const,
key: 'change_auth',
},
{
label: 'Exit',
value: 'exit' as const,
key: 'exit',
},
);
return menuItems;
}, [appealUrl, appealLinkText]);
useKeypress(
(key) => {
if (key.name === 'escape') {
void handleExit();
return true;
}
return false;
},
{ isActive: true },
);
const handleExit = useCallback(async () => {
await runExitCleanup();
onExit();
}, [onExit]);
const handleSelect = useCallback(
async (choice: string) => {
if (choice === 'open_form' && appealUrl) {
if (!shouldLaunchBrowser()) {
setErrorMessage(`Please open this URL in a browser: ${appealUrl}`);
return;
}
try {
await openBrowserSecurely(appealUrl);
} catch {
setErrorMessage(`Failed to open browser. Please visit: ${appealUrl}`);
}
} else if (choice === 'change_auth') {
onChangeAuth();
} else {
await handleExit();
}
},
[handleExit, onChangeAuth, appealUrl],
);
return (
<Box flexDirection="column" padding={1}>
<Text bold color={theme.status.error}>
Error: Account Suspended
</Text>
<Box marginTop={1}>
<Text>{accountSuspensionInfo.message}</Text>
</Box>
{appealUrl && (
<>
<Box marginTop={1}>
<Text>Appeal URL:</Text>
</Box>
<Box>
<Text color={theme.text.link}>[{appealUrl}]</Text>
</Box>
</>
)}
{errorMessage && (
<Box marginTop={1}>
<Text color={theme.status.error}>{errorMessage}</Text>
</Box>
)}
<Box marginTop={1}>
<RadioButtonSelect
items={items}
onSelect={(choice) => void handleSelect(choice)}
/>
</Box>
<Box marginTop={1}>
<Text dimColor>Escape to exit</Text>
</Box>
</Box>
);
}
@@ -1,17 +0,0 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`BannedAccountDialog > renders snapshot correctly 1`] = `
"
Error: Account Suspended
This service has been disabled in this account for violation of Terms of Service. Please submit an
appeal to continue using this product.
Appeal URL:
[https://example.com/appeal]
Appeal HereChange authenticationExit
Escape to exit
"
`;
+1 -18
View File
@@ -11,7 +11,6 @@ import {
type Config,
loadApiKey,
debugLogger,
isAccountSuspendedError,
} from '@google/gemini-cli-core';
import { getErrorMessage } from '@google/gemini-cli-core';
import { AuthState } from '../types.js';
@@ -35,21 +34,16 @@ export function validateAuthMethodWithSettings(
return validateAuthMethod(authType);
}
import type { AccountSuspensionInfo } from '../contexts/UIStateContext.js';
export const useAuthCommand = (
settings: LoadedSettings,
config: Config,
initialAuthError: string | null = null,
initialAccountSuspensionInfo: AccountSuspensionInfo | null = null,
) => {
const [authState, setAuthState] = useState<AuthState>(
initialAuthError ? AuthState.Updating : AuthState.Unauthenticated,
);
const [authError, setAuthError] = useState<string | null>(initialAuthError);
const [accountSuspensionInfo, setAccountSuspensionInfo] =
useState<AccountSuspensionInfo | null>(initialAccountSuspensionInfo);
const [apiKeyDefaultValue, setApiKeyDefaultValue] = useState<
string | undefined
>(undefined);
@@ -136,16 +130,7 @@ export const useAuthCommand = (
setAuthError(null);
setAuthState(AuthState.Authenticated);
} catch (e) {
const suspendedError = isAccountSuspendedError(e);
if (suspendedError) {
setAccountSuspensionInfo({
message: suspendedError.message,
appealUrl: suspendedError.appealUrl,
appealLinkText: suspendedError.appealLinkText,
});
} else {
onAuthError(`Failed to login. Message: ${getErrorMessage(e)}`);
}
onAuthError(`Failed to login. Message: ${getErrorMessage(e)}`);
}
})();
}, [
@@ -165,7 +150,5 @@ export const useAuthCommand = (
onAuthError,
apiKeyDefaultValue,
reloadApiKey,
accountSuspensionInfo,
setAccountSuspensionInfo,
};
};
@@ -13,7 +13,6 @@ import { ThemeDialog } from './ThemeDialog.js';
import { SettingsDialog } from './SettingsDialog.js';
import { AuthInProgress } from '../auth/AuthInProgress.js';
import { AuthDialog } from '../auth/AuthDialog.js';
import { BannedAccountDialog } from '../auth/BannedAccountDialog.js';
import { ApiAuthDialog } from '../auth/ApiAuthDialog.js';
import { EditorSettingsDialog } from './EditorSettingsDialog.js';
import { PrivacyNotice } from '../privacy/PrivacyNotice.js';
@@ -296,21 +295,6 @@ export const DialogManager = ({
</Box>
);
}
if (uiState.accountSuspensionInfo) {
return (
<Box flexDirection="column">
<BannedAccountDialog
accountSuspensionInfo={uiState.accountSuspensionInfo}
onExit={() => {
process.exit(1);
}}
onChangeAuth={() => {
uiActions.clearAccountSuspension();
}}
/>
</Box>
);
}
if (uiState.isAuthenticating) {
return (
<AuthInProgress
@@ -333,7 +317,6 @@ export const DialogManager = ({
</Box>
);
}
if (uiState.isAuthDialogOpen) {
return (
<Box flexDirection="column">
@@ -87,8 +87,6 @@ export interface UIActions {
onHintSubmit: (hint: string) => void;
handleRestart: () => void;
handleNewAgentsSelect: (choice: NewAgentsChoice) => Promise<void>;
getPreferredEditor: () => EditorType | undefined;
clearAccountSuspension: () => void;
}
export const UIActionsContext = createContext<UIActions | null>(null);
@@ -64,12 +64,6 @@ export interface QuotaState {
validationRequest: ValidationDialogRequest | null;
}
export interface AccountSuspensionInfo {
message: string;
appealUrl?: string;
appealLinkText?: string;
}
export interface UIState {
history: HistoryItem[];
historyManager: UseHistoryManagerReturn;
@@ -80,7 +74,6 @@ export interface UIState {
isAuthenticating: boolean;
isConfigInitialized: boolean;
authError: string | null;
accountSuspensionInfo: AccountSuspensionInfo | null;
isAuthDialogOpen: boolean;
isAwaitingApiKeyInput: boolean;
apiKeyDefaultValue?: string;
+1 -1
View File
@@ -14,5 +14,5 @@
"./package.json"
],
"exclude": ["node_modules", "dist"],
"references": [{ "path": "../core" }]
"references": [{ "path": "../core" }, { "path": "../devtools" }]
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-core",
"version": "0.31.0-preview.3",
"version": "0.30.0-nightly.20260210.a2174751d",
"description": "Gemini CLI Core",
"license": "Apache-2.0",
"repository": {
+4 -1
View File
@@ -169,7 +169,10 @@ export class Storage {
}
getAutoSavedPolicyPath(): string {
return path.join(Storage.getUserPoliciesDir(), AUTO_SAVED_POLICY_FILENAME);
return path.join(
this.getWorkspacePoliciesDir(),
AUTO_SAVED_POLICY_FILENAME,
);
}
ensureProjectTempDirExists(): void {
+2 -1
View File
@@ -447,8 +447,9 @@ export function createPolicyUpdater(
if (message.persist) {
persistenceQueue = persistenceQueue.then(async () => {
try {
const workspacePoliciesDir = storage.getWorkspacePoliciesDir();
await fs.mkdir(workspacePoliciesDir, { recursive: true });
const policyFile = storage.getAutoSavedPolicyPath();
await fs.mkdir(path.dirname(policyFile), { recursive: true });
// Read existing file
let existingData: { rule?: TomlRule[] } = {};
+34 -9
View File
@@ -48,8 +48,14 @@ describe('createPolicyUpdater', () => {
it('should persist policy when persist flag is true', async () => {
createPolicyUpdater(policyEngine, messageBus, mockStorage);
const userPoliciesDir = '/mock/user/.gemini/policies';
const policyFile = path.join(userPoliciesDir, AUTO_SAVED_POLICY_FILENAME);
const workspacePoliciesDir = '/mock/project/.gemini/policies';
const policyFile = path.join(
workspacePoliciesDir,
AUTO_SAVED_POLICY_FILENAME,
);
vi.spyOn(mockStorage, 'getWorkspacePoliciesDir').mockReturnValue(
workspacePoliciesDir,
);
vi.spyOn(mockStorage, 'getAutoSavedPolicyPath').mockReturnValue(policyFile);
(fs.mkdir as unknown as Mock).mockResolvedValue(undefined);
(fs.readFile as unknown as Mock).mockRejectedValue(
@@ -73,7 +79,8 @@ describe('createPolicyUpdater', () => {
// Wait for async operations (microtasks)
await new Promise((resolve) => setTimeout(resolve, 0));
expect(fs.mkdir).toHaveBeenCalledWith(userPoliciesDir, {
expect(mockStorage.getWorkspacePoliciesDir).toHaveBeenCalled();
expect(fs.mkdir).toHaveBeenCalledWith(workspacePoliciesDir, {
recursive: true,
});
@@ -108,8 +115,14 @@ describe('createPolicyUpdater', () => {
it('should persist policy with commandPrefix when provided', async () => {
createPolicyUpdater(policyEngine, messageBus, mockStorage);
const userPoliciesDir = '/mock/user/.gemini/policies';
const policyFile = path.join(userPoliciesDir, AUTO_SAVED_POLICY_FILENAME);
const workspacePoliciesDir = '/mock/project/.gemini/policies';
const policyFile = path.join(
workspacePoliciesDir,
AUTO_SAVED_POLICY_FILENAME,
);
vi.spyOn(mockStorage, 'getWorkspacePoliciesDir').mockReturnValue(
workspacePoliciesDir,
);
vi.spyOn(mockStorage, 'getAutoSavedPolicyPath').mockReturnValue(policyFile);
(fs.mkdir as unknown as Mock).mockResolvedValue(undefined);
(fs.readFile as unknown as Mock).mockRejectedValue(
@@ -155,8 +168,14 @@ describe('createPolicyUpdater', () => {
it('should persist policy with mcpName and toolName when provided', async () => {
createPolicyUpdater(policyEngine, messageBus, mockStorage);
const userPoliciesDir = '/mock/user/.gemini/policies';
const policyFile = path.join(userPoliciesDir, AUTO_SAVED_POLICY_FILENAME);
const workspacePoliciesDir = '/mock/project/.gemini/policies';
const policyFile = path.join(
workspacePoliciesDir,
AUTO_SAVED_POLICY_FILENAME,
);
vi.spyOn(mockStorage, 'getWorkspacePoliciesDir').mockReturnValue(
workspacePoliciesDir,
);
vi.spyOn(mockStorage, 'getAutoSavedPolicyPath').mockReturnValue(policyFile);
(fs.mkdir as unknown as Mock).mockResolvedValue(undefined);
(fs.readFile as unknown as Mock).mockRejectedValue(
@@ -195,8 +214,14 @@ describe('createPolicyUpdater', () => {
it('should escape special characters in toolName and mcpName', async () => {
createPolicyUpdater(policyEngine, messageBus, mockStorage);
const userPoliciesDir = '/mock/user/.gemini/policies';
const policyFile = path.join(userPoliciesDir, AUTO_SAVED_POLICY_FILENAME);
const workspacePoliciesDir = '/mock/project/.gemini/policies';
const policyFile = path.join(
workspacePoliciesDir,
AUTO_SAVED_POLICY_FILENAME,
);
vi.spyOn(mockStorage, 'getWorkspacePoliciesDir').mockReturnValue(
workspacePoliciesDir,
);
vi.spyOn(mockStorage, 'getAutoSavedPolicyPath').mockReturnValue(policyFile);
(fs.mkdir as unknown as Mock).mockResolvedValue(undefined);
(fs.readFile as unknown as Mock).mockRejectedValue(
@@ -50,8 +50,8 @@ describe('createPolicyUpdater', () => {
messageBus = new MessageBus(policyEngine);
mockStorage = new Storage('/mock/project');
vi.spyOn(mockStorage, 'getAutoSavedPolicyPath').mockReturnValue(
'/mock/user/.gemini/policies/auto-saved.toml',
vi.spyOn(mockStorage, 'getWorkspacePoliciesDir').mockReturnValue(
'/mock/project/.gemini/policies',
);
});
@@ -1304,7 +1304,21 @@ The user has the ability to modify the \`new_string\` content. If modified, this
"type": "string",
},
"instruction": {
"description": "A clear, semantic instruction for the code change, acting as a high-quality prompt for an expert LLM assistant. It must be self-contained and explain the goal of the change.",
"description": "A clear, semantic instruction for the code change, acting as a high-quality prompt for an expert LLM assistant. It must be self-contained and explain the goal of the change.
A good instruction should concisely answer:
1. WHY is the change needed? (e.g., "To fix a bug where users can be null...")
2. WHERE should the change happen? (e.g., "...in the 'renderUserProfile' function...")
3. WHAT is the high-level change? (e.g., "...add a null check for the 'user' object...")
4. WHAT is the desired outcome? (e.g., "...so that it displays a loading spinner instead of crashing.")
**GOOD Example:** "In the 'calculateTotal' function, correct the sales tax calculation by updating the 'taxRate' constant from 0.05 to 0.075 to reflect the new regional tax laws."
**BAD Examples:**
- "Change the text." (Too vague)
- "Fix the bug." (Doesn't explain the bug or the fix)
- "Replace the line with this new line." (Brittle, just repeats the other parameters)
",
"type": "string",
},
"new_string": {
@@ -1312,7 +1326,7 @@ The user has the ability to modify the \`new_string\` content. If modified, this
"type": "string",
},
"old_string": {
"description": "The exact literal text to replace, unescaped. If this string is not the exact literal text (i.e. you escaped it) or does not match exactly, the tool will fail.",
"description": "The exact literal text to replace, preferably unescaped. For single replacements (default), include at least 3 lines of context BEFORE and AFTER the target text, matching whitespace and indentation precisely. If this string is not the exact literal text (i.e. you escaped it) or does not match exactly, the tool will fail.",
"type": "string",
},
},
@@ -300,12 +300,26 @@ The user has the ability to modify the \`new_string\` content. If modified, this
type: 'string',
},
instruction: {
description: `A clear, semantic instruction for the code change, acting as a high-quality prompt for an expert LLM assistant. It must be self-contained and explain the goal of the change.`,
description: `A clear, semantic instruction for the code change, acting as a high-quality prompt for an expert LLM assistant. It must be self-contained and explain the goal of the change.
A good instruction should concisely answer:
1. WHY is the change needed? (e.g., "To fix a bug where users can be null...")
2. WHERE should the change happen? (e.g., "...in the 'renderUserProfile' function...")
3. WHAT is the high-level change? (e.g., "...add a null check for the 'user' object...")
4. WHAT is the desired outcome? (e.g., "...so that it displays a loading spinner instead of crashing.")
**GOOD Example:** "In the 'calculateTotal' function, correct the sales tax calculation by updating the 'taxRate' constant from 0.05 to 0.075 to reflect the new regional tax laws."
**BAD Examples:**
- "Change the text." (Too vague)
- "Fix the bug." (Doesn't explain the bug or the fix)
- "Replace the line with this new line." (Brittle, just repeats the other parameters)
`,
type: 'string',
},
old_string: {
description:
'The exact literal text to replace, unescaped. If this string is not the exact literal text (i.e. you escaped it) or does not match exactly, the tool will fail.',
'The exact literal text to replace, preferably unescaped. For single replacements (default), include at least 3 lines of context BEFORE and AFTER the target text, matching whitespace and indentation precisely. If this string is not the exact literal text (i.e. you escaped it) or does not match exactly, the tool will fail.',
type: 'string',
},
new_string: {
@@ -1792,80 +1792,6 @@ describe('mcp-client', () => {
expect(callArgs.env!['GEMINI_CLI_EXT_VAR']).toBeUndefined();
});
it('should include extension settings with defined values in environment', async () => {
const mockedTransport = vi
.spyOn(SdkClientStdioLib, 'StdioClientTransport')
.mockReturnValue({} as SdkClientStdioLib.StdioClientTransport);
await createTransport(
'test-server',
{
command: 'test-command',
extension: {
name: 'test-ext',
resolvedSettings: [
{
envVar: 'GEMINI_CLI_EXT_VAR',
value: 'defined-value',
sensitive: false,
name: 'ext-setting',
},
],
version: '',
isActive: false,
path: '',
contextFiles: [],
id: '',
},
},
false,
EMPTY_CONFIG,
);
const callArgs = mockedTransport.mock.calls[0][0];
expect(callArgs.env).toBeDefined();
expect(callArgs.env!['GEMINI_CLI_EXT_VAR']).toBe('defined-value');
});
it('should resolve environment variables in mcpServerConfig.env using extension settings', async () => {
const mockedTransport = vi
.spyOn(SdkClientStdioLib, 'StdioClientTransport')
.mockReturnValue({} as SdkClientStdioLib.StdioClientTransport);
await createTransport(
'test-server',
{
command: 'test-command',
env: {
RESOLVED_VAR: '$GEMINI_CLI_EXT_VAR',
},
extension: {
name: 'test-ext',
resolvedSettings: [
{
envVar: 'GEMINI_CLI_EXT_VAR',
value: 'ext-value',
sensitive: false,
name: 'ext-setting',
},
],
version: '',
isActive: false,
path: '',
contextFiles: [],
id: '',
},
},
false,
EMPTY_CONFIG,
);
const callArgs = mockedTransport.mock.calls[0][0];
expect(callArgs.env).toBeDefined();
expect(callArgs.env!['GEMINI_CLI_EXT_VAR']).toBe('ext-value');
expect(callArgs.env!['RESOLVED_VAR']).toBe('ext-value');
});
it('should expand environment variables in mcpServerConfig.env and not redact them', async () => {
const mockedTransport = vi
.spyOn(SdkClientStdioLib, 'StdioClientTransport')
+6 -53
View File
@@ -34,11 +34,7 @@ import {
ProgressNotificationSchema,
} from '@modelcontextprotocol/sdk/types.js';
import { parse } from 'shell-quote';
import type {
Config,
MCPServerConfig,
GeminiCLIExtension,
} from '../config/config.js';
import type { Config, MCPServerConfig } from '../config/config.js';
import { AuthProviderType } from '../config/config.js';
import { GoogleCredentialProvider } from '../mcp/google-auth-provider.js';
import { ServiceAccountImpersonationProvider } from '../mcp/sa-impersonation-provider.js';
@@ -782,25 +778,15 @@ async function handleAutomaticOAuth(
*
* @param mcpServerConfig The MCP server configuration
* @param headers Additional headers
* @param sanitizationConfig Configuration for environment sanitization
*/
function createTransportRequestInit(
mcpServerConfig: MCPServerConfig,
headers: Record<string, string>,
sanitizationConfig: EnvironmentSanitizationConfig,
): RequestInit {
const extensionEnv = getExtensionEnvironment(mcpServerConfig.extension);
const expansionEnv = { ...process.env, ...extensionEnv };
const sanitizedEnv = sanitizeEnvironment(expansionEnv, {
...sanitizationConfig,
enableEnvironmentVariableRedaction: true,
});
const expandedHeaders: Record<string, string> = {};
if (mcpServerConfig.headers) {
for (const [key, value] of Object.entries(mcpServerConfig.headers)) {
expandedHeaders[key] = expandEnvVars(value, sanitizedEnv);
expandedHeaders[key] = expandEnvVars(value, process.env);
}
}
@@ -840,14 +826,12 @@ function createAuthProvider(
* @param mcpServerName The name of the MCP server
* @param mcpServerConfig The MCP server configuration
* @param accessToken The OAuth access token
* @param sanitizationConfig Configuration for environment sanitization
* @returns The transport with OAuth token, or null if creation fails
*/
async function createTransportWithOAuth(
mcpServerName: string,
mcpServerConfig: MCPServerConfig,
accessToken: string,
sanitizationConfig: EnvironmentSanitizationConfig,
): Promise<StreamableHTTPClientTransport | SSEClientTransport | null> {
try {
const headers: Record<string, string> = {
@@ -856,11 +840,7 @@ async function createTransportWithOAuth(
const transportOptions:
| StreamableHTTPClientTransportOptions
| SSEClientTransportOptions = {
requestInit: createTransportRequestInit(
mcpServerConfig,
headers,
sanitizationConfig,
),
requestInit: createTransportRequestInit(mcpServerConfig, headers),
};
return createUrlTransport(mcpServerName, mcpServerConfig, transportOptions);
@@ -1455,7 +1435,6 @@ async function showAuthRequiredMessage(serverName: string): Promise<never> {
* @param config The MCP server configuration
* @param accessToken The OAuth access token to use
* @param httpReturned404 Whether the HTTP transport returned 404 (indicating SSE-only server)
* @param sanitizationConfig Configuration for environment sanitization
*/
async function retryWithOAuth(
client: Client,
@@ -1463,7 +1442,6 @@ async function retryWithOAuth(
config: MCPServerConfig,
accessToken: string,
httpReturned404: boolean,
sanitizationConfig: EnvironmentSanitizationConfig,
): Promise<void> {
if (httpReturned404) {
// HTTP returned 404, only try SSE
@@ -1484,7 +1462,6 @@ async function retryWithOAuth(
serverName,
config,
accessToken,
sanitizationConfig,
);
if (!httpTransport) {
throw new Error(
@@ -1764,7 +1741,6 @@ export async function connectToMcpServer(
mcpServerConfig,
accessToken,
httpReturned404,
sanitizationConfig,
);
return mcpClient;
} else {
@@ -1837,7 +1813,6 @@ export async function connectToMcpServer(
mcpServerName,
mcpServerConfig,
accessToken,
sanitizationConfig,
);
if (!oauthTransport) {
throw new Error(
@@ -1985,11 +1960,7 @@ export async function createTransport(
const transportOptions:
| StreamableHTTPClientTransportOptions
| SSEClientTransportOptions = {
requestInit: createTransportRequestInit(
mcpServerConfig,
headers,
sanitizationConfig,
),
requestInit: createTransportRequestInit(mcpServerConfig, headers),
authProvider,
};
@@ -1997,11 +1968,8 @@ export async function createTransport(
}
if (mcpServerConfig.command) {
const extensionEnv = getExtensionEnvironment(mcpServerConfig.extension);
const expansionEnv = { ...process.env, ...extensionEnv };
// 1. Sanitize the base process environment to prevent unintended leaks of system-wide secrets.
const sanitizedEnv = sanitizeEnvironment(expansionEnv, {
const sanitizedEnv = sanitizeEnvironment(process.env, {
...sanitizationConfig,
enableEnvironmentVariableRedaction: true,
});
@@ -2009,7 +1977,6 @@ export async function createTransport(
const finalEnv: Record<string, string> = {
[GEMINI_CLI_IDENTIFICATION_ENV_VAR]:
GEMINI_CLI_IDENTIFICATION_ENV_VAR_VALUE,
...extensionEnv,
};
for (const [key, value] of Object.entries(sanitizedEnv)) {
if (value !== undefined) {
@@ -2020,7 +1987,7 @@ export async function createTransport(
// Expand and merge explicit environment variables from the MCP configuration.
if (mcpServerConfig.env) {
for (const [key, value] of Object.entries(mcpServerConfig.env)) {
finalEnv[key] = expandEnvVars(value, expansionEnv);
finalEnv[key] = expandEnvVars(value, process.env);
}
}
@@ -2078,20 +2045,6 @@ interface NamedTool {
name?: string;
}
function getExtensionEnvironment(
extension?: GeminiCLIExtension,
): Record<string, string> {
const env: Record<string, string> = {};
if (extension?.resolvedSettings) {
for (const setting of extension.resolvedSettings) {
if (setting.value !== undefined) {
env[setting.envVar] = setting.value;
}
}
}
return env;
}
/** Visible for testing */
export function isEnabled(
funcDecl: NamedTool,
-81
View File
@@ -11,7 +11,6 @@ import {
toFriendlyError,
BadRequestError,
ForbiddenError,
AccountSuspendedError,
getErrorMessage,
getErrorType,
FatalAuthenticationError,
@@ -128,86 +127,9 @@ describe('toFriendlyError', () => {
};
const result = toFriendlyError(error);
expect(result).toBeInstanceOf(ForbiddenError);
expect(result).not.toBeInstanceOf(AccountSuspendedError);
expect((result as ForbiddenError).message).toBe('Forbidden');
});
it('should return AccountSuspendedError for 403 with TOS_VIOLATION reason in details', () => {
const error = {
response: {
data: {
error: {
code: 403,
message:
'This service has been disabled in this account for violation of Terms of Service.',
details: [
{
'@type': 'type.googleapis.com/google.rpc.ErrorInfo',
reason: 'TOS_VIOLATION',
domain: 'example.googleapis.com',
metadata: {
uiMessage: 'true',
appeal_url_link_text: 'Appeal Here',
appeal_url: 'https://example.com/appeal',
},
},
],
},
},
},
};
const result = toFriendlyError(error);
expect(result).toBeInstanceOf(AccountSuspendedError);
expect(result).toBeInstanceOf(ForbiddenError);
const suspended = result as AccountSuspendedError;
expect(suspended.message).toBe(
'This service has been disabled in this account for violation of Terms of Service.',
);
expect(suspended.appealUrl).toBe('https://example.com/appeal');
expect(suspended.appealLinkText).toBe('Appeal Here');
});
it('should return ForbiddenError for 403 with violation message but no TOS_VIOLATION detail', () => {
const error = {
response: {
data: {
error: {
code: 403,
message:
'This service has been disabled in this account for violation of Terms of Service.',
},
},
},
};
const result = toFriendlyError(error);
expect(result).toBeInstanceOf(ForbiddenError);
expect(result).not.toBeInstanceOf(AccountSuspendedError);
});
it('should return ForbiddenError for 403 with non-TOS_VIOLATION detail', () => {
const error = {
response: {
data: {
error: {
code: 403,
message: 'Forbidden',
details: [
{
'@type': 'type.googleapis.com/google.rpc.ErrorInfo',
reason: 'ACCESS_DENIED',
domain: 'googleapis.com',
metadata: {},
},
],
},
},
},
};
const result = toFriendlyError(error);
expect(result).toBeInstanceOf(ForbiddenError);
expect(result).not.toBeInstanceOf(AccountSuspendedError);
});
it('should parse stringified JSON data', () => {
const error = {
response: {
@@ -314,9 +236,6 @@ describe('getErrorType', () => {
'FatalCancellationError',
);
expect(getErrorType(new ForbiddenError('test'))).toBe('ForbiddenError');
expect(getErrorType(new AccountSuspendedError('test'))).toBe(
'AccountSuspendedError',
);
expect(getErrorType(new UnauthorizedError('test'))).toBe(
'UnauthorizedError',
);
+3 -38
View File
@@ -4,8 +4,6 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { parseGoogleApiError, type ErrorInfo } from './googleErrors.js';
interface GaxiosError {
response?: {
data?: unknown;
@@ -100,17 +98,6 @@ export class CanceledError extends Error {
}
export class ForbiddenError extends Error {}
export class AccountSuspendedError extends ForbiddenError {
readonly appealUrl?: string;
readonly appealLinkText?: string;
constructor(message: string, metadata?: Record<string, string>) {
super(message);
this.name = 'AccountSuspendedError';
this.appealUrl = metadata?.['appeal_url'];
this.appealLinkText = metadata?.['appeal_url_link_text'];
}
}
export class UnauthorizedError extends Error {}
export class BadRequestError extends Error {}
@@ -161,24 +148,6 @@ function isResponseData(data: unknown): data is ResponseData {
}
export function toFriendlyError(error: unknown): unknown {
// First, try structured parsing for TOS_VIOLATION detection.
const googleApiError = parseGoogleApiError(error);
if (googleApiError && googleApiError.code === 403) {
const tosDetail = googleApiError.details.find(
(d): d is ErrorInfo =>
d['@type'] === 'type.googleapis.com/google.rpc.ErrorInfo' &&
'reason' in d &&
d.reason === 'TOS_VIOLATION',
);
if (tosDetail) {
return new AccountSuspendedError(
googleApiError.message,
tosDetail.metadata,
);
}
}
// Fall back to basic Gaxios error parsing for other HTTP errors.
if (isGaxiosError(error)) {
const data = parseResponseData(error);
if (data && data.error && data.error.message && data.error.code) {
@@ -188,6 +157,9 @@ export function toFriendlyError(error: unknown): unknown {
case 401:
return new UnauthorizedError(data.error.message);
case 403:
// It's import to pass the message here since it might
// explain the cause like "the cloud project you're
// using doesn't have code assist enabled".
return new ForbiddenError(data.error.message);
default:
}
@@ -196,13 +168,6 @@ export function toFriendlyError(error: unknown): unknown {
return error;
}
export function isAccountSuspendedError(
error: unknown,
): AccountSuspendedError | null {
const friendly = toFriendlyError(error);
return friendly instanceof AccountSuspendedError ? friendly : null;
}
function parseResponseData(error: GaxiosError): ResponseData | undefined {
let data = error.response?.data;
// Inexplicably, Gaxios sometimes doesn't JSONify the response data.
+10 -8
View File
@@ -5,7 +5,7 @@
*/
import React, { useState, useEffect, useRef, useMemo } from 'react';
import { useDevToolsData, type ConsoleLog, type NetworkLog } from './hooks';
import { useDevToolsData, type ConsoleLog, type NetworkLog } from './hooks.js';
type ThemeMode = 'light' | 'dark' | null; // null means follow system
@@ -115,7 +115,6 @@ export default function App() {
if (!networkMap.has(id)) {
networkMap.set(id, {
...payload,
type,
timestamp,
id,
} as NetworkLog);
@@ -125,8 +124,7 @@ export default function App() {
networkMap.set(id, {
...existing,
...payload,
// Ensure we don't overwrite the original timestamp or type
type: existing.type,
// Ensure we don't overwrite the original timestamp
timestamp: existing.timestamp,
} as NetworkLog);
}
@@ -158,7 +156,7 @@ export default function App() {
const entries: Array<{ timestamp: number; data: object }> = [];
// Export console logs
filteredConsoleLogs.forEach((log) => {
filteredConsoleLogs.forEach((log: ConsoleLog) => {
entries.push({
timestamp: log.timestamp,
data: {
@@ -171,7 +169,7 @@ export default function App() {
});
// Export network logs
filteredNetworkLogs.forEach((log) => {
filteredNetworkLogs.forEach((log: NetworkLog) => {
entries.push({
timestamp: log.timestamp,
data: {
@@ -230,7 +228,9 @@ export default function App() {
if (selectedSessionId === importedSessionId && importedLogs) {
return importedLogs.console;
}
return consoleLogs.filter((l) => l.sessionId === selectedSessionId);
return consoleLogs.filter(
(l: ConsoleLog) => l.sessionId === selectedSessionId,
);
}, [consoleLogs, selectedSessionId, importedSessionId, importedLogs]);
const filteredNetworkLogs = useMemo(() => {
@@ -238,7 +238,9 @@ export default function App() {
if (selectedSessionId === importedSessionId && importedLogs) {
return importedLogs.network;
}
return networkLogs.filter((l) => l.sessionId === selectedSessionId);
return networkLogs.filter(
(l: NetworkLog) => l.sessionId === selectedSessionId,
);
}, [networkLogs, selectedSessionId, importedSessionId, importedLogs]);
return (
+1 -1
View File
@@ -6,7 +6,7 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import App from './App.js';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-devtools",
"version": "0.31.0-preview.3",
"version": "0.30.0-nightly.20260210.a2174751d",
"license": "Apache-2.0",
"type": "module",
"main": "dist/src/index.js",
+2 -3
View File
@@ -1,10 +1,9 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "dist",
"lib": ["DOM", "DOM.Iterable", "ES2023"],
"jsx": "react-jsx",
"allowImportingTsExtensions": true,
"noEmit": true
"jsx": "react-jsx"
},
"include": ["src", "client/src"]
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-sdk",
"version": "0.31.0-preview.3",
"version": "0.29.0-nightly.20260203.71f46f116",
"description": "Gemini CLI SDK",
"license": "Apache-2.0",
"repository": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-test-utils",
"version": "0.31.0-preview.3",
"version": "0.30.0-nightly.20260210.a2174751d",
"private": true,
"main": "src/index.ts",
"license": "Apache-2.0",
+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.31.0-preview.3",
"version": "0.30.0-nightly.20260210.a2174751d",
"publisher": "google",
"icon": "assets/icon.png",
"repository": {