Compare commits

...

19 Commits

Author SHA1 Message Date
gemini-cli-robot fdf3019b1b chore(release): v0.35.2 2026-03-26 23:13:19 +00:00
Gal Zahavi d118cbba86 fix(core): allow disabling environment variable redaction (#23927) 2026-03-26 15:32:55 -07:00
Keith Schaab 81c4b8c6b5 fix(a2a-server): A2A server should execute ask policies in interactive mode (#23831) 2026-03-26 15:23:54 -07:00
gemini-cli-robot 420ad3822f chore(release): v0.35.1 2026-03-26 00:00:06 +00:00
A.K.M. Adib 8804fb7701 remove unused params 2026-03-25 13:57:34 -04:00
Adib234 2c9a84f828 fix(policy): relax write_file argsPattern in plan mode to allow paths without session ID (#23695) 2026-03-25 13:50:08 -04:00
Adib234 81675857fd fix(plan): sandbox path resolution in Plan Mode to prevent hallucinations (#22737)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-03-25 13:49:51 -04:00
gemini-cli-robot a3cc1c5682 chore(release): v0.35.0 2026-03-24 20:04:53 +00:00
gemini-cli-robot 9804bd6964 chore(release): v0.35.0-preview.5 2026-03-23 23:15:09 +00:00
gemini-cli-robot e88b56bbcb fix(patch): cherry-pick b2d6dc4 to release/v0.35.0-preview.4-pr-23546 [CONFLICTS] (#23585)
Co-authored-by: Abhi <43648792+abhipatel12@users.noreply.github.com>
Co-authored-by: Abhi <abhipatel@google.com>
2026-03-23 22:40:50 +00:00
gemini-cli-robot 63c6560a38 chore(release): v0.35.0-preview.4 2026-03-23 20:05:33 +00:00
Emily Hedlund 596a736a99 fix(extensions): revert broken extension removal behavior (#23317) 2026-03-23 12:26:49 -07:00
Sehoon Shon a0ee9bac71 feat(core): refine User-Agent for VS Code traffic (unified format) (#23256) 2026-03-23 12:26:47 -07:00
Yuna Seol e9ea7b3e04 feat(core): set up onboarding telemetry (#23118)
Co-authored-by: Yuna Seol <yunaseol@google.com>
2026-03-23 12:26:45 -07:00
gemini-cli-robot 22580dd826 chore(release): v0.35.0-preview.3 2026-03-23 18:49:15 +00:00
gemini-cli-robot d1de82c46f fix(patch): cherry-pick daf3691 to release/v0.35.0-preview.2-pr-23558 to patch version v0.35.0-preview.2 and create version 0.35.0-preview.3 (#23565)
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
2026-03-23 18:16:13 +00:00
gemini-cli-robot 7f8fb6f292 chore(release): v0.35.0-preview.2 2026-03-19 19:10:05 +00:00
gemini-cli-robot dd950088c7 fix(patch): cherry-pick 4e5dfd0 to release/v0.35.0-preview.1-pr-23074 to patch version v0.35.0-preview.1 and create version 0.35.0-preview.2 (#23134)
Co-authored-by: Sandy Tao <sandytao520@icloud.com>
2026-03-19 18:25:32 +00:00
gemini-cli-robot 4b4794ad2f chore(release): v0.35.0-preview.1 2026-03-17 20:58:05 +00:00
69 changed files with 1144 additions and 374 deletions
+28
View File
@@ -901,6 +901,20 @@ Logs keychain availability checks.
- `available` (boolean)
##### `gemini_cli.startup_stats`
Logs detailed startup performance statistics.
<details>
<summary>Attributes</summary>
- `phases` (json array of startup phases)
- `os_platform` (string)
- `os_release` (string)
- `is_docker` (boolean)
</details>
</details>
### Metrics
@@ -917,6 +931,20 @@ Gemini CLI exports several custom metrics.
Incremented once per CLI startup.
##### Onboarding
Tracks onboarding flow from authentication to the user
- `gemini_cli.onboarding.start` (Counter, Int): Incremented when the
authentication flow begins.
- `gemini_cli.onboarding.success` (Counter, Int): Incremented when the user
onboarding flow completes successfully.
<details>
<summary>Attributes (Success)</summary>
- `user_tier` (string)
##### Tools
##### `gemini_cli.tool.call.count`
+1 -1
View File
@@ -1159,7 +1159,7 @@ their corresponding top-level category object in your `settings.json` file.
- **`experimental.enableAgents`** (boolean):
- **Description:** Enable local and remote subagents.
- **Default:** `true`
- **Default:** `false`
- **Requires restart:** Yes
- **`experimental.extensionManagement`** (boolean):
+111
View File
@@ -132,6 +132,32 @@ describe('plan_mode', () => {
expect(wasToolCalled, 'Expected exit_plan_mode tool to be called').toBe(
true,
);
const toolLogs = rig.readToolLogs();
const exitPlanCall = toolLogs.find(
(log) => log.toolRequest.name === 'exit_plan_mode',
);
expect(
exitPlanCall,
'Expected to find exit_plan_mode in tool logs',
).toBeDefined();
const args = JSON.parse(exitPlanCall!.toolRequest.args);
expect(args.plan_filename, 'plan_filename should be a string').toBeTypeOf(
'string',
);
expect(args.plan_filename, 'plan_filename should end with .md').toMatch(
/\.md$/,
);
expect(
args.plan_filename,
'plan_filename should not be a path',
).not.toContain('/');
expect(
args.plan_filename,
'plan_filename should not be a path',
).not.toContain('\\');
assertModelHasOutput(result);
},
});
@@ -166,4 +192,89 @@ describe('plan_mode', () => {
assertModelHasOutput(result);
},
});
evalTest('USUALLY_PASSES', {
name: 'should create a plan in plan mode and implement it for a refactoring task',
params: {
settings,
},
files: {
'src/mathUtils.ts':
'export const sum = (a: number, b: number) => a + b;\nexport const multiply = (a: number, b: number) => a * b;',
'src/main.ts':
'import { sum } from "./mathUtils";\nconsole.log(sum(1, 2));',
},
prompt:
'I want to refactor our math utilities. Move the `sum` function from `src/mathUtils.ts` to a new file `src/basicMath.ts` and update `src/main.ts` to use the new file. Please create a detailed implementation plan first, then execute it.',
assert: async (rig, result) => {
const enterPlanCalled = await rig.waitForToolCall('enter_plan_mode');
expect(
enterPlanCalled,
'Expected enter_plan_mode tool to be called',
).toBe(true);
const exitPlanCalled = await rig.waitForToolCall('exit_plan_mode');
expect(exitPlanCalled, 'Expected exit_plan_mode tool to be called').toBe(
true,
);
await rig.waitForTelemetryReady();
const toolLogs = rig.readToolLogs();
const exitPlanCall = toolLogs.find(
(log) => log.toolRequest.name === 'exit_plan_mode',
);
expect(
exitPlanCall,
'Expected to find exit_plan_mode in tool logs',
).toBeDefined();
const args = JSON.parse(exitPlanCall!.toolRequest.args);
expect(args.plan_filename, 'plan_filename should be a string').toBeTypeOf(
'string',
);
expect(args.plan_filename, 'plan_filename should end with .md').toMatch(
/\.md$/,
);
expect(
args.plan_filename,
'plan_filename should not be a path',
).not.toContain('/');
expect(
args.plan_filename,
'plan_filename should not be a path',
).not.toContain('\\');
// Check if plan was written
const planWrite = toolLogs.find(
(log) =>
log.toolRequest.name === 'write_file' &&
log.toolRequest.args.includes('/plans/'),
);
expect(
planWrite,
'Expected a plan file to be written in the plans directory',
).toBeDefined();
// Check for implementation files
const newFileWrite = toolLogs.find(
(log) =>
log.toolRequest.name === 'write_file' &&
log.toolRequest.args.includes('src/basicMath.ts'),
);
expect(
newFileWrite,
'Expected src/basicMath.ts to be created',
).toBeDefined();
const mainUpdate = toolLogs.find(
(log) =>
['write_file', 'replace'].includes(log.toolRequest.name) &&
log.toolRequest.args.includes('src/main.ts'),
);
expect(mainUpdate, 'Expected src/main.ts to be updated').toBeDefined();
assertModelHasOutput(result);
},
});
});
+92 -58
View File
@@ -4,10 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { TestRig, checkModelOutputContent, GEMINI_DIR } from './test-helper.js';
import { TestRig, checkModelOutputContent } from './test-helper.js';
describe('Plan Mode', () => {
let rig: TestRig;
@@ -36,27 +34,23 @@ describe('Plan Mode', () => {
},
);
// 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.',
args: '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();
const shellLog = toolLogs.find(
(l) => l.toolRequest.name === 'run_shell_command',
);
expect(lsLog, 'Expected list_directory to be called').toBeDefined();
expect(lsLog?.toolRequest.success).toBe(true);
expect(
shellLog,
'Expected run_shell_command to be blocked (not even called)',
).toBeUndefined();
checkModelOutputContent(result, {
expectedContent: ['Plan Mode', 'read-only'],
@@ -84,23 +78,11 @@ describe('Plan Mode', () => {
},
});
// Disable the interactive terminal setup prompt in tests
writeFileSync(
join(rig.homeDir!, GEMINI_DIR, 'state.json'),
JSON.stringify({ terminalSetupPromptShown: true }, null, 2),
);
const run = await rig.runInteractive({
await rig.run({
approvalMode: 'plan',
args: 'Create a file called plan.md in the plans directory.',
});
await run.type('Create a file called plan.md in the plans directory.');
await run.type('\r');
await rig.expectToolCallSuccess(['write_file'], 30000, (args) =>
args.includes('plan.md'),
);
const toolLogs = rig.readToolLogs();
const planWrite = toolLogs.find(
(l) =>
@@ -108,7 +90,25 @@ describe('Plan Mode', () => {
l.toolRequest.args.includes('plans') &&
l.toolRequest.args.includes('plan.md'),
);
expect(planWrite?.toolRequest.success).toBe(true);
if (!planWrite) {
console.error(
'All tool calls found:',
toolLogs.map((l) => ({
name: l.toolRequest.name,
args: l.toolRequest.args,
})),
);
}
expect(
planWrite,
'Expected write_file to be called for plan.md',
).toBeDefined();
expect(
planWrite?.toolRequest.success,
`Expected write_file to succeed, but it failed with error: ${planWrite?.toolRequest.error}`,
).toBe(true);
});
it('should deny write_file to non-plans directory in plan mode', async () => {
@@ -131,19 +131,11 @@ describe('Plan Mode', () => {
},
});
// Disable the interactive terminal setup prompt in tests
writeFileSync(
join(rig.homeDir!, GEMINI_DIR, 'state.json'),
JSON.stringify({ terminalSetupPromptShown: true }, null, 2),
);
const run = await rig.runInteractive({
await rig.run({
approvalMode: 'plan',
args: 'Create a file called hello.txt in the current directory.',
});
await run.type('Create a file called hello.txt in the current directory.');
await run.type('\r');
const toolLogs = rig.readToolLogs();
const writeLog = toolLogs.find(
(l) =>
@@ -151,10 +143,11 @@ describe('Plan Mode', () => {
l.toolRequest.args.includes('hello.txt'),
);
// In Plan Mode, writes outside the plans directory should be blocked.
// Model is undeterministic, sometimes it doesn't even try, but if it does, it must fail.
if (writeLog) {
expect(writeLog.toolRequest.success).toBe(false);
expect(
writeLog.toolRequest.success,
'Expected write_file to non-plans dir to fail',
).toBe(false);
}
});
@@ -169,28 +162,69 @@ describe('Plan Mode', () => {
},
});
// Disable the interactive terminal setup prompt in tests
writeFileSync(
join(rig.homeDir!, GEMINI_DIR, 'state.json'),
JSON.stringify({ terminalSetupPromptShown: true }, null, 2),
);
// 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.',
args: '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');
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, 'Expected enter_plan_mode to be called').toBeDefined();
expect(enterLog?.toolRequest.success).toBe(true);
});
it('should allow write_file to the plans directory in plan mode even without a session ID', async () => {
const plansDir = '.gemini/tmp/foo/plans';
const testName =
'should allow write_file to the plans directory in plan mode even without a session ID';
await rig.setup(testName, {
settings: {
experimental: { plan: true },
tools: {
core: ['write_file', 'read_file', 'list_directory'],
},
general: {
defaultApprovalMode: 'plan',
plan: {
directory: plansDir,
},
},
},
});
await rig.run({
approvalMode: 'plan',
args: 'Create a file called plan-no-session.md in the plans directory.',
});
const toolLogs = rig.readToolLogs();
const planWrite = toolLogs.find(
(l) =>
l.toolRequest.name === 'write_file' &&
l.toolRequest.args.includes('plans') &&
l.toolRequest.args.includes('plan-no-session.md'),
);
if (!planWrite) {
console.error(
'All tool calls found:',
toolLogs.map((l) => ({
name: l.toolRequest.name,
args: l.toolRequest.args,
})),
);
}
expect(
planWrite,
'Expected write_file to be called for plan-no-session.md',
).toBeDefined();
expect(
planWrite?.toolRequest.success,
`Expected write_file to succeed, but it failed with error: ${planWrite?.toolRequest.error}`,
).toBe(true);
});
});
+9 -10
View File
@@ -1,12 +1,12 @@
{
"name": "@google/gemini-cli",
"version": "0.35.0-nightly.20260313.bb060d7a9",
"version": "0.35.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@google/gemini-cli",
"version": "0.35.0-nightly.20260313.bb060d7a9",
"version": "0.35.2",
"workspaces": [
"packages/*"
],
@@ -16231,7 +16231,6 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD"
},
"node_modules/tsx": {
@@ -17414,7 +17413,7 @@
},
"packages/a2a-server": {
"name": "@google/gemini-cli-a2a-server",
"version": "0.35.0-nightly.20260313.bb060d7a9",
"version": "0.35.2",
"dependencies": {
"@a2a-js/sdk": "0.3.11",
"@google-cloud/storage": "^7.16.0",
@@ -17529,7 +17528,7 @@
},
"packages/cli": {
"name": "@google/gemini-cli",
"version": "0.35.0-nightly.20260313.bb060d7a9",
"version": "0.35.2",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "^0.12.0",
@@ -17701,7 +17700,7 @@
},
"packages/core": {
"name": "@google/gemini-cli-core",
"version": "0.35.0-nightly.20260313.bb060d7a9",
"version": "0.35.2",
"license": "Apache-2.0",
"dependencies": {
"@a2a-js/sdk": "0.3.11",
@@ -17967,7 +17966,7 @@
},
"packages/devtools": {
"name": "@google/gemini-cli-devtools",
"version": "0.35.0-nightly.20260313.bb060d7a9",
"version": "0.35.2",
"license": "Apache-2.0",
"dependencies": {
"ws": "^8.16.0"
@@ -17982,7 +17981,7 @@
},
"packages/sdk": {
"name": "@google/gemini-cli-sdk",
"version": "0.35.0-nightly.20260313.bb060d7a9",
"version": "0.35.2",
"license": "Apache-2.0",
"dependencies": {
"@google/gemini-cli-core": "file:../core",
@@ -17999,7 +17998,7 @@
},
"packages/test-utils": {
"name": "@google/gemini-cli-test-utils",
"version": "0.35.0-nightly.20260313.bb060d7a9",
"version": "0.35.2",
"license": "Apache-2.0",
"dependencies": {
"@google/gemini-cli-core": "file:../core",
@@ -18016,7 +18015,7 @@
},
"packages/vscode-ide-companion": {
"name": "gemini-cli-vscode-ide-companion",
"version": "0.35.0-nightly.20260313.bb060d7a9",
"version": "0.35.2",
"license": "LICENSE",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.23.0",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.35.0-nightly.20260313.bb060d7a9",
"version": "0.35.2",
"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.35.0-nightly.20260313.bb060d7a9"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.35.2"
},
"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.35.0-nightly.20260313.bb060d7a9",
"version": "0.35.2",
"description": "Gemini CLI A2A Server",
"repository": {
"type": "git",
+40 -4
View File
@@ -325,24 +325,60 @@ describe('loadConfig', () => {
);
});
it('should pass enableAgents to Config constructor', async () => {
const settings: Settings = {
experimental: {
enableAgents: false,
},
};
await loadConfig(settings, mockExtensionLoader, taskId);
expect(Config).toHaveBeenCalledWith(
expect.objectContaining({
enableAgents: false,
}),
);
});
it('should default enableAgents to false when not provided', async () => {
await loadConfig(mockSettings, mockExtensionLoader, taskId);
expect(Config).toHaveBeenCalledWith(
expect.objectContaining({
enableAgents: false,
}),
);
});
describe('interactivity', () => {
it('should set interactive true when not headless', async () => {
it('should always set interactive true', async () => {
vi.mocked(isHeadlessMode).mockReturnValue(true);
await loadConfig(mockSettings, mockExtensionLoader, taskId);
expect(Config).toHaveBeenCalledWith(
expect.objectContaining({
interactive: true,
}),
);
vi.mocked(isHeadlessMode).mockReturnValue(false);
await loadConfig(mockSettings, mockExtensionLoader, taskId);
expect(Config).toHaveBeenCalledWith(
expect.objectContaining({
interactive: true,
enableInteractiveShell: true,
}),
);
});
it('should set interactive false when headless', async () => {
it('should set enableInteractiveShell based on headless mode', async () => {
vi.mocked(isHeadlessMode).mockReturnValue(false);
await loadConfig(mockSettings, mockExtensionLoader, taskId);
expect(Config).toHaveBeenCalledWith(
expect.objectContaining({
enableInteractiveShell: true,
}),
);
vi.mocked(isHeadlessMode).mockReturnValue(true);
await loadConfig(mockSettings, mockExtensionLoader, taskId);
expect(Config).toHaveBeenCalledWith(
expect.objectContaining({
interactive: false,
enableInteractiveShell: false,
}),
);
+2 -1
View File
@@ -107,9 +107,10 @@ export async function loadConfig(
trustedFolder: true,
extensionLoader,
checkpointing,
interactive: !isHeadlessMode(),
interactive: true,
enableInteractiveShell: !isHeadlessMode(),
ptyInfo: 'auto',
enableAgents: settings.experimental?.enableAgents ?? false,
};
const fileService = new FileDiscoveryService(workspaceDir, {
@@ -48,6 +48,9 @@ export interface Settings {
enableRecursiveFileSearch?: boolean;
customIgnoreFilePaths?: string[];
};
experimental?: {
enableAgents?: boolean;
};
}
export interface SettingsError {
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.35.0-nightly.20260313.bb060d7a9",
"version": "0.35.2",
"description": "Gemini CLI",
"license": "Apache-2.0",
"repository": {
@@ -26,7 +26,7 @@
"dist"
],
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.35.0-nightly.20260313.bb060d7a9"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.35.2"
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.12.0",
@@ -637,64 +637,4 @@ describe('ExtensionManager', () => {
);
});
});
describe('orphaned extension cleanup', () => {
it('should remove broken extension metadata on startup to allow re-installation', async () => {
const extName = 'orphaned-ext';
const sourceDir = path.join(tempHomeDir, 'valid-source');
fs.mkdirSync(sourceDir, { recursive: true });
fs.writeFileSync(
path.join(sourceDir, 'gemini-extension.json'),
JSON.stringify({ name: extName, version: '1.0.0' }),
);
// Link an extension successfully.
await extensionManager.loadExtensions();
await extensionManager.installOrUpdateExtension({
source: sourceDir,
type: 'link',
});
const destinationPath = path.join(userExtensionsDir, extName);
const metadataPath = path.join(
destinationPath,
'.gemini-extension-install.json',
);
expect(fs.existsSync(metadataPath)).toBe(true);
// Simulate metadata corruption (e.g., pointing to a non-existent source).
fs.writeFileSync(
metadataPath,
JSON.stringify({ source: '/NON_EXISTENT_PATH', type: 'link' }),
);
// Simulate CLI startup. The manager should detect the broken link
// and proactively delete the orphaned metadata directory.
const newManager = new ExtensionManager({
settings: createTestMergedSettings(),
workspaceDir: tempWorkspaceDir,
requestConsent: vi.fn().mockResolvedValue(true),
requestSetting: null,
integrityManager: mockIntegrityManager,
});
await newManager.loadExtensions();
// Verify the extension failed to load and was proactively cleaned up.
expect(newManager.getExtensions().some((e) => e.name === extName)).toBe(
false,
);
expect(fs.existsSync(destinationPath)).toBe(false);
// Verify the system is self-healed and allows re-linking to the valid source.
await newManager.installOrUpdateExtension({
source: sourceDir,
type: 'link',
});
expect(newManager.getExtensions().some((e) => e.name === extName)).toBe(
true,
);
});
});
});
+4 -11
View File
@@ -982,18 +982,11 @@ Would you like to attempt to install via "git clone" instead?`,
plan: config.plan,
};
} catch (e) {
const extName = path.basename(extensionDir);
debugLogger.warn(
`Warning: Removing broken extension ${extName}: ${getErrorMessage(e)}`,
debugLogger.error(
`Warning: Skipping extension in ${effectiveExtensionPath}: ${getErrorMessage(
e,
)}`,
);
try {
await fs.promises.rm(extensionDir, { recursive: true, force: true });
} catch (rmError) {
debugLogger.error(
`Failed to remove broken extension directory ${extensionDir}:`,
rmError,
);
}
return null;
}
}
+18 -10
View File
@@ -249,8 +249,10 @@ describe('extension tests', () => {
expect(extensions[0].name).toBe('test-extension');
});
it('should log a warning and remove the extension if a context file path is outside the extension directory', async () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
it('should skip the extension if a context file path is outside the extension directory and log an error', async () => {
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
createExtension({
extensionsDir: userExtensionsDir,
name: 'traversal-extension',
@@ -660,8 +662,10 @@ name = "yolo-checker"
expect(serverConfig.env!['MISSING_VAR_BRACES']).toBe('${ALSO_UNDEFINED}');
});
it('should remove an extension with invalid JSON config and log a warning', async () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
it('should skip an extension with invalid JSON config and log an error', async () => {
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
// Good extension
createExtension({
@@ -682,15 +686,17 @@ name = "yolo-checker"
expect(extensions[0].name).toBe('good-ext');
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining(
`Warning: Removing broken extension bad-ext: Failed to load extension config from ${badConfigPath}`,
`Warning: Skipping extension in ${badExtDir}: Failed to load extension config from ${badConfigPath}`,
),
);
consoleSpy.mockRestore();
});
it('should remove an extension with missing "name" in config and log a warning', async () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
it('should skip an extension with missing "name" in config and log an error', async () => {
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
// Good extension
createExtension({
@@ -711,7 +717,7 @@ name = "yolo-checker"
expect(extensions[0].name).toBe('good-ext');
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining(
`Warning: Removing broken extension bad-ext-no-name: Failed to load extension config from ${badConfigPath}: Invalid configuration in ${badConfigPath}: missing "name"`,
`Warning: Skipping extension in ${badExtDir}: Failed to load extension config from ${badConfigPath}: Invalid configuration in ${badConfigPath}: missing "name"`,
),
);
@@ -737,8 +743,10 @@ name = "yolo-checker"
expect(extensions[0].mcpServers?.['test-server'].trust).toBeUndefined();
});
it('should log a warning for invalid extension names during loading', async () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
it('should log an error for invalid extension names during loading', async () => {
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
createExtension({
extensionsDir: userExtensionsDir,
name: 'bad_name',
@@ -400,7 +400,7 @@ describe('SettingsSchema', () => {
expect(setting).toBeDefined();
expect(setting.type).toBe('boolean');
expect(setting.category).toBe('Experimental');
expect(setting.default).toBe(true);
expect(setting.default).toBe(false);
expect(setting.requiresRestart).toBe(true);
expect(setting.showInDialog).toBe(false);
expect(setting.description).toBe('Enable local and remote subagents.');
+1 -1
View File
@@ -1838,7 +1838,7 @@ const SETTINGS_SCHEMA = {
label: 'Enable Agents',
category: 'Experimental',
requiresRestart: true,
default: true,
default: false,
description: 'Enable local and remote subagents.',
showInDialog: false,
},
@@ -80,7 +80,6 @@ function usePlanContent(planPath: string, config: Config): PlanContentState {
const pathError = await validatePlanPath(
planPath,
config.storage.getPlansDir(),
config.getTargetDir(),
);
if (ignore) return;
if (pathError) {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-core",
"version": "0.35.0-nightly.20260313.bb060d7a9",
"version": "0.35.2",
"description": "Gemini CLI Core",
"license": "Apache-2.0",
"repository": {
+22 -4
View File
@@ -44,6 +44,7 @@ export class AgentRegistry {
private readonly agents = new Map<string, AgentDefinition<any>>();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private readonly allDefinitions = new Map<string, AgentDefinition<any>>();
private readonly builtInAgents = new Set<string>();
constructor(private readonly config: Config) {}
@@ -56,6 +57,13 @@ export class AgentRegistry {
await this.loadAgents();
}
/**
* Returns true if the agent is a built-in agent.
*/
isBuiltIn(name: string): boolean {
return this.builtInAgents.has(name);
}
private onModelChanged = () => {
this.refreshAgents().catch((e) => {
debugLogger.error(
@@ -240,15 +248,25 @@ export class AgentRegistry {
}
private loadBuiltInAgents(): void {
this.registerLocalAgent(CodebaseInvestigatorAgent(this.config));
this.registerLocalAgent(CliHelpAgent(this.config));
this.registerLocalAgent(GeneralistAgent(this.config));
const codebaseAgent = CodebaseInvestigatorAgent(this.config);
this.builtInAgents.add(codebaseAgent.name);
this.registerLocalAgent(codebaseAgent);
const helpAgent = CliHelpAgent(this.config);
this.builtInAgents.add(helpAgent.name);
this.registerLocalAgent(helpAgent);
const generalistAgent = GeneralistAgent(this.config);
this.builtInAgents.add(generalistAgent.name);
this.registerLocalAgent(generalistAgent);
// Register the browser agent if enabled in settings.
// Tools are configured dynamically at invocation time via browserAgentFactory.
const browserConfig = this.config.getBrowserAgentConfig();
if (browserConfig.enabled) {
this.registerLocalAgent(BrowserAgentDefinition(this.config));
const browserAgent = BrowserAgentDefinition(this.config);
this.builtInAgents.add(browserAgent.name);
this.registerLocalAgent(browserAgent);
}
}
@@ -44,6 +44,7 @@ describe('codeAssist', () => {
projectId: 'test-project',
userTier: UserTierId.FREE,
userTierName: 'free-tier-name',
hasOnboardedPreviously: false,
};
it('should create a server for LOGIN_WITH_GOOGLE', async () => {
@@ -63,7 +64,7 @@ describe('codeAssist', () => {
);
expect(setupUser).toHaveBeenCalledWith(
mockAuthClient,
mockValidationHandler,
mockConfig,
httpOptions,
);
expect(MockedCodeAssistServer).toHaveBeenCalledWith(
@@ -95,7 +96,7 @@ describe('codeAssist', () => {
);
expect(setupUser).toHaveBeenCalledWith(
mockAuthClient,
mockValidationHandler,
mockConfig,
httpOptions,
);
expect(MockedCodeAssistServer).toHaveBeenCalledWith(
+1 -5
View File
@@ -22,11 +22,7 @@ export async function createCodeAssistContentGenerator(
authType === AuthType.COMPUTE_ADC
) {
const authClient = await getOauthClient(authType, config);
const userData = await setupUser(
authClient,
config.getValidationHandler(),
httpOptions,
);
const userData = await setupUser(authClient, config, httpOptions);
return new CodeAssistServer(
authClient,
userData.projectId,
+42 -22
View File
@@ -14,6 +14,7 @@ import { ValidationRequiredError } from '../utils/googleQuotaErrors.js';
import { CodeAssistServer } from '../code_assist/server.js';
import type { OAuth2Client } from 'google-auth-library';
import { UserTierId, type GeminiUserTier } from './types.js';
import type { Config } from '../config/config.js';
vi.mock('../code_assist/server.js');
@@ -35,6 +36,8 @@ describe('setupUser', () => {
let mockLoad: ReturnType<typeof vi.fn>;
let mockOnboardUser: ReturnType<typeof vi.fn>;
let mockGetOperation: ReturnType<typeof vi.fn>;
let mockConfig: Config;
let mockValidationHandler: ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.resetAllMocks();
@@ -60,6 +63,18 @@ describe('setupUser', () => {
getOperation: mockGetOperation,
}) as unknown as CodeAssistServer,
);
mockValidationHandler = vi.fn();
mockConfig = {
getValidationHandler: () => mockValidationHandler,
getUsageStatisticsEnabled: () => true,
getSessionId: () => 'test-session-id',
getContentGeneratorConfig: () => ({
authType: 'google-login',
}),
isInteractive: () => false,
getExperiments: () => undefined,
} as unknown as Config;
});
afterEach(() => {
@@ -76,9 +91,9 @@ describe('setupUser', () => {
const client = {} as OAuth2Client;
// First call
await setupUser(client);
await setupUser(client, mockConfig);
// Second call
await setupUser(client);
await setupUser(client, mockConfig);
expect(mockLoad).toHaveBeenCalledTimes(1);
});
@@ -91,10 +106,10 @@ describe('setupUser', () => {
const client = {} as OAuth2Client;
vi.stubEnv('GOOGLE_CLOUD_PROJECT', 'p1');
await setupUser(client);
await setupUser(client, mockConfig);
vi.stubEnv('GOOGLE_CLOUD_PROJECT', 'p2');
await setupUser(client);
await setupUser(client, mockConfig);
expect(mockLoad).toHaveBeenCalledTimes(2);
});
@@ -106,11 +121,11 @@ describe('setupUser', () => {
});
const client = {} as OAuth2Client;
await setupUser(client);
await setupUser(client, mockConfig);
vi.advanceTimersByTime(31000); // 31s > 30s expiration
await setupUser(client);
await setupUser(client, mockConfig);
expect(mockLoad).toHaveBeenCalledTimes(2);
});
@@ -123,8 +138,10 @@ describe('setupUser', () => {
});
const client = {} as OAuth2Client;
await expect(setupUser(client)).rejects.toThrow('Network error');
await setupUser(client);
await expect(setupUser(client, mockConfig)).rejects.toThrow(
'Network error',
);
await setupUser(client, mockConfig);
expect(mockLoad).toHaveBeenCalledTimes(2);
});
@@ -136,7 +153,7 @@ describe('setupUser', () => {
mockLoad.mockResolvedValue({
currentTier: mockPaidTier,
});
await setupUser({} as OAuth2Client);
await setupUser({} as OAuth2Client, mockConfig);
expect(CodeAssistServer).toHaveBeenCalledWith(
{},
'test-project',
@@ -157,7 +174,7 @@ describe('setupUser', () => {
'User-Agent': 'GeminiCLI/1.0.0/gemini-2.0-flash (darwin; arm64)',
},
};
await setupUser({} as OAuth2Client, undefined, httpOptions);
await setupUser({} as OAuth2Client, mockConfig, httpOptions);
expect(CodeAssistServer).toHaveBeenCalledWith(
{},
'test-project',
@@ -174,7 +191,7 @@ describe('setupUser', () => {
cloudaicompanionProject: 'server-project',
currentTier: mockPaidTier,
});
const result = await setupUser({} as OAuth2Client);
const result = await setupUser({} as OAuth2Client, mockConfig);
expect(result.projectId).toBe('server-project');
});
@@ -185,7 +202,7 @@ describe('setupUser', () => {
throw new ProjectIdRequiredError();
});
await expect(setupUser({} as OAuth2Client)).rejects.toThrow(
await expect(setupUser({} as OAuth2Client, mockConfig)).rejects.toThrow(
ProjectIdRequiredError,
);
});
@@ -197,7 +214,7 @@ describe('setupUser', () => {
mockLoad.mockResolvedValue({
allowedTiers: [mockPaidTier],
});
const userData = await setupUser({} as OAuth2Client);
const userData = await setupUser({} as OAuth2Client, mockConfig);
expect(mockOnboardUser).toHaveBeenCalledWith(
expect.objectContaining({
tierId: UserTierId.STANDARD,
@@ -208,6 +225,7 @@ describe('setupUser', () => {
projectId: 'server-project',
userTier: UserTierId.STANDARD,
userTierName: 'paid',
hasOnboardedPreviously: false,
});
});
@@ -216,7 +234,7 @@ describe('setupUser', () => {
mockLoad.mockResolvedValue({
allowedTiers: [mockFreeTier],
});
const userData = await setupUser({} as OAuth2Client);
const userData = await setupUser({} as OAuth2Client, mockConfig);
expect(mockOnboardUser).toHaveBeenCalledWith(
expect.objectContaining({
tierId: UserTierId.FREE,
@@ -227,6 +245,7 @@ describe('setupUser', () => {
projectId: 'server-project',
userTier: UserTierId.FREE,
userTierName: 'free',
hasOnboardedPreviously: false,
});
});
@@ -241,11 +260,12 @@ describe('setupUser', () => {
cloudaicompanionProject: undefined,
},
});
const userData = await setupUser({} as OAuth2Client);
const userData = await setupUser({} as OAuth2Client, mockConfig);
expect(userData).toEqual({
projectId: 'test-project',
userTier: UserTierId.STANDARD,
userTierName: 'paid',
hasOnboardedPreviously: false,
});
});
@@ -276,7 +296,7 @@ describe('setupUser', () => {
},
});
const promise = setupUser({} as OAuth2Client);
const promise = setupUser({} as OAuth2Client, mockConfig);
await vi.advanceTimersByTimeAsync(5000);
await vi.advanceTimersByTimeAsync(5000);
@@ -308,10 +328,10 @@ describe('setupUser', () => {
cloudaicompanionProject: 'p1',
});
const mockHandler = vi.fn().mockResolvedValue('verify');
const result = await setupUser({} as OAuth2Client, mockHandler);
mockValidationHandler.mockResolvedValue('verify');
const result = await setupUser({} as OAuth2Client, mockConfig);
expect(mockHandler).toHaveBeenCalledWith(
expect(mockValidationHandler).toHaveBeenCalledWith(
'https://verify',
'Verify please',
);
@@ -333,9 +353,9 @@ describe('setupUser', () => {
],
});
const mockHandler = vi.fn().mockResolvedValue('cancel');
mockValidationHandler.mockResolvedValue('cancel');
await expect(setupUser({} as OAuth2Client, mockHandler)).rejects.toThrow(
await expect(setupUser({} as OAuth2Client, mockConfig)).rejects.toThrow(
ValidationCancelledError,
);
});
@@ -343,7 +363,7 @@ describe('setupUser', () => {
it('should throw error if LoadCodeAssist returns empty response', async () => {
mockLoad.mockResolvedValue(null);
await expect(setupUser({} as OAuth2Client)).rejects.toThrow(
await expect(setupUser({} as OAuth2Client, mockConfig)).rejects.toThrow(
'LoadCodeAssist returned empty response',
);
});
+26 -5
View File
@@ -15,11 +15,17 @@ import {
} from './types.js';
import { CodeAssistServer, type HttpOptions } from './server.js';
import type { AuthClient } from 'google-auth-library';
import type { ValidationHandler } from '../fallback/types.js';
import { ChangeAuthRequestedError } from '../utils/errors.js';
import { ValidationRequiredError } from '../utils/googleQuotaErrors.js';
import { debugLogger } from '../utils/debugLogger.js';
import { createCache, type CacheService } from '../utils/cache.js';
import type { Config } from '../config/config.js';
import {
logOnboardingStart,
logOnboardingSuccess,
OnboardingStartEvent,
OnboardingSuccessEvent,
} from '../telemetry/index.js';
export class ProjectIdRequiredError extends Error {
constructor() {
@@ -54,6 +60,7 @@ export interface UserData {
userTier: UserTierId;
userTierName?: string;
paidTier?: GeminiUserTier;
hasOnboardedPreviously?: boolean;
}
// Cache to store the results of setupUser to avoid redundant network calls.
@@ -94,7 +101,8 @@ export function resetUserDataCacheForTesting() {
* retry, auth change, or cancellation.
*
* @param client - The authenticated client to use for API calls
* @param validationHandler - Optional handler for account validation flow
* @param config - The CLI configuration
* @param httpOptions - Optional HTTP options
* @returns The user's project ID, tier ID, and tier name
* @throws {ValidationRequiredError} If account validation is required
* @throws {ProjectIdRequiredError} If no project ID is available and required
@@ -103,7 +111,7 @@ export function resetUserDataCacheForTesting() {
*/
export async function setupUser(
client: AuthClient,
validationHandler?: ValidationHandler,
config: Config,
httpOptions: HttpOptions = {},
): Promise<UserData> {
const projectId =
@@ -119,7 +127,7 @@ export async function setupUser(
);
return projectCache.getOrCreate(projectId, () =>
_doSetupUser(client, projectId, validationHandler, httpOptions),
_doSetupUser(client, projectId, config, httpOptions),
);
}
@@ -129,7 +137,7 @@ export async function setupUser(
async function _doSetupUser(
client: AuthClient,
projectId: string | undefined,
validationHandler?: ValidationHandler,
config: Config,
httpOptions: HttpOptions = {},
): Promise<UserData> {
const caServer = new CodeAssistServer(
@@ -146,6 +154,8 @@ async function _doSetupUser(
pluginType: 'GEMINI',
};
const validationHandler = config.getValidationHandler();
let loadRes: LoadCodeAssistResponse;
while (true) {
loadRes = await caServer.loadCodeAssist({
@@ -194,6 +204,8 @@ async function _doSetupUser(
UserTierId.STANDARD,
userTierName: loadRes.paidTier?.name ?? loadRes.currentTier.name,
paidTier: loadRes.paidTier ?? undefined,
hasOnboardedPreviously:
loadRes.currentTier.hasOnboardedPreviously ?? true,
};
}
@@ -206,6 +218,8 @@ async function _doSetupUser(
loadRes.paidTier?.id ?? loadRes.currentTier.id ?? UserTierId.STANDARD,
userTierName: loadRes.paidTier?.name ?? loadRes.currentTier.name,
paidTier: loadRes.paidTier ?? undefined,
hasOnboardedPreviously:
loadRes.currentTier.hasOnboardedPreviously ?? true,
};
}
@@ -236,6 +250,8 @@ async function _doSetupUser(
};
}
logOnboardingStart(config, new OnboardingStartEvent());
let lroRes = await caServer.onboardUser(onboardReq);
if (!lroRes.done && lroRes.name) {
const operationName = lroRes.name;
@@ -245,12 +261,16 @@ async function _doSetupUser(
}
}
const userTier = tier.id ?? UserTierId.STANDARD;
logOnboardingSuccess(config, new OnboardingSuccessEvent(userTier));
if (!lroRes.response?.cloudaicompanionProject?.id) {
if (projectId) {
return {
projectId,
userTier: tier.id ?? UserTierId.STANDARD,
userTierName: tier.name,
hasOnboardedPreviously: tier.hasOnboardedPreviously ?? false,
};
}
@@ -261,6 +281,7 @@ async function _doSetupUser(
projectId: lroRes.response.cloudaicompanionProject.id,
userTier: tier.id ?? UserTierId.STANDARD,
userTierName: tier.name,
hasOnboardedPreviously: tier.hasOnboardedPreviously ?? false,
};
}
+7
View File
@@ -185,6 +185,7 @@ vi.mock('../agents/registry.js', () => {
AgentRegistryMock.prototype.initialize = vi.fn();
AgentRegistryMock.prototype.getAllDefinitions = vi.fn(() => []);
AgentRegistryMock.prototype.getDefinition = vi.fn();
AgentRegistryMock.prototype.isBuiltIn = vi.fn();
return { AgentRegistry: AgentRegistryMock };
});
@@ -1262,6 +1263,9 @@ describe('Server Config (config.ts)', () => {
AgentRegistryMock.prototype.getAllDefinitions.mockReturnValue([
mockAgentDefinition,
]);
AgentRegistryMock.prototype.isBuiltIn.mockImplementation(
(name: string) => name === 'codebase_investigator',
);
const SubAgentToolMock = (
(await vi.importMock('../agents/subagent-tool.js')) as {
@@ -1317,6 +1321,9 @@ describe('Server Config (config.ts)', () => {
AgentRegistryMock.prototype.getAllDefinitions.mockReturnValue([
mockAgentDefinition,
]);
AgentRegistryMock.prototype.isBuiltIn.mockImplementation(
(name: string) => name === 'codebase_investigator',
);
const SubAgentToolMock = (
(await vi.importMock('../agents/subagent-tool.js')) as {
+7 -2
View File
@@ -951,7 +951,7 @@ export class Config implements McpContext, AgentLoopContext {
this.model = params.model;
this.disableLoopDetection = params.disableLoopDetection ?? false;
this._activeModel = params.model;
this.enableAgents = params.enableAgents ?? true;
this.enableAgents = params.enableAgents ?? false;
this.agents = params.agents ?? {};
this.disableLLMCorrection = params.disableLLMCorrection ?? true;
this.planEnabled = params.plan ?? true;
@@ -2186,6 +2186,10 @@ export class Config implements McpContext, AgentLoopContext {
return this.policyEngine.getApprovalMode();
}
isPlanMode(): boolean {
return this.getApprovalMode() === ApprovalMode.PLAN;
}
getPolicyUpdateConfirmationRequest():
| PolicyUpdateConfirmationRequest
| undefined {
@@ -3196,7 +3200,8 @@ export class Config implements McpContext, AgentLoopContext {
for (const definition of definitions) {
try {
if (
!this.isAgentsEnabled() ||
(!this.isAgentsEnabled() &&
!this.agentRegistry.isBuiltIn(definition.name)) ||
agentsOverrides[definition.name]?.enabled === false
) {
continue;
+118 -3
View File
@@ -131,6 +131,10 @@ describe('createContentGenerator', () => {
// Set a fixed version for testing
vi.stubEnv('CLI_VERSION', '1.2.3');
vi.stubEnv('TERM_PROGRAM', 'iTerm.app');
vi.stubEnv('VSCODE_PID', '');
vi.stubEnv('GITHUB_SHA', '');
vi.stubEnv('GEMINI_CLI_SURFACE', '');
const mockGenerator = {
models: {},
@@ -149,7 +153,7 @@ describe('createContentGenerator', () => {
httpOptions: expect.objectContaining({
headers: expect.objectContaining({
'User-Agent': expect.stringMatching(
/GeminiCLI\/1\.2\.3\/gemini-pro \(.*; .*; .*\)/,
/GeminiCLI\/1\.2\.3\/gemini-pro \(.*; .*; terminal\)/,
),
}),
}),
@@ -159,7 +163,7 @@ describe('createContentGenerator', () => {
);
});
it('should include clientName prefix in User-Agent when specified', async () => {
it('should use standard User-Agent for a2a-server running outside VS Code', async () => {
const mockConfig = {
getModel: vi.fn().mockReturnValue('gemini-pro'),
getProxy: vi.fn().mockReturnValue(undefined),
@@ -169,6 +173,10 @@ describe('createContentGenerator', () => {
// Set a fixed version for testing
vi.stubEnv('CLI_VERSION', '1.2.3');
vi.stubEnv('TERM_PROGRAM', 'iTerm.app');
vi.stubEnv('VSCODE_PID', '');
vi.stubEnv('GITHUB_SHA', '');
vi.stubEnv('GEMINI_CLI_SURFACE', '');
const mockGenerator = {
models: {},
@@ -185,7 +193,7 @@ describe('createContentGenerator', () => {
httpOptions: expect.objectContaining({
headers: expect.objectContaining({
'User-Agent': expect.stringMatching(
/GeminiCLI-a2a-server\/.*\/gemini-pro \(.*; .*; .*\)/,
/GeminiCLI-a2a-server\/1\.2\.3\/gemini-pro \(.*; .*; terminal\)/,
),
}),
}),
@@ -193,6 +201,113 @@ describe('createContentGenerator', () => {
);
});
it('should include unified User-Agent for a2a-server (VS Code Agent Mode)', async () => {
const mockConfig = {
getModel: vi.fn().mockReturnValue('gemini-pro'),
getProxy: vi.fn().mockReturnValue(undefined),
getUsageStatisticsEnabled: () => true,
getClientName: vi.fn().mockReturnValue('a2a-server'),
} as unknown as Config;
// Set a fixed version for testing
vi.stubEnv('CLI_VERSION', '1.2.3');
// Mock the environment variable that the VS Code extension host would provide to the a2a-server process
vi.stubEnv('VSCODE_PID', '12345');
vi.stubEnv('TERM_PROGRAM', 'vscode');
vi.stubEnv('TERM_PROGRAM_VERSION', '1.85.0');
const mockGenerator = {
models: {},
} as unknown as GoogleGenAI;
vi.mocked(GoogleGenAI).mockImplementation(() => mockGenerator as never);
await createContentGenerator(
{ apiKey: 'test-api-key', authType: AuthType.USE_GEMINI },
mockConfig,
undefined,
);
expect(GoogleGenAI).toHaveBeenCalledWith(
expect.objectContaining({
httpOptions: expect.objectContaining({
headers: expect.objectContaining({
'User-Agent': expect.stringMatching(
/CloudCodeVSCode\/1\.2\.3 \(aidev_client; os_type=.*; os_version=.*; arch=.*; host_path=VSCode\/1\.85\.0; proxy_client=geminicli\)/,
),
}),
}),
}),
);
});
it('should include clientName prefix in User-Agent when specified (non-VSCode)', async () => {
const mockConfig = {
getModel: vi.fn().mockReturnValue('gemini-pro'),
getProxy: vi.fn().mockReturnValue(undefined),
getUsageStatisticsEnabled: () => true,
getClientName: vi.fn().mockReturnValue('my-client'),
} as unknown as Config;
// Set a fixed version for testing
vi.stubEnv('CLI_VERSION', '1.2.3');
vi.stubEnv('TERM_PROGRAM', 'iTerm.app');
vi.stubEnv('VSCODE_PID', '');
vi.stubEnv('GITHUB_SHA', '');
vi.stubEnv('GEMINI_CLI_SURFACE', '');
const mockGenerator = {
models: {},
} as unknown as GoogleGenAI;
vi.mocked(GoogleGenAI).mockImplementation(() => mockGenerator as never);
await createContentGenerator(
{ apiKey: 'test-api-key', authType: AuthType.USE_GEMINI },
mockConfig,
undefined,
);
expect(GoogleGenAI).toHaveBeenCalledWith(
expect.objectContaining({
httpOptions: expect.objectContaining({
headers: expect.objectContaining({
'User-Agent': expect.stringMatching(
/GeminiCLI-my-client\/1\.2\.3\/gemini-pro \(.*; .*; terminal\)/,
),
}),
}),
}),
);
});
it('should allow custom headers to override User-Agent', async () => {
const mockConfig = {
getModel: vi.fn().mockReturnValue('gemini-pro'),
getProxy: vi.fn().mockReturnValue(undefined),
getUsageStatisticsEnabled: () => true,
getClientName: vi.fn().mockReturnValue(undefined),
} as unknown as Config;
vi.stubEnv('GEMINI_CLI_CUSTOM_HEADERS', 'User-Agent:MyCustomUA');
const mockGenerator = {
models: {},
} as unknown as GoogleGenAI;
vi.mocked(GoogleGenAI).mockImplementation(() => mockGenerator as never);
await createContentGenerator(
{ apiKey: 'test-api-key', authType: AuthType.USE_GEMINI },
mockConfig,
undefined,
);
expect(GoogleGenAI).toHaveBeenCalledWith(
expect.objectContaining({
httpOptions: expect.objectContaining({
headers: expect.objectContaining({
'User-Agent': 'MyCustomUA',
}),
}),
}),
);
});
it('should include custom headers from GEMINI_CLI_CUSTOM_HEADERS for Code Assist requests', async () => {
const mockGenerator = {} as unknown as ContentGenerator;
vi.mocked(createCodeAssistContentGenerator).mockResolvedValue(
+34 -5
View File
@@ -13,7 +13,9 @@ import {
type EmbedContentResponse,
type EmbedContentParameters,
} from '@google/genai';
import * as os from 'node:os';
import { createCodeAssistContentGenerator } from '../code_assist/codeAssist.js';
import { isCloudShell } from '../ide/detect-ide.js';
import type { Config } from '../config/config.js';
import { loadApiKey } from './apiKeyCredentialStorage.js';
@@ -175,19 +177,46 @@ export async function createContentGenerator(
const customHeadersEnv =
process.env['GEMINI_CLI_CUSTOM_HEADERS'] || undefined;
const clientName = gcConfig.getClientName();
const userAgentPrefix = clientName
? `GeminiCLI-${clientName}`
: 'GeminiCLI';
const surface = determineSurface();
const userAgent = `${userAgentPrefix}/${version}/${model} (${process.platform}; ${process.arch}; ${surface})`;
let userAgent: string;
// Use unified format for VS Code traffic.
// Note: We don't automatically assume a2a-server is VS Code,
// as it could be used by other clients unless the surface explicitly says 'vscode'.
if (clientName === 'acp-vscode' || surface === 'vscode') {
const osTypeMap: Record<string, string> = {
darwin: 'macOS',
win32: 'Windows',
linux: 'Linux',
};
const osType = osTypeMap[process.platform] || process.platform;
const osVersion = os.release();
const arch = process.arch;
const vscodeVersion = process.env['TERM_PROGRAM_VERSION'] || 'unknown';
let hostPath = `VSCode/${vscodeVersion}`;
if (isCloudShell()) {
const cloudShellVersion =
process.env['CLOUD_SHELL_VERSION'] || 'unknown';
hostPath += ` > CloudShell/${cloudShellVersion}`;
}
userAgent = `CloudCodeVSCode/${version} (aidev_client; os_type=${osType}; os_version=${osVersion}; arch=${arch}; host_path=${hostPath}; proxy_client=geminicli)`;
} else {
const userAgentPrefix = clientName
? `GeminiCLI-${clientName}`
: 'GeminiCLI';
userAgent = `${userAgentPrefix}/${version}/${model} (${process.platform}; ${process.arch}; ${surface})`;
}
const customHeadersMap = parseCustomHeaders(customHeadersEnv);
const apiKeyAuthMechanism =
process.env['GEMINI_API_KEY_AUTH_MECHANISM'] || 'x-goog-api-key';
const apiVersionEnv = process.env['GOOGLE_GENAI_API_VERSION'];
const baseHeaders: Record<string, string> = {
...customHeadersMap,
'User-Agent': userAgent,
...customHeadersMap,
};
if (
@@ -94,6 +94,8 @@ priority = 70
modes = ["plan"]
# Allow write_file and replace for .md files in the plans directory (cross-platform)
# We split this into two rules to avoid ReDoS checker issues with nested optional segments.
# This rule handles the case where there is a session ID in the plan file path
[[rule]]
toolName = ["write_file", "replace"]
decision = "allow"
@@ -101,6 +103,14 @@ priority = 70
modes = ["plan"]
argsPattern = "\\x00\"file_path\":\"[^\"]+[\\\\/]+\\.gemini[\\\\/]+tmp[\\\\/]+[\\w-]+[\\\\/]+[\\w-]+[\\\\/]+plans[\\\\/]+[\\w-]+\\.md\"\\x00"
# This rule handles the case where there isn't a session ID in the plan file path
[[rule]]
toolName = ["write_file", "replace"]
decision = "allow"
priority = 70
modes = ["plan"]
argsPattern = "\\x00\"file_path\":\"[^\"]+[\\\\/]+\\.gemini[\\\\/]+tmp[\\\\/]+[\\w-]+[\\\\/]+plans[\\\\/]+[\\w-]+\\.md\"\\x00"
# Explicitly Deny other write operations in Plan mode with a clear message.
[[rule]]
toolName = ["write_file", "replace"]
@@ -375,9 +375,9 @@ describe('sanitizeEnvironment', () => {
});
describe('getSecureSanitizationConfig', () => {
it('should enable environment variable redaction by default', () => {
it('should default enableEnvironmentVariableRedaction to false', () => {
const config = getSecureSanitizationConfig();
expect(config.enableEnvironmentVariableRedaction).toBe(true);
expect(config.enableEnvironmentVariableRedaction).toBe(false);
});
it('should merge allowed and blocked variables from base and requested configs', () => {
@@ -440,13 +440,13 @@ describe('getSecureSanitizationConfig', () => {
expect(config.blockedEnvironmentVariables).toEqual(['BLOCKED_VAR']);
});
it('should force enableEnvironmentVariableRedaction to true even if requested false', () => {
it('should respect requested enableEnvironmentVariableRedaction value', () => {
const requestedConfig = {
enableEnvironmentVariableRedaction: false,
};
const config = getSecureSanitizationConfig(requestedConfig);
expect(config.enableEnvironmentVariableRedaction).toBe(true);
expect(config.enableEnvironmentVariableRedaction).toBe(false);
});
});
@@ -230,6 +230,9 @@ export function getSecureSanitizationConfig(
allowedEnvironmentVariables: [...new Set(allowed)],
blockedEnvironmentVariables: [...new Set(blocked)],
// Redaction must be enabled for secure configurations
enableEnvironmentVariableRedaction: true,
enableEnvironmentVariableRedaction:
requestedConfig.enableEnvironmentVariableRedaction ??
baseConfig?.enableEnvironmentVariableRedaction ??
false,
};
}
@@ -41,6 +41,11 @@ describe('NoopSandboxManager', () => {
MY_SECRET: 'super-secret',
SAFE_VAR: 'is-safe',
},
config: {
sanitizationConfig: {
enableEnvironmentVariableRedaction: true,
},
},
};
const result = await sandboxManager.prepareCommand(req);
@@ -51,7 +56,7 @@ describe('NoopSandboxManager', () => {
expect(result.env['MY_SECRET']).toBeUndefined();
});
it('should NOT allow disabling environment variable redaction if requested in config (vulnerability fix)', async () => {
it('should allow disabling environment variable redaction if requested in config', async () => {
const req = {
command: 'echo',
args: ['hello'],
@@ -68,8 +73,8 @@ describe('NoopSandboxManager', () => {
const result = await sandboxManager.prepareCommand(req);
// API_KEY should be redacted because SandboxManager forces redaction and API_KEY matches NEVER_ALLOWED_NAME_PATTERNS
expect(result.env['API_KEY']).toBeUndefined();
// API_KEY should be preserved because redaction was explicitly disabled
expect(result.env['API_KEY']).toBe('sensitive-key');
});
it('should respect allowedEnvironmentVariables in config but filter sensitive ones', async () => {
@@ -84,6 +89,7 @@ describe('NoopSandboxManager', () => {
config: {
sanitizationConfig: {
allowedEnvironmentVariables: ['MY_SAFE_VAR', 'MY_TOKEN'],
enableEnvironmentVariableRedaction: true,
},
},
};
@@ -107,6 +113,7 @@ describe('NoopSandboxManager', () => {
config: {
sanitizationConfig: {
blockedEnvironmentVariables: ['BLOCKED_VAR'],
enableEnvironmentVariableRedaction: true,
},
},
};
@@ -41,6 +41,8 @@ import {
AgentFinishEvent,
WebFetchFallbackAttemptEvent,
HookCallEvent,
OnboardingStartEvent,
OnboardingSuccessEvent,
} from '../types.js';
import { HookType } from '../../hooks/types.js';
import { AgentTerminateMode } from '../../agents/types.js';
@@ -1652,4 +1654,38 @@ describe('ClearcutLogger', () => {
]);
});
});
describe('logOnboardingStartEvent', () => {
it('logs an event with proper name and start key', () => {
const { logger } = setup();
const event = new OnboardingStartEvent();
logger?.logOnboardingStartEvent(event);
const events = getEvents(logger!);
expect(events.length).toBe(1);
expect(events[0]).toHaveEventName(EventNames.ONBOARDING_START);
expect(events[0]).toHaveMetadataValue([
EventMetadataKey.GEMINI_CLI_ONBOARDING_START,
'true',
]);
});
});
describe('logOnboardingSuccessEvent', () => {
it('logs an event with proper name and user tier', () => {
const { logger } = setup();
const event = new OnboardingSuccessEvent('standard-tier');
logger?.logOnboardingSuccessEvent(event);
const events = getEvents(logger!);
expect(events.length).toBe(1);
expect(events[0]).toHaveEventName(EventNames.ONBOARDING_SUCCESS);
expect(events[0]).toHaveMetadataValue([
EventMetadataKey.GEMINI_CLI_ONBOARDING_USER_TIER,
'standard-tier',
]);
});
});
});
@@ -51,6 +51,8 @@ import type {
KeychainAvailabilityEvent,
TokenStorageInitializationEvent,
StartupStatsEvent,
OnboardingStartEvent,
OnboardingSuccessEvent,
} from '../types.js';
import type {
CreditsUsedEvent,
@@ -124,6 +126,8 @@ export enum EventNames {
TOOL_OUTPUT_MASKING = 'tool_output_masking',
KEYCHAIN_AVAILABILITY = 'keychain_availability',
TOKEN_STORAGE_INITIALIZATION = 'token_storage_initialization',
ONBOARDING_START = 'onboarding_start',
ONBOARDING_SUCCESS = 'onboarding_success',
CONSECA_POLICY_GENERATION = 'conseca_policy_generation',
CONSECA_VERDICT = 'conseca_verdict',
STARTUP_STATS = 'startup_stats',
@@ -1791,6 +1795,33 @@ export class ClearcutLogger {
this.flushIfNeeded();
}
logOnboardingStartEvent(_event: OnboardingStartEvent): void {
const data: EventValue[] = [
{
gemini_cli_key: EventMetadataKey.GEMINI_CLI_ONBOARDING_START,
value: 'true',
},
];
this.enqueueLogEvent(
this.createLogEvent(EventNames.ONBOARDING_START, data),
);
this.flushIfNeeded();
}
logOnboardingSuccessEvent(event: OnboardingSuccessEvent): void {
const data: EventValue[] = [];
if (event.userTier) {
data.push({
gemini_cli_key: EventMetadataKey.GEMINI_CLI_ONBOARDING_USER_TIER,
value: event.userTier,
});
}
this.enqueueLogEvent(
this.createLogEvent(EventNames.ONBOARDING_SUCCESS, data),
);
this.flushIfNeeded();
}
logStartupStatsEvent(event: StartupStatsEvent): void {
const data: EventValue[] = [
{
@@ -7,7 +7,7 @@
// Defines valid event metadata keys for Clearcut logging.
export enum EventMetadataKey {
// Deleted enums: 24
// Next ID: 191
// Next ID: 194
GEMINI_CLI_KEY_UNKNOWN = 0,
@@ -709,4 +709,14 @@ export enum EventMetadataKey {
// Logs the source of a credit purchase click (e.g. overage_menu, empty_wallet_menu, manage).
GEMINI_CLI_BILLING_PURCHASE_SOURCE = 190,
// ==========================================================================
// Gemini Enterprise (GE) Event Keys
// ==========================================================================
// Logs the start of the onboarding process.
GEMINI_CLI_ONBOARDING_START = 192,
// Logs the user tier for onboarding success events.
GEMINI_CLI_ONBOARDING_USER_TIER = 193,
}
+4
View File
@@ -48,6 +48,8 @@ export {
logWebFetchFallbackAttempt,
logNetworkRetryAttempt,
logRewind,
logOnboardingStart,
logOnboardingSuccess,
} from './loggers.js';
export {
logConsecaPolicyGeneration,
@@ -70,6 +72,8 @@ export {
NetworkRetryAttemptEvent,
ToolCallDecision,
RewindEvent,
OnboardingStartEvent,
OnboardingSuccessEvent,
ConsecaPolicyGenerationEvent,
ConsecaVerdictEvent,
} from './types.js';
@@ -48,6 +48,8 @@ import {
logNetworkRetryAttempt,
logExtensionUpdateEvent,
logHookCall,
logOnboardingStart,
logOnboardingSuccess,
} from './loggers.js';
import { ToolCallDecision } from './tool-call-decision.js';
import {
@@ -72,6 +74,8 @@ import {
EVENT_WEB_FETCH_FALLBACK_ATTEMPT,
EVENT_INVALID_CHUNK,
EVENT_NETWORK_RETRY_ATTEMPT,
EVENT_ONBOARDING_START,
EVENT_ONBOARDING_SUCCESS,
ApiErrorEvent,
ApiRequestEvent,
ApiResponseEvent,
@@ -98,6 +102,8 @@ import {
EVENT_EXTENSION_UPDATE,
HookCallEvent,
EVENT_HOOK_CALL,
OnboardingStartEvent,
OnboardingSuccessEvent,
LlmRole,
} from './types.js';
import { HookType } from '../hooks/types.js';
@@ -2482,6 +2488,76 @@ describe('loggers', () => {
});
});
describe('logOnboardingStart', () => {
const mockConfig = makeFakeConfig();
beforeEach(() => {
vi.spyOn(ClearcutLogger.prototype, 'logOnboardingStartEvent');
vi.spyOn(metrics, 'recordOnboardingStart');
});
it('should log onboarding start event to Clearcut and OTEL, and record metrics', () => {
const event = new OnboardingStartEvent();
logOnboardingStart(mockConfig, event);
expect(
ClearcutLogger.prototype.logOnboardingStartEvent,
).toHaveBeenCalledWith(event);
expect(mockLogger.emit).toHaveBeenCalledWith({
body: 'Onboarding started.',
attributes: {
'session.id': 'test-session-id',
'user.email': 'test-user@example.com',
'installation.id': 'test-installation-id',
'event.name': EVENT_ONBOARDING_START,
'event.timestamp': '2025-01-01T00:00:00.000Z',
interactive: false,
},
});
expect(metrics.recordOnboardingStart).toHaveBeenCalledWith(mockConfig);
});
});
describe('logOnboardingSuccess', () => {
const mockConfig = makeFakeConfig();
beforeEach(() => {
vi.spyOn(ClearcutLogger.prototype, 'logOnboardingSuccessEvent');
vi.spyOn(metrics, 'recordOnboardingSuccess');
});
it('should log onboarding success event to Clearcut and OTEL, and record metrics', () => {
const event = new OnboardingSuccessEvent('standard-tier');
logOnboardingSuccess(mockConfig, event);
expect(
ClearcutLogger.prototype.logOnboardingSuccessEvent,
).toHaveBeenCalledWith(event);
expect(mockLogger.emit).toHaveBeenCalledWith({
body: 'Onboarding succeeded. Tier: standard-tier',
attributes: {
'session.id': 'test-session-id',
'user.email': 'test-user@example.com',
'installation.id': 'test-installation-id',
'event.name': EVENT_ONBOARDING_SUCCESS,
'event.timestamp': '2025-01-01T00:00:00.000Z',
interactive: false,
user_tier: 'standard-tier',
},
});
expect(metrics.recordOnboardingSuccess).toHaveBeenCalledWith(
mockConfig,
'standard-tier',
);
});
});
describe('Telemetry Buffering', () => {
it('should buffer events when SDK is not initialized', async () => {
vi.spyOn(sdk, 'isTelemetrySdkInitialized').mockReturnValue(false);
+38
View File
@@ -57,6 +57,8 @@ import {
type ToolOutputMaskingEvent,
type KeychainAvailabilityEvent,
type TokenStorageInitializationEvent,
type OnboardingStartEvent,
type OnboardingSuccessEvent,
} from './types.js';
import {
recordApiErrorMetrics,
@@ -79,6 +81,8 @@ import {
recordKeychainAvailability,
recordTokenStorageInitialization,
recordInvalidChunk,
recordOnboardingStart,
recordOnboardingSuccess,
} from './metrics.js';
import { bufferTelemetryEvent } from './sdk.js';
import { uiTelemetryService, type UiEvent } from './uiTelemetry.js';
@@ -871,6 +875,40 @@ export function logTokenStorageInitialization(
});
}
export function logOnboardingStart(
config: Config,
event: OnboardingStartEvent,
): void {
ClearcutLogger.getInstance(config)?.logOnboardingStartEvent(event);
bufferTelemetryEvent(() => {
const logger = logs.getLogger(SERVICE_NAME);
const logRecord: LogRecord = {
body: event.toLogBody(),
attributes: event.toOpenTelemetryAttributes(config),
};
logger.emit(logRecord);
recordOnboardingStart(config);
});
}
export function logOnboardingSuccess(
config: Config,
event: OnboardingSuccessEvent,
): void {
ClearcutLogger.getInstance(config)?.logOnboardingSuccessEvent(event);
bufferTelemetryEvent(() => {
const logger = logs.getLogger(SERVICE_NAME);
const logRecord: LogRecord = {
body: event.toLogBody(),
attributes: event.toOpenTelemetryAttributes(config),
};
logger.emit(logRecord);
recordOnboardingSuccess(config, event.userTier);
});
}
export function logBillingEvent(
config: Config,
event: BillingTelemetryEvent,
+43
View File
@@ -51,6 +51,8 @@ const KEYCHAIN_AVAILABILITY_COUNT = 'gemini_cli.keychain.availability.count';
const TOKEN_STORAGE_TYPE_COUNT = 'gemini_cli.token_storage.type.count';
const OVERAGE_OPTION_COUNT = 'gemini_cli.overage_option.count';
const CREDIT_PURCHASE_COUNT = 'gemini_cli.credit_purchase.count';
const EVENT_ONBOARDING_START = 'gemini_cli.onboarding.start';
const EVENT_ONBOARDING_SUCCESS = 'gemini_cli.onboarding.success';
// Agent Metrics
const AGENT_RUN_COUNT = 'gemini_cli.agent.run.count';
@@ -299,6 +301,20 @@ const COUNTER_DEFINITIONS = {
model: string;
},
},
[EVENT_ONBOARDING_START]: {
description: 'Counts onboarding started',
valueType: ValueType.INT,
assign: (c: Counter) => (onboardingStartCounter = c),
attributes: {} as Record<string, never>,
},
[EVENT_ONBOARDING_SUCCESS]: {
description: 'Counts onboarding succeeded',
valueType: ValueType.INT,
assign: (c: Counter) => (onboardingSuccessCounter = c),
attributes: {} as {
user_tier?: string;
},
},
} as const;
const HISTOGRAM_DEFINITIONS = {
@@ -640,6 +656,8 @@ let keychainAvailabilityCounter: Counter | undefined;
let tokenStorageTypeCounter: Counter | undefined;
let overageOptionCounter: Counter | undefined;
let creditPurchaseCounter: Counter | undefined;
let onboardingStartCounter: Counter | undefined;
let onboardingSuccessCounter: Counter | undefined;
// OpenTelemetry GenAI Semantic Convention Metrics
let genAiClientTokenUsageHistogram: Histogram | undefined;
@@ -812,6 +830,31 @@ export function recordLinesChanged(
// --- New Metric Recording Functions ---
/**
* Records a metric for when the Google auth process starts.
*/
export function recordOnboardingStart(config: Config): void {
if (!onboardingStartCounter || !isMetricsInitialized) return;
onboardingStartCounter.add(
1,
baseMetricDefinition.getCommonAttributes(config),
);
}
/**
* Records a metric for when the Google auth process ends successfully.
*/
export function recordOnboardingSuccess(
config: Config,
userTier?: string,
): void {
if (!onboardingSuccessCounter || !isMetricsInitialized) return;
onboardingSuccessCounter.add(1, {
...baseMetricDefinition.getCommonAttributes(config),
...(userTier && { user_tier: userTier }),
});
}
/**
* Records a metric for when a UI frame flickers.
*/
+1 -1
View File
@@ -344,9 +344,9 @@ export async function initializeTelemetry(
if (config.getDebugMode()) {
debugLogger.log('OpenTelemetry SDK started successfully.');
}
telemetryInitialized = true;
activeTelemetryEmail = credentials?.client_email;
initializeMetrics(config);
telemetryInitialized = true;
void flushTelemetryBuffer();
} catch (error) {
debugLogger.error('Error starting OpenTelemetry SDK:', error);
+50
View File
@@ -44,6 +44,7 @@ import { getFileDiffFromResultDisplay } from '../utils/fileDiffUtils.js';
import { LlmRole } from './llmRole.js';
export { LlmRole };
import type { HookType } from '../hooks/types.js';
import type { UserTierId } from '../code_assist/types.js';
export interface BaseTelemetryEvent {
'event.name': string;
@@ -2357,6 +2358,55 @@ export class KeychainAvailabilityEvent implements BaseTelemetryEvent {
}
}
export const EVENT_ONBOARDING_START = 'gemini_cli.onboarding.start';
export class OnboardingStartEvent implements BaseTelemetryEvent {
'event.name': 'onboarding_start';
'event.timestamp': string;
constructor() {
this['event.name'] = 'onboarding_start';
this['event.timestamp'] = new Date().toISOString();
}
toOpenTelemetryAttributes(config: Config): LogAttributes {
return {
...getCommonAttributes(config),
'event.name': EVENT_ONBOARDING_START,
'event.timestamp': this['event.timestamp'],
};
}
toLogBody(): string {
return 'Onboarding started.';
}
}
export const EVENT_ONBOARDING_SUCCESS = 'gemini_cli.onboarding.success';
export class OnboardingSuccessEvent implements BaseTelemetryEvent {
'event.name': 'onboarding_success';
'event.timestamp': string;
userTier?: UserTierId;
constructor(userTier?: UserTierId) {
this['event.name'] = 'onboarding_success';
this['event.timestamp'] = new Date().toISOString();
this.userTier = userTier;
}
toOpenTelemetryAttributes(config: Config): LogAttributes {
return {
...getCommonAttributes(config),
'event.name': EVENT_ONBOARDING_SUCCESS,
'event.timestamp': this['event.timestamp'],
user_tier: this.userTier ?? '',
};
}
toLogBody(): string {
return `Onboarding succeeded.${this.userTier ? ` Tier: ${this.userTier}` : ''}`;
}
}
export const EVENT_TOKEN_STORAGE_INITIALIZATION =
'gemini_cli.token_storage.initialization';
export class TokenStorageInitializationEvent implements BaseTelemetryEvent {
@@ -71,6 +71,7 @@ describe('Tool Confirmation Policy Updates', () => {
getDisableLLMCorrection: () => true,
getIdeMode: () => false,
getActiveModel: () => 'test-model',
isPlanMode: () => false,
getWorkspaceContext: () => ({
isPathWithinWorkspace: () => true,
getDirectories: () => [rootDir],
@@ -169,13 +169,13 @@ exports[`coreTools snapshots for specific models > Model: gemini-2.5-pro > snaps
"name": "exit_plan_mode",
"parametersJsonSchema": {
"properties": {
"plan_path": {
"description": "The file path to the finalized plan (e.g., "/mock/plans/feature-x.md"). This path MUST be within the designated plans directory: /mock/plans/",
"plan_filename": {
"description": "The filename of the finalized plan (e.g., "feature-x.md"). Do not provide an absolute path.",
"type": "string",
},
},
"required": [
"plan_path",
"plan_filename",
],
"type": "object",
},
@@ -956,13 +956,13 @@ exports[`coreTools snapshots for specific models > Model: gemini-3-pro-preview >
"name": "exit_plan_mode",
"parametersJsonSchema": {
"properties": {
"plan_path": {
"description": "The file path to the finalized plan (e.g., "/mock/plans/feature-x.md"). This path MUST be within the designated plans directory: /mock/plans/",
"plan_filename": {
"description": "The filename of the finalized plan (e.g., "feature-x.md"). Do not provide an absolute path.",
"type": "string",
},
},
"required": [
"plan_path",
"plan_filename",
],
"type": "object",
},
@@ -117,7 +117,7 @@ export const ASK_USER_OPTION_PARAM_DESCRIPTION = 'description';
// -- exit_plan_mode --
export const EXIT_PLAN_MODE_TOOL_NAME = 'exit_plan_mode';
export const EXIT_PLAN_PARAM_PLAN_PATH = 'plan_path';
export const EXIT_PLAN_PARAM_PLAN_FILENAME = 'plan_filename';
// -- enter_plan_mode --
export const ENTER_PLAN_MODE_TOOL_NAME = 'enter_plan_mode';
@@ -89,7 +89,7 @@ export {
ASK_USER_OPTION_PARAM_LABEL,
ASK_USER_OPTION_PARAM_DESCRIPTION,
PLAN_MODE_PARAM_REASON,
EXIT_PLAN_PARAM_PLAN_PATH,
EXIT_PLAN_PARAM_PLAN_FILENAME,
SKILL_PARAM_NAME,
} from './base-declarations.js';
@@ -244,10 +244,10 @@ export function getShellDefinition(
};
}
export function getExitPlanModeDefinition(plansDir: string): ToolDefinition {
export function getExitPlanModeDefinition(): ToolDefinition {
return {
base: getExitPlanModeDeclaration(plansDir),
overrides: (modelId) => getToolSet(modelId).exit_plan_mode(plansDir),
base: getExitPlanModeDeclaration(),
overrides: (modelId) => getToolSet(modelId).exit_plan_mode(),
};
}
@@ -82,7 +82,7 @@ describe('coreTools snapshots for specific models', () => {
{ name: 'enter_plan_mode', definition: ENTER_PLAN_MODE_DEFINITION },
{
name: 'exit_plan_mode',
definition: getExitPlanModeDefinition('/mock/plans'),
definition: getExitPlanModeDefinition(),
},
{
name: 'activate_skill',
@@ -21,7 +21,7 @@ import {
PARAM_DESCRIPTION,
PARAM_DIR_PATH,
SHELL_PARAM_IS_BACKGROUND,
EXIT_PLAN_PARAM_PLAN_PATH,
EXIT_PLAN_PARAM_PLAN_FILENAME,
SKILL_PARAM_NAME,
} from './base-declarations.js';
@@ -118,20 +118,18 @@ export function getShellDeclaration(
/**
* Returns the FunctionDeclaration for exiting plan mode.
*/
export function getExitPlanModeDeclaration(
plansDir: string,
): FunctionDeclaration {
export function getExitPlanModeDeclaration(): FunctionDeclaration {
return {
name: EXIT_PLAN_MODE_TOOL_NAME,
description:
'Finalizes the planning phase and transitions to implementation by presenting the plan for user approval. This tool MUST be used to exit Plan Mode before any source code edits can be performed. Call this whenever a plan is ready or the user requests implementation.',
parametersJsonSchema: {
type: 'object',
required: [EXIT_PLAN_PARAM_PLAN_PATH],
required: [EXIT_PLAN_PARAM_PLAN_FILENAME],
properties: {
[EXIT_PLAN_PARAM_PLAN_PATH]: {
[EXIT_PLAN_PARAM_PLAN_FILENAME]: {
type: 'string',
description: `The file path to the finalized plan (e.g., "${plansDir}/feature-x.md"). This path MUST be within the designated plans directory: ${plansDir}/`,
description: `The filename of the finalized plan (e.g., "feature-x.md"). Do not provide an absolute path.`,
},
},
},
@@ -732,6 +732,6 @@ The agent did not use the todo list because this task could be completed by a ti
},
},
exit_plan_mode: (plansDir) => getExitPlanModeDeclaration(plansDir),
exit_plan_mode: () => getExitPlanModeDeclaration(),
activate_skill: (skillNames) => getActivateSkillDeclaration(skillNames),
};
@@ -707,6 +707,6 @@ The agent did not use the todo list because this task could be completed by a ti
},
},
exit_plan_mode: (plansDir) => getExitPlanModeDeclaration(plansDir),
exit_plan_mode: () => getExitPlanModeDeclaration(),
activate_skill: (skillNames) => getActivateSkillDeclaration(skillNames),
};
+1 -1
View File
@@ -47,6 +47,6 @@ export interface CoreToolSet {
get_internal_docs: FunctionDeclaration;
ask_user: FunctionDeclaration;
enter_plan_mode: FunctionDeclaration;
exit_plan_mode: (plansDir: string) => FunctionDeclaration;
exit_plan_mode: () => FunctionDeclaration;
activate_skill: (skillNames: string[]) => FunctionDeclaration;
}
+40
View File
@@ -131,8 +131,10 @@ describe('EditTool', () => {
isInteractive: () => false,
getDisableLLMCorrection: vi.fn(() => true),
getExperiments: () => {},
isPlanMode: vi.fn(() => false),
storage: {
getProjectTempDir: vi.fn().mockReturnValue('/tmp/project'),
getPlansDir: vi.fn().mockReturnValue('/tmp/plans'),
},
isPathAllowed(this: Config, absolutePath: string): boolean {
const workspaceContext = this.getWorkspaceContext();
@@ -1299,4 +1301,42 @@ function doIt() {
);
});
});
describe('plan mode', () => {
it('should allow edits to plans directory when isPlanMode is true', async () => {
const mockProjectTempDir = path.join(tempDir, 'project');
fs.mkdirSync(mockProjectTempDir);
vi.mocked(mockConfig.storage.getProjectTempDir).mockReturnValue(
mockProjectTempDir,
);
const plansDir = path.join(mockProjectTempDir, 'plans');
fs.mkdirSync(plansDir);
vi.mocked(mockConfig.isPlanMode).mockReturnValue(true);
vi.mocked(mockConfig.storage.getPlansDir).mockReturnValue(plansDir);
const filePath = path.join(rootDir, 'test-file.txt');
const planFilePath = path.join(plansDir, 'test-file.txt');
const initialContent = 'some initial content';
fs.writeFileSync(planFilePath, initialContent, 'utf8');
const params: EditToolParams = {
file_path: filePath,
instruction: 'Replace initial with new',
old_string: 'initial',
new_string: 'new',
};
const invocation = tool.build(params);
const result = await invocation.execute(new AbortController().signal);
expect(result.llmContent).toMatch(/Successfully modified file/);
// Verify plan file is written with new content
expect(fs.readFileSync(planFilePath, 'utf8')).toBe('some new content');
fs.rmSync(plansDir, { recursive: true, force: true });
});
});
});
+8 -2
View File
@@ -454,8 +454,14 @@ class EditToolInvocation
toolName?: string,
displayName?: string,
) {
super(params, messageBus, toolName, displayName);
if (!path.isAbsolute(this.params.file_path)) {
super(params, messageBus, toolName, displayName, undefined, undefined);
if (this.config.isPlanMode()) {
const safeFilename = path.basename(this.params.file_path);
this.resolvedPath = path.join(
this.config.storage.getPlansDir(),
safeFilename,
);
} else if (!path.isAbsolute(this.params.file_path)) {
const result = correctPath(this.params.file_path, this.config);
if (result.success) {
this.resolvedPath = result.correctedPath;
+27 -44
View File
@@ -78,7 +78,7 @@ describe('ExitPlanModeTool', () => {
describe('shouldConfirmExecute', () => {
it('should return plan approval confirmation details when plan has content', async () => {
const planRelativePath = createPlanFile('test-plan.md', '# My Plan');
const invocation = tool.build({ plan_path: planRelativePath });
const invocation = tool.build({ plan_filename: planRelativePath });
const result = await invocation.shouldConfirmExecute(
new AbortController().signal,
@@ -97,7 +97,7 @@ describe('ExitPlanModeTool', () => {
it('should return false when plan file is empty', async () => {
const planRelativePath = createPlanFile('empty.md', ' ');
const invocation = tool.build({ plan_path: planRelativePath });
const invocation = tool.build({ plan_filename: planRelativePath });
const result = await invocation.shouldConfirmExecute(
new AbortController().signal,
@@ -108,7 +108,7 @@ describe('ExitPlanModeTool', () => {
it('should return false when plan file cannot be read', async () => {
const planRelativePath = path.join('plans', 'non-existent.md');
const invocation = tool.build({ plan_path: planRelativePath });
const invocation = tool.build({ plan_filename: planRelativePath });
const result = await invocation.shouldConfirmExecute(
new AbortController().signal,
@@ -119,7 +119,7 @@ describe('ExitPlanModeTool', () => {
it('should auto-approve when policy decision is ALLOW', async () => {
const planRelativePath = createPlanFile('test.md', '# Content');
const invocation = tool.build({ plan_path: planRelativePath });
const invocation = tool.build({ plan_filename: planRelativePath });
vi.spyOn(
invocation as unknown as {
@@ -142,7 +142,7 @@ describe('ExitPlanModeTool', () => {
it('should throw error when policy decision is DENY', async () => {
const planRelativePath = createPlanFile('test.md', '# Content');
const invocation = tool.build({ plan_path: planRelativePath });
const invocation = tool.build({ plan_filename: planRelativePath });
vi.spyOn(
invocation as unknown as {
@@ -160,7 +160,7 @@ describe('ExitPlanModeTool', () => {
describe('execute with invalid plan', () => {
it('should return error when plan file is empty', async () => {
const planRelativePath = createPlanFile('empty.md', '');
const invocation = tool.build({ plan_path: planRelativePath });
const invocation = tool.build({ plan_filename: planRelativePath });
await invocation.shouldConfirmExecute(new AbortController().signal);
const result = await invocation.execute(new AbortController().signal);
@@ -170,8 +170,8 @@ describe('ExitPlanModeTool', () => {
});
it('should return error when plan file cannot be read', async () => {
const planRelativePath = 'plans/ghost.md';
const invocation = tool.build({ plan_path: planRelativePath });
const planRelativePath = 'ghost.md';
const invocation = tool.build({ plan_filename: planRelativePath });
await invocation.shouldConfirmExecute(new AbortController().signal);
const result = await invocation.execute(new AbortController().signal);
@@ -183,7 +183,7 @@ describe('ExitPlanModeTool', () => {
describe('execute', () => {
it('should return approval message when plan is approved with DEFAULT mode', async () => {
const planRelativePath = createPlanFile('test.md', '# Content');
const invocation = tool.build({ plan_path: planRelativePath });
const invocation = tool.build({ plan_filename: planRelativePath });
const confirmDetails = await invocation.shouldConfirmExecute(
new AbortController().signal,
@@ -211,7 +211,7 @@ Read and follow the plan strictly during implementation.`,
it('should return approval message when plan is approved with AUTO_EDIT mode', async () => {
const planRelativePath = createPlanFile('test.md', '# Content');
const invocation = tool.build({ plan_path: planRelativePath });
const invocation = tool.build({ plan_filename: planRelativePath });
const confirmDetails = await invocation.shouldConfirmExecute(
new AbortController().signal,
@@ -242,7 +242,7 @@ Read and follow the plan strictly during implementation.`,
it('should return feedback message when plan is rejected with feedback', async () => {
const planRelativePath = createPlanFile('test.md', '# Content');
const invocation = tool.build({ plan_path: planRelativePath });
const invocation = tool.build({ plan_filename: planRelativePath });
const confirmDetails = await invocation.shouldConfirmExecute(
new AbortController().signal,
@@ -269,7 +269,7 @@ Revise the plan based on the feedback.`,
it('should handle rejection without feedback gracefully', async () => {
const planRelativePath = createPlanFile('test.md', '# Content');
const invocation = tool.build({ plan_path: planRelativePath });
const invocation = tool.build({ plan_filename: planRelativePath });
const confirmDetails = await invocation.shouldConfirmExecute(
new AbortController().signal,
@@ -295,7 +295,7 @@ Ask the user for specific feedback on how to improve the plan.`,
it('should log plan execution event when plan is approved', async () => {
const planRelativePath = createPlanFile('test.md', '# Content');
const invocation = tool.build({ plan_path: planRelativePath });
const invocation = tool.build({ plan_filename: planRelativePath });
const confirmDetails = await invocation.shouldConfirmExecute(
new AbortController().signal,
@@ -319,7 +319,7 @@ Ask the user for specific feedback on how to improve the plan.`,
it('should return cancellation message when cancelled', async () => {
const planRelativePath = createPlanFile('test.md', '# Content');
const invocation = tool.build({ plan_path: planRelativePath });
const invocation = tool.build({ plan_filename: planRelativePath });
const confirmDetails = await invocation.shouldConfirmExecute(
new AbortController().signal,
@@ -342,7 +342,7 @@ Ask the user for specific feedback on how to improve the plan.`,
describe('execute when shouldConfirmExecute is never called', () => {
it('should approve with DEFAULT mode when approvalPayload is null (policy ALLOW skips confirmation)', async () => {
const planRelativePath = createPlanFile('test.md', '# Content');
const invocation = tool.build({ plan_path: planRelativePath });
const invocation = tool.build({ plan_filename: planRelativePath });
// Simulate the scheduler's policy ALLOW path: execute() is called
// directly without ever calling shouldConfirmExecute(), leaving
@@ -362,7 +362,7 @@ Ask the user for specific feedback on how to improve the plan.`,
describe('getApprovalModeDescription (internal)', () => {
it('should handle all valid approval modes', async () => {
const planRelativePath = createPlanFile('test.md', '# Content');
const invocation = tool.build({ plan_path: planRelativePath });
const invocation = tool.build({ plan_filename: planRelativePath });
const testMode = async (mode: ApprovalMode, expected: string) => {
const confirmDetails = await invocation.shouldConfirmExecute(
@@ -391,7 +391,7 @@ Ask the user for specific feedback on how to improve the plan.`,
it('should throw for invalid post-planning modes', async () => {
const planRelativePath = createPlanFile('test.md', '# Content');
const invocation = tool.build({ plan_path: planRelativePath });
const invocation = tool.build({ plan_filename: planRelativePath });
const testInvalidMode = async (mode: ApprovalMode) => {
const confirmDetails = await invocation.shouldConfirmExecute(
@@ -414,36 +414,19 @@ Ask the user for specific feedback on how to improve the plan.`,
});
});
it('should throw error during build if plan path is outside plans directory', () => {
expect(() => tool.build({ plan_path: '../../../etc/passwd' })).toThrow(
/Access denied/,
);
});
describe('validateToolParams', () => {
it('should reject empty plan_path', () => {
const result = tool.validateToolParams({ plan_path: '' });
expect(result).toBe('plan_path is required.');
it('should reject empty plan_filename', () => {
const result = tool.validateToolParams({ plan_filename: '' });
expect(result).toBe('plan_filename is required.');
});
it('should reject whitespace-only plan_path', () => {
const result = tool.validateToolParams({ plan_path: ' ' });
expect(result).toBe('plan_path is required.');
});
it('should reject path outside plans directory', () => {
const result = tool.validateToolParams({
plan_path: '../../../etc/passwd',
});
expect(result).toContain('Access denied');
it('should reject whitespace-only plan_filename', () => {
const result = tool.validateToolParams({ plan_filename: ' ' });
expect(result).toBe('plan_filename is required.');
});
it('should reject non-existent plan file', async () => {
const result = await validatePlanPath(
'plans/ghost.md',
mockPlansDir,
tempRootDir,
);
const result = await validatePlanPath('ghost.md', mockPlansDir);
expect(result).toContain('Plan file does not exist');
});
@@ -454,18 +437,18 @@ Ask the user for specific feedback on how to improve the plan.`,
fs.symlinkSync(outsideFile, maliciousPath);
const result = tool.validateToolParams({
plan_path: 'plans/malicious.md',
plan_filename: 'malicious.md',
});
expect(result).toBe(
'Access denied: plan path must be within the designated plans directory.',
`Access denied: plan path (${path.join(mockPlansDir, 'malicious.md')}) must be within the designated plans directory (${mockPlansDir}).`,
);
});
it('should accept valid path within plans directory', () => {
createPlanFile('valid.md', '# Content');
const result = tool.validateToolParams({
plan_path: 'plans/valid.md',
plan_filename: 'valid.md',
});
expect(result).toBeNull();
});
+14 -17
View File
@@ -28,7 +28,7 @@ import { resolveToolDeclaration } from './definitions/resolver.js';
import { getPlanModeExitMessage } from '../utils/approvalModeUtils.js';
export interface ExitPlanModeParams {
plan_path: string;
plan_filename: string;
}
export class ExitPlanModeTool extends BaseDeclarativeTool<
@@ -41,8 +41,7 @@ export class ExitPlanModeTool extends BaseDeclarativeTool<
private config: Config,
messageBus: MessageBus,
) {
const plansDir = config.storage.getPlansDir();
const definition = getExitPlanModeDefinition(plansDir);
const definition = getExitPlanModeDefinition();
super(
ExitPlanModeTool.Name,
'Exit Plan Mode',
@@ -56,22 +55,21 @@ export class ExitPlanModeTool extends BaseDeclarativeTool<
protected override validateToolParamValues(
params: ExitPlanModeParams,
): string | null {
if (!params.plan_path || params.plan_path.trim() === '') {
return 'plan_path is required.';
if (!params.plan_filename || params.plan_filename.trim() === '') {
return 'plan_filename is required.';
}
// Since validateToolParamValues is synchronous, we use a basic synchronous check
// for path traversal safety. High-level async validation is deferred to shouldConfirmExecute.
const safeFilename = path.basename(params.plan_filename);
const plansDir = resolveToRealPath(this.config.storage.getPlansDir());
const resolvedPath = path.resolve(
this.config.getTargetDir(),
params.plan_path,
const resolvedPath = path.join(
this.config.storage.getPlansDir(),
safeFilename,
);
const realPath = resolveToRealPath(resolvedPath);
if (!isSubpath(plansDir, realPath)) {
return `Access denied: plan path must be within the designated plans directory.`;
return `Access denied: plan path (${resolvedPath}) must be within the designated plans directory (${plansDir}).`;
}
return null;
@@ -93,8 +91,7 @@ export class ExitPlanModeTool extends BaseDeclarativeTool<
}
override getSchema(modelId?: string) {
const plansDir = this.config.storage.getPlansDir();
return resolveToolDeclaration(getExitPlanModeDefinition(plansDir), modelId);
return resolveToolDeclaration(getExitPlanModeDefinition(), modelId);
}
}
@@ -122,9 +119,8 @@ export class ExitPlanModeInvocation extends BaseToolInvocation<
const resolvedPlanPath = this.getResolvedPlanPath();
const pathError = await validatePlanPath(
this.params.plan_path,
this.params.plan_filename,
this.config.storage.getPlansDir(),
this.config.getTargetDir(),
);
if (pathError) {
this.planValidationError = pathError;
@@ -174,7 +170,7 @@ export class ExitPlanModeInvocation extends BaseToolInvocation<
}
getDescription(): string {
return `Requesting plan approval for: ${this.params.plan_path}`;
return `Requesting plan approval for: ${path.join(this.config.storage.getPlansDir(), this.params.plan_filename)}`;
}
/**
@@ -182,7 +178,8 @@ export class ExitPlanModeInvocation extends BaseToolInvocation<
* Note: Validation is done in validateToolParamValues, so this assumes the path is valid.
*/
private getResolvedPlanPath(): string {
return path.resolve(this.config.getTargetDir(), this.params.plan_path);
const safeFilename = path.basename(this.params.plan_filename);
return path.join(this.config.storage.getPlansDir(), safeFilename);
}
async execute(_signal: AbortSignal): Promise<ToolResult> {
@@ -85,6 +85,10 @@ const mockConfigInternal = {
discoverTools: vi.fn(),
}) as unknown as ToolRegistry,
isInteractive: () => false,
isPlanMode: () => false,
storage: {
getPlansDir: () => '/tmp/plans',
},
};
const mockConfig = mockConfigInternal as unknown as Config;
+2 -2
View File
@@ -73,7 +73,7 @@ import {
ASK_USER_OPTION_PARAM_LABEL,
ASK_USER_OPTION_PARAM_DESCRIPTION,
PLAN_MODE_PARAM_REASON,
EXIT_PLAN_PARAM_PLAN_PATH,
EXIT_PLAN_PARAM_PLAN_FILENAME,
SKILL_PARAM_NAME,
} from './definitions/coreTools.js';
@@ -146,7 +146,7 @@ export {
ASK_USER_OPTION_PARAM_LABEL,
ASK_USER_OPTION_PARAM_DESCRIPTION,
PLAN_MODE_PARAM_REASON,
EXIT_PLAN_PARAM_PLAN_PATH,
EXIT_PLAN_PARAM_PLAN_FILENAME,
SKILL_PARAM_NAME,
};
@@ -105,6 +105,7 @@ const mockConfigInternal = {
}) as unknown as ToolRegistry,
isInteractive: () => false,
getDisableLLMCorrection: vi.fn(() => true),
isPlanMode: vi.fn(() => false),
getActiveModel: () => 'test-model',
storage: {
getProjectTempDir: vi.fn().mockReturnValue('/tmp/project'),
+14 -5
View File
@@ -156,11 +156,20 @@ class WriteFileToolInvocation extends BaseToolInvocation<
toolName?: string,
displayName?: string,
) {
super(params, messageBus, toolName, displayName);
this.resolvedPath = path.resolve(
this.config.getTargetDir(),
this.params.file_path,
);
super(params, messageBus, toolName, displayName, undefined, undefined);
if (this.config.isPlanMode()) {
const safeFilename = path.basename(this.params.file_path);
this.resolvedPath = path.join(
this.config.storage.getPlansDir(),
safeFilename,
);
} else {
this.resolvedPath = path.resolve(
this.config.getTargetDir(),
this.params.file_path,
);
}
}
override toolLocations(): ToolLocation[] {
+75 -15
View File
@@ -740,7 +740,7 @@ included directory memory
});
describe('getEnvironmentMemoryPaths', () => {
it('should NOT traverse upward beyond trusted root (even with .git)', async () => {
it('should traverse upward from trusted root to git root', async () => {
// Setup: /temp/parent/repo/.git
const parentDir = await createEmptyDir(path.join(testRootDir, 'parent'));
const repoDir = await createEmptyDir(path.join(parentDir, 'repo'));
@@ -751,7 +751,7 @@ included directory memory
path.join(parentDir, DEFAULT_CONTEXT_FILENAME),
'Parent content',
);
await createTestFile(
const repoFile = await createTestFile(
path.join(repoDir, DEFAULT_CONTEXT_FILENAME),
'Repo content',
);
@@ -760,15 +760,16 @@ included directory memory
'Src content',
);
// Trust srcDir. Should ONLY load srcFile.
// Repo and Parent are NOT trusted.
// Trust srcDir. Should load srcFile AND repoFile (git root),
// but NOT parentFile (above git root).
const result = await getEnvironmentMemoryPaths([srcDir]);
expect(result).toHaveLength(1);
expect(result[0]).toBe(srcFile);
expect(result).toHaveLength(2);
expect(result).toContain(repoFile);
expect(result).toContain(srcFile);
});
it('should NOT traverse upward beyond trusted root (no .git)', async () => {
it('should fall back to trusted root as ceiling when no .git exists', async () => {
// Setup: /homedir/docs/notes (no .git anywhere)
const docsDir = await createEmptyDir(path.join(homedir, 'docs'));
const notesDir = await createEmptyDir(path.join(docsDir, 'notes'));
@@ -782,12 +783,12 @@ included directory memory
'Docs content',
);
// Trust notesDir. Should load NOTHING because notesDir has no file,
// and we do not traverse up to docsDir.
// No .git, so ceiling falls back to the trusted root itself.
// notesDir has no GEMINI.md and won't traverse up to docsDir.
const resultNotes = await getEnvironmentMemoryPaths([notesDir]);
expect(resultNotes).toHaveLength(0);
// Trust docsDir. Should load docsFile, but NOT homeFile.
// docsDir has a GEMINI.md at the trusted root itself, so it's found.
const resultDocs = await getEnvironmentMemoryPaths([docsDir]);
expect(resultDocs).toHaveLength(1);
expect(resultDocs[0]).toBe(docsFile);
@@ -809,6 +810,34 @@ included directory memory
expect(result[0]).toBe(repoFile);
});
it('should recognize .git as a file (submodules/worktrees)', async () => {
const repoDir = await createEmptyDir(
path.join(testRootDir, 'worktree_repo'),
);
// .git as a file, like in submodules and worktrees
await createTestFile(
path.join(repoDir, '.git'),
'gitdir: /some/other/path/.git/worktrees/worktree_repo',
);
const srcDir = await createEmptyDir(path.join(repoDir, 'src'));
const repoFile = await createTestFile(
path.join(repoDir, DEFAULT_CONTEXT_FILENAME),
'Repo content',
);
const srcFile = await createTestFile(
path.join(srcDir, DEFAULT_CONTEXT_FILENAME),
'Src content',
);
// Trust srcDir. Should traverse up to repoDir (git root via .git file).
const result = await getEnvironmentMemoryPaths([srcDir]);
expect(result).toHaveLength(2);
expect(result).toContain(repoFile);
expect(result).toContain(srcFile);
});
it('should keep multiple memory files from the same directory adjacent and in order', async () => {
// Configure multiple memory filenames
setGeminiMdFilename(['PRIMARY.md', 'SECONDARY.md']);
@@ -1008,6 +1037,7 @@ included directory memory
describe('loadJitSubdirectoryMemory', () => {
it('should load JIT memory when target is inside a trusted root', async () => {
const rootDir = await createEmptyDir(path.join(testRootDir, 'jit_root'));
await createEmptyDir(path.join(rootDir, '.git'));
const subDir = await createEmptyDir(path.join(rootDir, 'subdir'));
const targetFile = path.join(subDir, 'target.txt');
@@ -1052,6 +1082,7 @@ included directory memory
it('should skip already loaded paths', async () => {
const rootDir = await createEmptyDir(path.join(testRootDir, 'jit_root'));
await createEmptyDir(path.join(rootDir, '.git'));
const subDir = await createEmptyDir(path.join(rootDir, 'subdir'));
const targetFile = path.join(subDir, 'target.txt');
@@ -1080,6 +1111,7 @@ included directory memory
it('should deduplicate files in JIT memory loading (same inode)', async () => {
const rootDir = await createEmptyDir(path.join(testRootDir, 'jit_root'));
await createEmptyDir(path.join(rootDir, '.git'));
const subDir = await createEmptyDir(path.join(rootDir, 'subdir'));
const targetFile = path.join(subDir, 'target.txt');
@@ -1131,6 +1163,7 @@ included directory memory
it('should use the deepest trusted root when multiple nested roots exist', async () => {
const outerRoot = await createEmptyDir(path.join(testRootDir, 'outer'));
await createEmptyDir(path.join(outerRoot, '.git'));
const innerRoot = await createEmptyDir(path.join(outerRoot, 'inner'));
const targetFile = path.join(innerRoot, 'target.txt');
@@ -1149,17 +1182,18 @@ included directory memory
new Set(),
);
expect(result.files).toHaveLength(1);
expect(result.files[0].path).toBe(innerMemory);
expect(result.files[0].content).toBe('Inner content');
// Ensure outer memory is NOT loaded
expect(result.files.find((f) => f.path === outerMemory)).toBeUndefined();
// Traversal goes from innerRoot (deepest trusted root) up to outerRoot
// (git root), so both files are found.
expect(result.files).toHaveLength(2);
expect(result.files.find((f) => f.path === innerMemory)).toBeDefined();
expect(result.files.find((f) => f.path === outerMemory)).toBeDefined();
});
it('should resolve file target to its parent directory for traversal', async () => {
const rootDir = await createEmptyDir(
path.join(testRootDir, 'jit_file_resolve'),
);
await createEmptyDir(path.join(rootDir, '.git'));
const subDir = await createEmptyDir(path.join(rootDir, 'src'));
// Create the target file so fs.stat can identify it as a file
@@ -1189,6 +1223,7 @@ included directory memory
const rootDir = await createEmptyDir(
path.join(testRootDir, 'jit_nonexistent'),
);
await createEmptyDir(path.join(rootDir, '.git'));
const subDir = await createEmptyDir(path.join(rootDir, 'src'));
// Target file does NOT exist (e.g. write_file creating a new file)
@@ -1209,6 +1244,31 @@ included directory memory
expect(result.files[0].path).toBe(subDirMemory);
expect(result.files[0].content).toBe('Rules for new files');
});
it('should fall back to trusted root as ceiling when no git root exists', async () => {
const rootDir = await createEmptyDir(
path.join(testRootDir, 'jit_no_git'),
);
// No .git directory created — ceiling falls back to trusted root
const subDir = await createEmptyDir(path.join(rootDir, 'subdir'));
const targetFile = path.join(subDir, 'target.txt');
const subDirMemory = await createTestFile(
path.join(subDir, DEFAULT_CONTEXT_FILENAME),
'Content without git',
);
const result = await loadJitSubdirectoryMemory(
targetFile,
[rootDir],
new Set(),
);
// subDir is within the trusted root, so its GEMINI.md is found
expect(result.files).toHaveLength(1);
expect(result.files[0].path).toBe(subDirMemory);
expect(result.files[0].content).toBe('Content without git');
});
});
it('refreshServerHierarchicalMemory should refresh memory and update config', async () => {
+21 -11
View File
@@ -151,10 +151,10 @@ async function findProjectRoot(startDir: string): Promise<string | null> {
while (true) {
const gitPath = path.join(currentDir, '.git');
try {
const stats = await fs.lstat(gitPath);
if (stats.isDirectory()) {
return currentDir;
}
// Check for existence only — .git can be a directory (normal repos)
// or a file (submodules / worktrees).
await fs.access(gitPath);
return currentDir;
} catch (error: unknown) {
// Don't log ENOENT errors as they're expected when .git doesn't exist
// Also don't log errors in test environments, which often have mocked fs
@@ -175,11 +175,11 @@ async function findProjectRoot(startDir: string): Promise<string | null> {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const fsError = error as { code: string; message: string };
logger.warn(
`Error checking for .git directory at ${gitPath}: ${fsError.message}`,
`Error checking for .git at ${gitPath}: ${fsError.message}`,
);
} else {
logger.warn(
`Non-standard error checking for .git directory at ${gitPath}: ${String(error)}`,
`Non-standard error checking for .git at ${gitPath}: ${String(error)}`,
);
}
}
@@ -492,12 +492,17 @@ export async function getEnvironmentMemoryPaths(
// Trusted Roots Upward Traversal (Parallelized)
const traversalPromises = trustedRoots.map(async (root) => {
const resolvedRoot = normalizePath(root);
const gitRoot = await findProjectRoot(resolvedRoot);
const ceiling = gitRoot ? normalizePath(gitRoot) : resolvedRoot;
debugLogger.debug(
'[DEBUG] [MemoryDiscovery] Loading environment memory for trusted root:',
resolvedRoot,
'(Stopping exactly here)',
'(Stopping at',
gitRoot
? `git root: ${ceiling})`
: `trusted root: ${ceiling} — no git root found)`,
);
return findUpwardGeminiFiles(resolvedRoot, resolvedRoot);
return findUpwardGeminiFiles(resolvedRoot, ceiling);
});
const pathArrays = await Promise.all(traversalPromises);
@@ -761,10 +766,15 @@ export async function loadJitSubdirectoryMemory(
return { files: [], fileIdentities: [] };
}
// Find the git root to use as the traversal ceiling.
// If no git root exists, fall back to the trusted root as the ceiling.
const gitRoot = await findProjectRoot(bestRoot);
const resolvedCeiling = gitRoot ? normalizePath(gitRoot) : bestRoot;
debugLogger.debug(
'[DEBUG] [MemoryDiscovery] Loading JIT memory for',
resolvedTarget,
`(Trusted root: ${bestRoot})`,
`(Trusted root: ${bestRoot}, Ceiling: ${resolvedCeiling}${gitRoot ? ' [git root]' : ' [trusted root, no git]'})`,
);
// Resolve the target to a directory before traversing upward.
@@ -783,8 +793,8 @@ export async function loadJitSubdirectoryMemory(
startDir = normalizePath(path.dirname(resolvedTarget));
}
// Traverse from the resolved directory up to the trusted root
const potentialPaths = await findUpwardGeminiFiles(startDir, bestRoot);
// Traverse from the resolved directory up to the ceiling
const potentialPaths = await findUpwardGeminiFiles(startDir, resolvedCeiling);
if (potentialPaths.length === 0) {
return { files: [], fileIdentities: [] };
+3 -13
View File
@@ -35,19 +35,13 @@ describe('planUtils', () => {
const fullPath = path.join(tempRootDir, planPath);
fs.writeFileSync(fullPath, '# My Plan');
const result = await validatePlanPath(planPath, plansDir, tempRootDir);
const result = await validatePlanPath(planPath, plansDir);
expect(result).toBeNull();
});
it('should return error for path traversal', async () => {
const planPath = path.join('..', 'secret.txt');
const result = await validatePlanPath(planPath, plansDir, tempRootDir);
expect(result).toContain('Access denied');
});
it('should return error for non-existent file', async () => {
const planPath = path.join('plans', 'ghost.md');
const result = await validatePlanPath(planPath, plansDir, tempRootDir);
const result = await validatePlanPath(planPath, plansDir);
expect(result).toContain('Plan file does not exist');
});
@@ -60,11 +54,7 @@ describe('planUtils', () => {
// Create a symbolic link pointing outside the plans directory
fs.symlinkSync(outsideFile, fullMaliciousPath);
const result = await validatePlanPath(
maliciousPath,
plansDir,
tempRootDir,
);
const result = await validatePlanPath(maliciousPath, plansDir);
expect(result).toContain('Access denied');
});
});
+5 -5
View File
@@ -13,8 +13,8 @@ import { isSubpath, resolveToRealPath } from './paths.js';
* Shared between backend tools and CLI UI for consistency.
*/
export const PlanErrorMessages = {
PATH_ACCESS_DENIED:
'Access denied: plan path must be within the designated plans directory.',
PATH_ACCESS_DENIED: (planPath: string, plansDir: string) =>
`Access denied: plan path (${planPath}) must be within the designated plans directory (${plansDir}).`,
FILE_NOT_FOUND: (path: string) =>
`Plan file does not exist: ${path}. You must create the plan file before requesting approval.`,
FILE_EMPTY:
@@ -32,14 +32,14 @@ export const PlanErrorMessages = {
export async function validatePlanPath(
planPath: string,
plansDir: string,
targetDir: string,
): Promise<string | null> {
const resolvedPath = path.resolve(targetDir, planPath);
const safeFilename = path.basename(planPath);
const resolvedPath = path.join(plansDir, safeFilename);
const realPath = resolveToRealPath(resolvedPath);
const realPlansDir = resolveToRealPath(plansDir);
if (!isSubpath(realPlansDir, realPath)) {
return PlanErrorMessages.PATH_ACCESS_DENIED;
return PlanErrorMessages.PATH_ACCESS_DENIED(planPath, realPlansDir);
}
if (!(await fileExists(resolvedPath))) {
+4 -2
View File
@@ -119,8 +119,10 @@ describe('getCommandRoots', () => {
expect(getCommandRoots('ls -l')).toEqual(['ls']);
});
it('should handle paths and return the binary name', () => {
expect(getCommandRoots('/usr/local/bin/node script.js')).toEqual(['node']);
it('should handle paths and return the full path', () => {
expect(getCommandRoots('/usr/local/bin/node script.js')).toEqual([
'/usr/local/bin/node',
]);
});
it('should return an empty array for an empty string', () => {
+1 -5
View File
@@ -264,11 +264,7 @@ function normalizeCommandName(raw: string): string {
return raw.slice(1, -1);
}
}
const trimmed = raw.trim();
if (!trimmed) {
return trimmed;
}
return trimmed.split(/[\\/]/).pop() ?? trimmed;
return raw.trim();
}
function extractNameFromNode(node: Node): string | null {
+4 -3
View File
@@ -37,9 +37,10 @@ export function determineSurface(): string {
return ide.name;
}
// If the detected IDE is 'vscode', we only accept it if TERM_PROGRAM confirms it.
// This prevents generic terminals from being misidentified as VSCode.
if (process.env['TERM_PROGRAM'] === 'vscode') {
// If the detected IDE is 'vscode', we only accept it if TERM_PROGRAM or VSCODE_PID confirms it.
// This prevents generic terminals from being misidentified as VSCode, while still detecting
// background processes spawned by the VS Code extension host (like a2a-server).
if (process.env['TERM_PROGRAM'] === 'vscode' || process.env['VSCODE_PID']) {
return ide.name;
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-devtools",
"version": "0.35.0-nightly.20260313.bb060d7a9",
"version": "0.35.2",
"license": "Apache-2.0",
"type": "module",
"main": "dist/src/index.js",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-sdk",
"version": "0.35.0-nightly.20260313.bb060d7a9",
"version": "0.35.2",
"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.35.0-nightly.20260313.bb060d7a9",
"version": "0.35.2",
"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.35.0-nightly.20260313.bb060d7a9",
"version": "0.35.2",
"publisher": "google",
"icon": "assets/icon.png",
"repository": {
+2 -2
View File
@@ -1971,8 +1971,8 @@
"enableAgents": {
"title": "Enable Agents",
"description": "Enable local and remote subagents.",
"markdownDescription": "Enable local and remote subagents.\n\n- Category: `Experimental`\n- Requires restart: `yes`\n- Default: `true`",
"default": true,
"markdownDescription": "Enable local and remote subagents.\n\n- Category: `Experimental`\n- Requires restart: `yes`\n- Default: `false`",
"default": false,
"type": "boolean"
},
"extensionManagement": {