mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-08-03 21:51:11 -07:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f93b9f849c | |||
| 55d0bf5f8e | |||
| 81217cc906 | |||
| 4325e434f9 | |||
| 29e8f2abf4 |
@@ -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);
|
||||
});
|
||||
});
|
||||
Generated
+36
-10
@@ -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
@@ -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,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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 = []');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -1201,7 +1201,6 @@ describe('startInteractiveUI', () => {
|
||||
const mockWorkspaceRoot = '/root';
|
||||
const mockInitializationResult = {
|
||||
authError: null,
|
||||
accountSuspensionInfo: null,
|
||||
themeError: null,
|
||||
shouldOpenAuthDialog: false,
|
||||
geminiMdFileCount: 0,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
"
|
||||
`;
|
||||
@@ -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;
|
||||
|
||||
@@ -14,5 +14,5 @@
|
||||
"./package.json"
|
||||
],
|
||||
"exclude": ["node_modules", "dist"],
|
||||
"references": [{ "path": "../core" }]
|
||||
"references": [{ "path": "../core" }, { "path": "../devtools" }]
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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[] } = {};
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
+16
-2
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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,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,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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user