Compare commits

...

10 Commits

Author SHA1 Message Date
gemini-cli-robot 04b86e81ac chore(release): v0.34.0 2026-03-17 21:02:01 +00:00
gemini-cli-robot 49a86550c3 chore(release): v0.34.0-preview.4 2026-03-16 20:28:14 +00:00
gemini-cli-robot 72bca215fa fix(patch): cherry-pick 48130eb to release/v0.34.0-preview.3-pr-22665 to patch version v0.34.0-preview.3 and create version 0.34.0-preview.4 (#22719)
Co-authored-by: Sehoon Shon <sshon@google.com>
2026-03-16 19:04:23 +00:00
gemini-cli-robot 25bb21ed73 chore(release): v0.34.0-preview.3 2026-03-13 23:49:22 +00:00
gemini-cli-robot 810cd67165 fix(patch): cherry-pick 24adacd to release/v0.34.0-preview.2-pr-22332 to patch version v0.34.0-preview.2 and create version 0.34.0-preview.3 (#22391)
Co-authored-by: christine betts <chrstn@uw.edu>
2026-03-13 16:20:40 -07:00
gemini-cli-robot 62a4d375a8 chore(release): v0.34.0-preview.2 2026-03-12 20:56:55 +00:00
gemini-cli-robot 4085b4f8e7 fix(patch): cherry-pick 8432bce to release/v0.34.0-preview.1-pr-22069 to patch version v0.34.0-preview.1 and create version 0.34.0-preview.2 (#22205)
Co-authored-by: Abhi <43648792+abhipatel12@users.noreply.github.com>
Co-authored-by: Abhi <abhipatel@google.com>
2026-03-12 15:47:43 -04:00
gemini-cli-robot 881822fabc chore(release): v0.34.0-preview.1 2026-03-12 18:20:48 +00:00
gemini-cli-robot c56e58d7a2 fix(patch): cherry-pick 45faf4d to release/v0.34.0-preview.0-pr-22148 [CONFLICTS] (#22174)
Co-authored-by: Jack Wotherspoon <jackwoth@google.com>
Co-authored-by: Christine Betts <chrstn@uw.edu>
2026-03-12 17:37:08 +00:00
gemini-cli-robot ec31811c6c chore(release): v0.34.0-preview.0 2026-03-11 02:03:06 +00:00
37 changed files with 833 additions and 850 deletions
+9 -9
View File
@@ -1,12 +1,12 @@
{
"name": "@google/gemini-cli",
"version": "0.34.0-nightly.20260310.4653b126f",
"version": "0.34.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@google/gemini-cli",
"version": "0.34.0-nightly.20260310.4653b126f",
"version": "0.34.0",
"workspaces": [
"packages/*"
],
@@ -16815,7 +16815,7 @@
},
"packages/a2a-server": {
"name": "@google/gemini-cli-a2a-server",
"version": "0.34.0-nightly.20260310.4653b126f",
"version": "0.34.0",
"dependencies": {
"@a2a-js/sdk": "0.3.11",
"@google-cloud/storage": "^7.16.0",
@@ -16930,7 +16930,7 @@
},
"packages/cli": {
"name": "@google/gemini-cli",
"version": "0.34.0-nightly.20260310.4653b126f",
"version": "0.34.0",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "^0.12.0",
@@ -17102,7 +17102,7 @@
},
"packages/core": {
"name": "@google/gemini-cli-core",
"version": "0.34.0-nightly.20260310.4653b126f",
"version": "0.34.0",
"license": "Apache-2.0",
"dependencies": {
"@a2a-js/sdk": "0.3.11",
@@ -17358,7 +17358,7 @@
},
"packages/devtools": {
"name": "@google/gemini-cli-devtools",
"version": "0.34.0-nightly.20260310.4653b126f",
"version": "0.34.0",
"license": "Apache-2.0",
"dependencies": {
"ws": "^8.16.0"
@@ -17373,7 +17373,7 @@
},
"packages/sdk": {
"name": "@google/gemini-cli-sdk",
"version": "0.34.0-nightly.20260310.4653b126f",
"version": "0.34.0",
"license": "Apache-2.0",
"dependencies": {
"@google/gemini-cli-core": "file:../core",
@@ -17390,7 +17390,7 @@
},
"packages/test-utils": {
"name": "@google/gemini-cli-test-utils",
"version": "0.34.0-nightly.20260310.4653b126f",
"version": "0.34.0",
"license": "Apache-2.0",
"dependencies": {
"@google/gemini-cli-core": "file:../core",
@@ -17407,7 +17407,7 @@
},
"packages/vscode-ide-companion": {
"name": "gemini-cli-vscode-ide-companion",
"version": "0.34.0-nightly.20260310.4653b126f",
"version": "0.34.0",
"license": "LICENSE",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.23.0",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.34.0-nightly.20260310.4653b126f",
"version": "0.34.0",
"engines": {
"node": ">=20.0.0"
},
@@ -14,7 +14,7 @@
"url": "git+https://github.com/google-gemini/gemini-cli.git"
},
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.34.0-nightly.20260310.4653b126f"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.34.0"
},
"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.34.0-nightly.20260310.4653b126f",
"version": "0.34.0",
"description": "Gemini CLI A2A Server",
"repository": {
"type": "git",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.34.0-nightly.20260310.4653b126f",
"version": "0.34.0",
"description": "Gemini CLI",
"license": "Apache-2.0",
"repository": {
@@ -26,7 +26,7 @@
"dist"
],
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.34.0-nightly.20260310.4653b126f"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.34.0"
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.12.0",
@@ -12,12 +12,13 @@ import { ExtensionManager } from './extension-manager.js';
import { createTestMergedSettings } from './settings.js';
import { createExtension } from '../test-utils/createExtension.js';
import { EXTENSIONS_DIRECTORY_NAME } from './extensions/variables.js';
import { themeManager } from '../ui/themes/theme-manager.js';
import {
TrustLevel,
loadTrustedFolders,
isWorkspaceTrusted,
} from './trustedFolders.js';
import { getRealPath } from '@google/gemini-cli-core';
import { getRealPath, type CustomTheme } from '@google/gemini-cli-core';
import type { MergedSettings } from './settings.js';
const mockHomedir = vi.hoisted(() => vi.fn(() => '/tmp/mock-home'));
@@ -39,6 +40,26 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
};
});
const testTheme: CustomTheme = {
type: 'custom',
name: 'MyTheme',
background: {
primary: '#282828',
diff: { added: '#2b3312', removed: '#341212' },
},
text: {
primary: '#ebdbb2',
secondary: '#a89984',
link: '#83a598',
accent: '#d3869b',
},
status: {
success: '#b8bb26',
warning: '#fabd2f',
error: '#fb4934',
},
};
describe('ExtensionManager', () => {
let tempHomeDir: string;
let tempWorkspaceDir: string;
@@ -66,6 +87,7 @@ describe('ExtensionManager', () => {
});
afterEach(() => {
themeManager.clearExtensionThemes();
try {
fs.rmSync(tempHomeDir, { recursive: true, force: true });
} catch (_e) {
@@ -485,4 +507,45 @@ describe('ExtensionManager', () => {
).rejects.toThrow(/already installed/);
});
});
describe('early theme registration', () => {
it('should register themes with ThemeManager during loadExtensions for active extensions', async () => {
createExtension({
extensionsDir: userExtensionsDir,
name: 'themed-ext',
version: '1.0.0',
themes: [testTheme],
});
await extensionManager.loadExtensions();
expect(themeManager.getCustomThemeNames()).toContain(
'MyTheme (themed-ext)',
);
});
it('should not register themes for inactive extensions', async () => {
createExtension({
extensionsDir: userExtensionsDir,
name: 'disabled-ext',
version: '1.0.0',
themes: [testTheme],
});
// Disable the extension by creating an enablement override
const manager = new ExtensionManager({
enabledExtensionOverrides: ['none'],
settings: createTestMergedSettings(),
workspaceDir: tempWorkspaceDir,
requestConsent: vi.fn().mockResolvedValue(true),
requestSetting: null,
});
await manager.loadExtensions();
expect(themeManager.getCustomThemeNames()).not.toContain(
'MyTheme (disabled-ext)',
);
});
});
});
+8 -1
View File
@@ -564,7 +564,7 @@ Would you like to attempt to install via "git clone" instead?`,
protected override async startExtension(extension: GeminiCLIExtension) {
await super.startExtension(extension);
if (extension.themes) {
if (extension.themes && !themeManager.hasExtensionThemes(extension.name)) {
themeManager.registerExtensionThemes(extension.name, extension.themes);
}
}
@@ -624,6 +624,13 @@ Would you like to attempt to install via "git clone" instead?`,
this.loadedExtensions = builtExtensions;
// Register extension themes early so they're available at startup.
for (const ext of this.loadedExtensions) {
if (ext.isActive && ext.themes) {
themeManager.registerExtensionThemes(ext.name, ext.themes);
}
}
await Promise.all(
this.loadedExtensions.map((ext) => this.maybeStartExtension(ext)),
);
@@ -19,7 +19,9 @@ import {
PREVIEW_GEMINI_3_1_MODEL,
PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL,
PREVIEW_GEMINI_FLASH_MODEL,
PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL,
AuthType,
UserTierId,
} from '@google/gemini-cli-core';
import type { Config, ModelSlashCommandEvent } from '@google/gemini-cli-core';
@@ -28,8 +30,9 @@ const mockGetDisplayString = vi.fn();
const mockLogModelSlashCommand = vi.fn();
const mockModelSlashCommandEvent = vi.fn();
vi.mock('@google/gemini-cli-core', async () => {
const actual = await vi.importActual('@google/gemini-cli-core');
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
getDisplayString: (val: string) => mockGetDisplayString(val),
@@ -40,6 +43,7 @@ vi.mock('@google/gemini-cli-core', async () => {
mockModelSlashCommandEvent(model);
}
},
PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL: 'gemini-3.1-flash-lite-preview',
};
});
@@ -49,6 +53,9 @@ describe('<ModelDialog />', () => {
const mockOnClose = vi.fn();
const mockGetHasAccessToPreviewModel = vi.fn();
const mockGetGemini31LaunchedSync = vi.fn();
const mockGetProModelNoAccess = vi.fn();
const mockGetProModelNoAccessSync = vi.fn();
const mockGetUserTier = vi.fn();
interface MockConfig extends Partial<Config> {
setModel: (model: string, isTemporary?: boolean) => void;
@@ -56,6 +63,9 @@ describe('<ModelDialog />', () => {
getHasAccessToPreviewModel: () => boolean;
getIdeMode: () => boolean;
getGemini31LaunchedSync: () => boolean;
getProModelNoAccess: () => Promise<boolean>;
getProModelNoAccessSync: () => boolean;
getUserTier: () => UserTierId | undefined;
}
const mockConfig: MockConfig = {
@@ -64,6 +74,9 @@ describe('<ModelDialog />', () => {
getHasAccessToPreviewModel: mockGetHasAccessToPreviewModel,
getIdeMode: () => false,
getGemini31LaunchedSync: mockGetGemini31LaunchedSync,
getProModelNoAccess: mockGetProModelNoAccess,
getProModelNoAccessSync: mockGetProModelNoAccessSync,
getUserTier: mockGetUserTier,
};
beforeEach(() => {
@@ -71,6 +84,9 @@ describe('<ModelDialog />', () => {
mockGetModel.mockReturnValue(DEFAULT_GEMINI_MODEL_AUTO);
mockGetHasAccessToPreviewModel.mockReturnValue(false);
mockGetGemini31LaunchedSync.mockReturnValue(false);
mockGetProModelNoAccess.mockResolvedValue(false);
mockGetProModelNoAccessSync.mockReturnValue(false);
mockGetUserTier.mockReturnValue(UserTierId.STANDARD);
// Default implementation for getDisplayString
mockGetDisplayString.mockImplementation((val: string) => {
@@ -109,6 +125,55 @@ describe('<ModelDialog />', () => {
unmount();
});
it('renders the "manual" view initially for users with no pro access and filters Pro models with correct order', async () => {
mockGetProModelNoAccessSync.mockReturnValue(true);
mockGetProModelNoAccess.mockResolvedValue(true);
mockGetHasAccessToPreviewModel.mockReturnValue(true);
mockGetUserTier.mockReturnValue(UserTierId.FREE);
mockGetDisplayString.mockImplementation((val: string) => val);
const { lastFrame, unmount } = await renderComponent();
const output = lastFrame();
expect(output).toContain('Select Model');
expect(output).not.toContain(DEFAULT_GEMINI_MODEL);
expect(output).not.toContain(PREVIEW_GEMINI_MODEL);
// Verify order: Flash Preview -> Flash Lite Preview -> Flash -> Flash Lite
const flashPreviewIdx = output.indexOf(PREVIEW_GEMINI_FLASH_MODEL);
const flashLitePreviewIdx = output.indexOf(
PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL,
);
const flashIdx = output.indexOf(DEFAULT_GEMINI_FLASH_MODEL);
const flashLiteIdx = output.indexOf(DEFAULT_GEMINI_FLASH_LITE_MODEL);
expect(flashPreviewIdx).toBeLessThan(flashLitePreviewIdx);
expect(flashLitePreviewIdx).toBeLessThan(flashIdx);
expect(flashIdx).toBeLessThan(flashLiteIdx);
expect(output).not.toContain('Auto');
unmount();
});
it('closes dialog on escape in "manual" view for users with no pro access', async () => {
mockGetProModelNoAccessSync.mockReturnValue(true);
mockGetProModelNoAccess.mockResolvedValue(true);
const { stdin, waitUntilReady, unmount } = await renderComponent();
// Already in manual view
await act(async () => {
stdin.write('\u001B'); // Escape
});
await act(async () => {
await waitUntilReady();
});
await waitFor(() => {
expect(mockOnClose).toHaveBeenCalled();
});
unmount();
});
it('switches to "manual" view when "Manual" is selected and uses getDisplayString for models', async () => {
mockGetDisplayString.mockImplementation((val: string) => {
if (val === DEFAULT_GEMINI_MODEL) return 'Formatted Pro Model';
@@ -369,5 +434,50 @@ describe('<ModelDialog />', () => {
});
unmount();
});
it('hides Flash Lite Preview model for users with pro access', async () => {
mockGetProModelNoAccessSync.mockReturnValue(false);
mockGetProModelNoAccess.mockResolvedValue(false);
mockGetHasAccessToPreviewModel.mockReturnValue(true);
const { lastFrame, stdin, waitUntilReady, unmount } =
await renderComponent();
// Go to manual view
await act(async () => {
stdin.write('\u001B[B'); // Manual
});
await waitUntilReady();
await act(async () => {
stdin.write('\r');
});
await waitUntilReady();
const output = lastFrame();
expect(output).not.toContain(PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL);
unmount();
});
it('shows Flash Lite Preview model for free tier users', async () => {
mockGetProModelNoAccessSync.mockReturnValue(false);
mockGetProModelNoAccess.mockResolvedValue(false);
mockGetHasAccessToPreviewModel.mockReturnValue(true);
mockGetUserTier.mockReturnValue(UserTierId.FREE);
const { lastFrame, stdin, waitUntilReady, unmount } =
await renderComponent();
// Go to manual view
await act(async () => {
stdin.write('\u001B[B'); // Manual
});
await waitUntilReady();
await act(async () => {
stdin.write('\r');
});
await waitUntilReady();
const output = lastFrame();
expect(output).toContain(PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL);
unmount();
});
});
});
+49 -6
View File
@@ -5,12 +5,13 @@
*/
import type React from 'react';
import { useCallback, useContext, useMemo, useState } from 'react';
import { useCallback, useContext, useMemo, useState, useEffect } from 'react';
import { Box, Text } from 'ink';
import {
PREVIEW_GEMINI_MODEL,
PREVIEW_GEMINI_3_1_MODEL,
PREVIEW_GEMINI_FLASH_MODEL,
PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL,
PREVIEW_GEMINI_MODEL_AUTO,
DEFAULT_GEMINI_MODEL,
DEFAULT_GEMINI_FLASH_MODEL,
@@ -21,6 +22,8 @@ import {
getDisplayString,
AuthType,
PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL,
isProModel,
UserTierId,
} from '@google/gemini-cli-core';
import { useKeypress } from '../hooks/useKeypress.js';
import { theme } from '../semantic-colors.js';
@@ -35,9 +38,26 @@ interface ModelDialogProps {
export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
const config = useContext(ConfigContext);
const settings = useSettings();
const [view, setView] = useState<'main' | 'manual'>('main');
const [hasAccessToProModel, setHasAccessToProModel] = useState<boolean>(
() => !(config?.getProModelNoAccessSync() ?? false),
);
const [view, setView] = useState<'main' | 'manual'>(() =>
config?.getProModelNoAccessSync() ? 'manual' : 'main',
);
const [persistMode, setPersistMode] = useState(false);
useEffect(() => {
async function checkAccess() {
if (!config) return;
const noAccess = await config.getProModelNoAccess();
setHasAccessToProModel(!noAccess);
if (noAccess) {
setView('manual');
}
}
void checkAccess();
}, [config]);
// Determine the Preferred Model (read once when the dialog opens).
const preferredModel = config?.getModel() || DEFAULT_GEMINI_MODEL_AUTO;
@@ -66,7 +86,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
useKeypress(
(key) => {
if (key.name === 'escape') {
if (view === 'manual') {
if (view === 'manual' && hasAccessToProModel) {
setView('main');
} else {
onClose();
@@ -115,6 +135,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
}, [shouldShowPreviewModels, manualModelSelected, useGemini31]);
const manualOptions = useMemo(() => {
const isFreeTier = config?.getUserTier() === UserTierId.FREE;
const list = [
{
value: DEFAULT_GEMINI_MODEL,
@@ -142,7 +163,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
? PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL
: previewProModel;
list.unshift(
const previewOptions = [
{
value: previewProValue,
title: getDisplayString(previewProModel),
@@ -153,10 +174,32 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
title: getDisplayString(PREVIEW_GEMINI_FLASH_MODEL),
key: PREVIEW_GEMINI_FLASH_MODEL,
},
);
];
if (isFreeTier) {
previewOptions.push({
value: PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL,
title: getDisplayString(PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL),
key: PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL,
});
}
list.unshift(...previewOptions);
}
if (!hasAccessToProModel) {
// Filter out all Pro models for free tier
return list.filter((option) => !isProModel(option.value));
}
return list;
}, [shouldShowPreviewModels, useGemini31, useCustomToolModel]);
}, [
shouldShowPreviewModels,
useGemini31,
useCustomToolModel,
hasAccessToProModel,
config,
]);
const options = view === 'main' ? mainOptions : manualOptions;
@@ -241,6 +241,17 @@ class ThemeManager {
}
}
/**
* Checks if themes for a given extension are already registered.
* @param extensionName The name of the extension.
* @returns True if any themes from the extension are registered.
*/
hasExtensionThemes(extensionName: string): boolean {
return Array.from(this.extensionThemes.keys()).some((name) =>
name.endsWith(`(${extensionName})`),
);
}
/**
* Clears all registered extension themes.
* This is primarily for testing purposes to reset state between tests.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-core",
"version": "0.34.0-nightly.20260310.4653b126f",
"version": "0.34.0",
"description": "Gemini CLI Core",
"license": "Apache-2.0",
"repository": {
+5 -3
View File
@@ -107,9 +107,11 @@ const localAgentSchema = z
display_name: z.string().optional(),
tools: z
.array(
z.string().refine((val) => isValidToolName(val), {
message: 'Invalid tool name',
}),
z
.string()
.refine((val) => isValidToolName(val, { allowWildcards: true }), {
message: 'Invalid tool name',
}),
)
.optional(),
model: z.string().optional(),
@@ -33,6 +33,7 @@ import {
type PartListUnion,
type Tool,
type CallableTool,
type FunctionDeclaration,
} from '@google/genai';
import type { Config } from '../config/config.js';
import { MockTool } from '../test-utils/mock-tool.js';
@@ -550,6 +551,34 @@ describe('LocalAgentExecutor', () => {
getToolSpy.mockRestore();
});
it('should not duplicate schemas when instantiated tools are provided in toolConfig', async () => {
// Create an instantiated mock tool
const instantiatedTool = new MockTool({ name: 'instantiated_tool' });
// Create an agent definition containing the instantiated tool
const definition = createTestDefinition([instantiatedTool]);
// Create the executor
const executor = await LocalAgentExecutor.create(
definition,
mockConfig,
onActivity,
);
// Extract the prepared tools list using the private method
const toolsList = (
executor as unknown as { prepareToolsList: () => FunctionDeclaration[] }
).prepareToolsList();
// Filter for the specific tool schema
const foundSchemas = (
toolsList as unknown as FunctionDeclaration[]
).filter((t: FunctionDeclaration) => t.name === 'instantiated_tool');
// Assert that there is exactly ONE schema for this tool
expect(foundSchemas).toHaveLength(1);
});
});
describe('run (Execution Loop and Logic)', () => {
+48 -23
View File
@@ -16,7 +16,13 @@ import {
type Schema,
} from '@google/genai';
import { ToolRegistry } from '../tools/tool-registry.js';
import { DiscoveredMCPTool } from '../tools/mcp-tool.js';
import { type AnyDeclarativeTool } from '../tools/tools.js';
import {
DiscoveredMCPTool,
isMcpToolName,
parseMcpToolName,
MCP_TOOL_PREFIX,
} from '../tools/mcp-tool.js';
import { CompressionStatus } from '../core/turn.js';
import { type ToolCallRequestInfo } from '../scheduler/types.js';
import { type Message } from '../confirmation-bus/types.js';
@@ -141,28 +147,55 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
runtimeContext.getAgentRegistry().getAllAgentNames(),
);
const registerToolByName = (toolName: string) => {
const registerToolInstance = (tool: AnyDeclarativeTool) => {
// Check if the tool is a subagent to prevent recursion.
// We do not allow agents to call other agents.
if (allAgentNames.has(toolName)) {
if (allAgentNames.has(tool.name)) {
debugLogger.warn(
`[LocalAgentExecutor] Skipping subagent tool '${toolName}' for agent '${definition.name}' to prevent recursion.`,
`[LocalAgentExecutor] Skipping subagent tool '${tool.name}' for agent '${definition.name}' to prevent recursion.`,
);
return;
}
agentToolRegistry.registerTool(tool);
};
const registerToolByName = (toolName: string) => {
// Handle global wildcard
if (toolName === '*') {
for (const tool of parentToolRegistry.getAllTools()) {
registerToolInstance(tool);
}
return;
}
// Handle MCP wildcards
if (isMcpToolName(toolName)) {
if (toolName === `${MCP_TOOL_PREFIX}*`) {
for (const tool of parentToolRegistry.getAllTools()) {
if (tool instanceof DiscoveredMCPTool) {
registerToolInstance(tool);
}
}
return;
}
const parsed = parseMcpToolName(toolName);
if (parsed.serverName && parsed.toolName === '*') {
for (const tool of parentToolRegistry.getToolsByServer(
parsed.serverName,
)) {
registerToolInstance(tool);
}
return;
}
}
// If the tool is referenced by name, retrieve it from the parent
// registry and register it with the agent's isolated registry.
const tool = parentToolRegistry.getTool(toolName);
if (tool) {
if (tool instanceof DiscoveredMCPTool) {
// Subagents MUST use fully qualified names for MCP tools to ensure
// unambiguous tool calls and to comply with policy requirements.
// We automatically "upgrade" any MCP tool to its qualified version.
agentToolRegistry.registerTool(tool.asFullyQualifiedTool());
} else {
agentToolRegistry.registerTool(tool);
}
registerToolInstance(tool);
}
};
@@ -1171,22 +1204,14 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
const { toolConfig, outputConfig } = this.definition;
if (toolConfig) {
const toolNamesToLoad: string[] = [];
for (const toolRef of toolConfig.tools) {
if (typeof toolRef === 'string') {
toolNamesToLoad.push(toolRef);
} else if (typeof toolRef === 'object' && 'schema' in toolRef) {
// Tool instance with an explicit schema property.
toolsList.push(toolRef.schema);
} else {
if (typeof toolRef === 'object' && !('schema' in toolRef)) {
// Raw `FunctionDeclaration` object.
toolsList.push(toolRef);
}
}
// Add schemas from tools that were registered by name.
toolsList.push(
...this.toolRegistry.getFunctionDeclarationsFiltered(toolNamesToLoad),
);
// Add schemas from tools that were explicitly registered by name, wildcard, or instance.
toolsList.push(...this.toolRegistry.getFunctionDeclarations());
}
// Always inject complete_task.
@@ -17,6 +17,7 @@ export const ExperimentFlags = {
MASKING_PRUNABLE_THRESHOLD: 45758818,
MASKING_PROTECT_LATEST_TURN: 45758819,
GEMINI_3_1_PRO_LAUNCHED: 45760185,
PRO_MODEL_NO_ACCESS: 45768879,
} as const;
export type ExperimentFlagName =
+42
View File
@@ -64,6 +64,8 @@ import {
DEFAULT_GEMINI_MODEL,
PREVIEW_GEMINI_3_1_MODEL,
DEFAULT_GEMINI_MODEL_AUTO,
PREVIEW_GEMINI_MODEL_AUTO,
PREVIEW_GEMINI_FLASH_MODEL,
} from './models.js';
import { Storage } from './storage.js';
@@ -593,6 +595,46 @@ describe('Server Config (config.ts)', () => {
config.getGeminiClient().stripThoughtsFromHistory,
).not.toHaveBeenCalledWith();
});
it('should switch to flash model if user has no Pro access and model is auto', async () => {
vi.mocked(getExperiments).mockResolvedValue({
experimentIds: [],
flags: {
[ExperimentFlags.PRO_MODEL_NO_ACCESS]: {
boolValue: true,
},
},
});
const config = new Config({
...baseParams,
model: PREVIEW_GEMINI_MODEL_AUTO,
});
await config.refreshAuth(AuthType.LOGIN_WITH_GOOGLE);
expect(config.getModel()).toBe(PREVIEW_GEMINI_FLASH_MODEL);
});
it('should NOT switch to flash model if user has Pro access and model is auto', async () => {
vi.mocked(getExperiments).mockResolvedValue({
experimentIds: [],
flags: {
[ExperimentFlags.PRO_MODEL_NO_ACCESS]: {
boolValue: false,
},
},
});
const config = new Config({
...baseParams,
model: PREVIEW_GEMINI_MODEL_AUTO,
});
await config.refreshAuth(AuthType.LOGIN_WITH_GOOGLE);
expect(config.getModel()).toBe(PREVIEW_GEMINI_MODEL_AUTO);
});
});
it('Config constructor should store userMemory correctly', () => {
+28
View File
@@ -1306,6 +1306,10 @@ export class Config implements McpContext, AgentLoopContext {
},
);
this.setRemoteAdminSettings(adminControls);
if ((await this.getProModelNoAccess()) && isAutoModel(this.model)) {
this.setModel(PREVIEW_GEMINI_FLASH_MODEL);
}
}
async getExperimentsAsync(): Promise<Experiments | undefined> {
@@ -2534,6 +2538,30 @@ export class Config implements McpContext, AgentLoopContext {
);
}
/**
* Returns whether the user has access to Pro models.
* This is determined by the PRO_MODEL_NO_ACCESS experiment flag.
*/
async getProModelNoAccess(): Promise<boolean> {
await this.ensureExperimentsLoaded();
return this.getProModelNoAccessSync();
}
/**
* Returns whether the user has access to Pro models synchronously.
*
* Note: This method should only be called after startup, once experiments have been loaded.
*/
getProModelNoAccessSync(): boolean {
if (this.contentGeneratorConfig?.authType !== AuthType.LOGIN_WITH_GOOGLE) {
return false;
}
return (
this.experiments?.flags[ExperimentFlags.PRO_MODEL_NO_ACCESS]?.boolValue ??
false
);
}
/**
* Returns whether Gemini 3.1 has been launched.
* This method is async and ensures that experiments are loaded before returning the result.
+15
View File
@@ -27,6 +27,7 @@ import {
DEFAULT_GEMINI_MODEL_AUTO,
isActiveModel,
PREVIEW_GEMINI_3_1_MODEL,
PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL,
PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL,
isPreviewModel,
isProModel,
@@ -155,6 +156,12 @@ describe('getDisplayString', () => {
);
});
it('should return PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL for PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL', () => {
expect(getDisplayString(PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL)).toBe(
PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL,
);
});
it('should return the model name as is for other models', () => {
expect(getDisplayString('custom-model')).toBe('custom-model');
expect(getDisplayString(DEFAULT_GEMINI_FLASH_LITE_MODEL)).toBe(
@@ -231,6 +238,12 @@ describe('resolveModel', () => {
).toBe(DEFAULT_GEMINI_FLASH_MODEL);
});
it('should return default flash lite model when access to preview is false and preview flash lite model is requested', () => {
expect(
resolveModel(PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL, false, false, false),
).toBe(DEFAULT_GEMINI_FLASH_LITE_MODEL);
});
it('should return default model when access to preview is false and auto-gemini-3 is requested', () => {
expect(resolveModel(PREVIEW_GEMINI_MODEL_AUTO, false, false, false)).toBe(
DEFAULT_GEMINI_MODEL,
@@ -349,6 +362,7 @@ describe('isActiveModel', () => {
expect(isActiveModel(DEFAULT_GEMINI_MODEL)).toBe(true);
expect(isActiveModel(PREVIEW_GEMINI_MODEL)).toBe(true);
expect(isActiveModel(DEFAULT_GEMINI_FLASH_MODEL)).toBe(true);
expect(isActiveModel(PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL)).toBe(true);
});
it('should return true for unknown models and aliases', () => {
@@ -362,6 +376,7 @@ describe('isActiveModel', () => {
it('should return true for other valid models when useGemini3_1 is true', () => {
expect(isActiveModel(DEFAULT_GEMINI_MODEL, true)).toBe(true);
expect(isActiveModel(PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL, true)).toBe(true);
});
it('should correctly filter Gemini 3.1 models based on useCustomToolModel when useGemini3_1 is true', () => {
+5 -1
View File
@@ -9,6 +9,8 @@ export const PREVIEW_GEMINI_3_1_MODEL = 'gemini-3.1-pro-preview';
export const PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL =
'gemini-3.1-pro-preview-customtools';
export const PREVIEW_GEMINI_FLASH_MODEL = 'gemini-3-flash-preview';
export const PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL =
'gemini-3.1-flash-lite-preview';
export const DEFAULT_GEMINI_MODEL = 'gemini-2.5-pro';
export const DEFAULT_GEMINI_FLASH_MODEL = 'gemini-2.5-flash';
export const DEFAULT_GEMINI_FLASH_LITE_MODEL = 'gemini-2.5-flash-lite';
@@ -18,6 +20,7 @@ export const VALID_GEMINI_MODELS = new Set([
PREVIEW_GEMINI_3_1_MODEL,
PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL,
PREVIEW_GEMINI_FLASH_MODEL,
PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL,
DEFAULT_GEMINI_MODEL,
DEFAULT_GEMINI_FLASH_MODEL,
DEFAULT_GEMINI_FLASH_LITE_MODEL,
@@ -169,7 +172,8 @@ export function isPreviewModel(model: string): boolean {
model === PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL ||
model === PREVIEW_GEMINI_FLASH_MODEL ||
model === PREVIEW_GEMINI_MODEL_AUTO ||
model === GEMINI_MODEL_ALIAS_AUTO
model === GEMINI_MODEL_ALIAS_AUTO ||
model === PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL
);
}
@@ -23,10 +23,14 @@ vi.mock('node:fs', () => ({
},
}));
vi.mock('node:path', () => ({
dirname: vi.fn(),
join: vi.fn(),
}));
vi.mock('node:path', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:path')>();
return {
...actual,
dirname: vi.fn(),
join: vi.fn(),
};
});
vi.mock('../config/storage.js', () => ({
Storage: {
@@ -40,14 +44,14 @@ vi.mock('../utils/events.js', () => ({
},
}));
const mockHybridTokenStorage = {
const mockHybridTokenStorage = vi.hoisted(() => ({
listServers: vi.fn(),
setCredentials: vi.fn(),
getCredentials: vi.fn(),
deleteCredentials: vi.fn(),
clearAll: vi.fn(),
getAllCredentials: vi.fn(),
};
}));
vi.mock('./token-storage/hybrid-token-storage.js', () => ({
HybridTokenStorage: vi.fn(() => mockHybridTokenStorage),
}));
@@ -1,360 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { promises as fs } from 'node:fs';
import * as path from 'node:path';
import { FileTokenStorage } from './file-token-storage.js';
import type { OAuthCredentials } from './types.js';
import { GEMINI_DIR } from '../../utils/paths.js';
vi.mock('node:fs', () => ({
promises: {
readFile: vi.fn(),
writeFile: vi.fn(),
unlink: vi.fn(),
mkdir: vi.fn(),
rename: vi.fn(),
},
}));
vi.mock('node:os', () => ({
default: {
homedir: vi.fn(() => '/home/test'),
hostname: vi.fn(() => 'test-host'),
userInfo: vi.fn(() => ({ username: 'test-user' })),
},
homedir: vi.fn(() => '/home/test'),
hostname: vi.fn(() => 'test-host'),
userInfo: vi.fn(() => ({ username: 'test-user' })),
}));
describe('FileTokenStorage', () => {
let storage: FileTokenStorage;
const mockFs = fs as unknown as {
readFile: ReturnType<typeof vi.fn>;
writeFile: ReturnType<typeof vi.fn>;
unlink: ReturnType<typeof vi.fn>;
mkdir: ReturnType<typeof vi.fn>;
rename: ReturnType<typeof vi.fn>;
};
const existingCredentials: OAuthCredentials = {
serverName: 'existing-server',
token: {
accessToken: 'existing-token',
tokenType: 'Bearer',
},
updatedAt: Date.now() - 10000,
};
beforeEach(() => {
vi.clearAllMocks();
storage = new FileTokenStorage('test-storage');
});
afterEach(() => {
vi.clearAllMocks();
});
describe('getCredentials', () => {
it('should return null when file does not exist', async () => {
mockFs.readFile.mockRejectedValue({ code: 'ENOENT' });
const result = await storage.getCredentials('test-server');
expect(result).toBeNull();
});
it('should return null for expired tokens', async () => {
const credentials: OAuthCredentials = {
serverName: 'test-server',
token: {
accessToken: 'access-token',
tokenType: 'Bearer',
expiresAt: Date.now() - 3600000,
},
updatedAt: Date.now(),
};
const encryptedData = storage['encrypt'](
JSON.stringify({ 'test-server': credentials }),
);
mockFs.readFile.mockResolvedValue(encryptedData);
const result = await storage.getCredentials('test-server');
expect(result).toBeNull();
});
it('should return credentials for valid tokens', async () => {
const credentials: OAuthCredentials = {
serverName: 'test-server',
token: {
accessToken: 'access-token',
tokenType: 'Bearer',
expiresAt: Date.now() + 3600000,
},
updatedAt: Date.now(),
};
const encryptedData = storage['encrypt'](
JSON.stringify({ 'test-server': credentials }),
);
mockFs.readFile.mockResolvedValue(encryptedData);
const result = await storage.getCredentials('test-server');
expect(result).toEqual(credentials);
});
it('should throw error with file path when file is corrupted', async () => {
mockFs.readFile.mockResolvedValue('corrupted-data');
try {
await storage.getCredentials('test-server');
expect.fail('Expected error to be thrown');
} catch (error) {
expect(error).toBeInstanceOf(Error);
const err = error as Error;
expect(err.message).toContain('Corrupted token file detected at:');
expect(err.message).toContain('mcp-oauth-tokens-v2.json');
expect(err.message).toContain('delete or rename');
}
});
});
describe('auth type switching', () => {
it('should throw error when trying to save credentials with corrupted file', async () => {
// Simulate corrupted file on first read
mockFs.readFile.mockResolvedValue('corrupted-data');
// Try to save new credentials (simulating switch from OAuth to API key)
const newCredentials: OAuthCredentials = {
serverName: 'new-auth-server',
token: {
accessToken: 'new-api-key',
tokenType: 'ApiKey',
},
updatedAt: Date.now(),
};
// Should throw error with file path
try {
await storage.setCredentials(newCredentials);
expect.fail('Expected error to be thrown');
} catch (error) {
expect(error).toBeInstanceOf(Error);
const err = error as Error;
expect(err.message).toContain('Corrupted token file detected at:');
expect(err.message).toContain('mcp-oauth-tokens-v2.json');
expect(err.message).toContain('delete or rename');
}
});
});
describe('setCredentials', () => {
it('should save credentials with encryption', async () => {
const encryptedData = storage['encrypt'](
JSON.stringify({ 'existing-server': existingCredentials }),
);
mockFs.readFile.mockResolvedValue(encryptedData);
mockFs.mkdir.mockResolvedValue(undefined);
mockFs.writeFile.mockResolvedValue(undefined);
const credentials: OAuthCredentials = {
serverName: 'test-server',
token: {
accessToken: 'access-token',
tokenType: 'Bearer',
},
updatedAt: Date.now(),
};
await storage.setCredentials(credentials);
expect(mockFs.mkdir).toHaveBeenCalledWith(
path.join('/home/test', GEMINI_DIR),
{ recursive: true, mode: 0o700 },
);
expect(mockFs.writeFile).toHaveBeenCalled();
const writeCall = mockFs.writeFile.mock.calls[0];
expect(writeCall[1]).toMatch(/^[0-9a-f]+:[0-9a-f]+:[0-9a-f]+$/);
expect(writeCall[2]).toEqual({ mode: 0o600 });
});
it('should update existing credentials', async () => {
const encryptedData = storage['encrypt'](
JSON.stringify({ 'existing-server': existingCredentials }),
);
mockFs.readFile.mockResolvedValue(encryptedData);
mockFs.writeFile.mockResolvedValue(undefined);
const newCredentials: OAuthCredentials = {
serverName: 'test-server',
token: {
accessToken: 'new-token',
tokenType: 'Bearer',
},
updatedAt: Date.now(),
};
await storage.setCredentials(newCredentials);
expect(mockFs.writeFile).toHaveBeenCalled();
const writeCall = mockFs.writeFile.mock.calls[0];
const decrypted = storage['decrypt'](writeCall[1]);
const saved = JSON.parse(decrypted);
expect(saved['existing-server']).toEqual(existingCredentials);
expect(saved['test-server'].token.accessToken).toBe('new-token');
});
});
describe('deleteCredentials', () => {
it('should throw when credentials do not exist', async () => {
mockFs.readFile.mockRejectedValue({ code: 'ENOENT' });
await expect(storage.deleteCredentials('test-server')).rejects.toThrow(
'No credentials found for test-server',
);
});
it('should delete file when last credential is removed', async () => {
const credentials: OAuthCredentials = {
serverName: 'test-server',
token: {
accessToken: 'access-token',
tokenType: 'Bearer',
},
updatedAt: Date.now(),
};
const encryptedData = storage['encrypt'](
JSON.stringify({ 'test-server': credentials }),
);
mockFs.readFile.mockResolvedValue(encryptedData);
mockFs.unlink.mockResolvedValue(undefined);
await storage.deleteCredentials('test-server');
expect(mockFs.unlink).toHaveBeenCalledWith(
path.join('/home/test', GEMINI_DIR, 'mcp-oauth-tokens-v2.json'),
);
});
it('should update file when other credentials remain', async () => {
const credentials1: OAuthCredentials = {
serverName: 'server1',
token: {
accessToken: 'token1',
tokenType: 'Bearer',
},
updatedAt: Date.now(),
};
const credentials2: OAuthCredentials = {
serverName: 'server2',
token: {
accessToken: 'token2',
tokenType: 'Bearer',
},
updatedAt: Date.now(),
};
const encryptedData = storage['encrypt'](
JSON.stringify({ server1: credentials1, server2: credentials2 }),
);
mockFs.readFile.mockResolvedValue(encryptedData);
mockFs.writeFile.mockResolvedValue(undefined);
await storage.deleteCredentials('server1');
expect(mockFs.writeFile).toHaveBeenCalled();
expect(mockFs.unlink).not.toHaveBeenCalled();
const writeCall = mockFs.writeFile.mock.calls[0];
const decrypted = storage['decrypt'](writeCall[1]);
const saved = JSON.parse(decrypted);
expect(saved['server1']).toBeUndefined();
expect(saved['server2']).toEqual(credentials2);
});
});
describe('listServers', () => {
it('should return empty list when file does not exist', async () => {
mockFs.readFile.mockRejectedValue({ code: 'ENOENT' });
const result = await storage.listServers();
expect(result).toEqual([]);
});
it('should return list of server names', async () => {
const credentials: Record<string, OAuthCredentials> = {
server1: {
serverName: 'server1',
token: { accessToken: 'token1', tokenType: 'Bearer' },
updatedAt: Date.now(),
},
server2: {
serverName: 'server2',
token: { accessToken: 'token2', tokenType: 'Bearer' },
updatedAt: Date.now(),
},
};
const encryptedData = storage['encrypt'](JSON.stringify(credentials));
mockFs.readFile.mockResolvedValue(encryptedData);
const result = await storage.listServers();
expect(result).toEqual(['server1', 'server2']);
});
});
describe('clearAll', () => {
it('should delete the token file', async () => {
mockFs.unlink.mockResolvedValue(undefined);
await storage.clearAll();
expect(mockFs.unlink).toHaveBeenCalledWith(
path.join('/home/test', GEMINI_DIR, 'mcp-oauth-tokens-v2.json'),
);
});
it('should not throw when file does not exist', async () => {
mockFs.unlink.mockRejectedValue({ code: 'ENOENT' });
await expect(storage.clearAll()).resolves.not.toThrow();
});
});
describe('encryption', () => {
it('should encrypt and decrypt data correctly', () => {
const original = 'test-data-123';
const encrypted = storage['encrypt'](original);
const decrypted = storage['decrypt'](encrypted);
expect(decrypted).toBe(original);
expect(encrypted).not.toBe(original);
expect(encrypted).toMatch(/^[0-9a-f]+:[0-9a-f]+:[0-9a-f]+$/);
});
it('should produce different encrypted output each time', () => {
const original = 'test-data';
const encrypted1 = storage['encrypt'](original);
const encrypted2 = storage['encrypt'](original);
expect(encrypted1).not.toBe(encrypted2);
expect(storage['decrypt'](encrypted1)).toBe(original);
expect(storage['decrypt'](encrypted2)).toBe(original);
});
it('should throw on invalid encrypted data format', () => {
expect(() => storage['decrypt']('invalid-data')).toThrow(
'Invalid encrypted data format',
);
});
});
});
@@ -1,194 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { promises as fs } from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import * as crypto from 'node:crypto';
import { BaseTokenStorage } from './base-token-storage.js';
import type { OAuthCredentials } from './types.js';
import { GEMINI_DIR, homedir } from '../../utils/paths.js';
export class FileTokenStorage extends BaseTokenStorage {
private readonly tokenFilePath: string;
private readonly encryptionKey: Buffer;
constructor(serviceName: string) {
super(serviceName);
const configDir = path.join(homedir(), GEMINI_DIR);
this.tokenFilePath = path.join(configDir, 'mcp-oauth-tokens-v2.json');
this.encryptionKey = this.deriveEncryptionKey();
}
private deriveEncryptionKey(): Buffer {
const salt = `${os.hostname()}-${os.userInfo().username}-gemini-cli`;
return crypto.scryptSync('gemini-cli-oauth', salt, 32);
}
private encrypt(text: string): string {
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv('aes-256-gcm', this.encryptionKey, iv);
let encrypted = cipher.update(text, 'utf8', 'hex');
encrypted += cipher.final('hex');
const authTag = cipher.getAuthTag();
return iv.toString('hex') + ':' + authTag.toString('hex') + ':' + encrypted;
}
private decrypt(encryptedData: string): string {
const parts = encryptedData.split(':');
if (parts.length !== 3) {
throw new Error('Invalid encrypted data format');
}
const iv = Buffer.from(parts[0], 'hex');
const authTag = Buffer.from(parts[1], 'hex');
const encrypted = parts[2];
const decipher = crypto.createDecipheriv(
'aes-256-gcm',
this.encryptionKey,
iv,
);
decipher.setAuthTag(authTag);
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
private async ensureDirectoryExists(): Promise<void> {
const dir = path.dirname(this.tokenFilePath);
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
}
private async loadTokens(): Promise<Map<string, OAuthCredentials>> {
try {
const data = await fs.readFile(this.tokenFilePath, 'utf-8');
const decrypted = this.decrypt(data);
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const tokens = JSON.parse(decrypted) as Record<string, OAuthCredentials>;
return new Map(Object.entries(tokens));
} catch (error: unknown) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const err = error as NodeJS.ErrnoException & { message?: string };
if (err.code === 'ENOENT') {
return new Map();
}
if (
err.message?.includes('Invalid encrypted data format') ||
err.message?.includes(
'Unsupported state or unable to authenticate data',
)
) {
// Decryption failed - this can happen when switching between auth types
// or if the file is genuinely corrupted.
throw new Error(
`Corrupted token file detected at: ${this.tokenFilePath}\n` +
`Please delete or rename this file to resolve the issue.`,
);
}
throw error;
}
}
private async saveTokens(
tokens: Map<string, OAuthCredentials>,
): Promise<void> {
await this.ensureDirectoryExists();
const data = Object.fromEntries(tokens);
const json = JSON.stringify(data, null, 2);
const encrypted = this.encrypt(json);
await fs.writeFile(this.tokenFilePath, encrypted, { mode: 0o600 });
}
async getCredentials(serverName: string): Promise<OAuthCredentials | null> {
const tokens = await this.loadTokens();
const credentials = tokens.get(serverName);
if (!credentials) {
return null;
}
if (this.isTokenExpired(credentials)) {
return null;
}
return credentials;
}
async setCredentials(credentials: OAuthCredentials): Promise<void> {
this.validateCredentials(credentials);
const tokens = await this.loadTokens();
const updatedCredentials: OAuthCredentials = {
...credentials,
updatedAt: Date.now(),
};
tokens.set(credentials.serverName, updatedCredentials);
await this.saveTokens(tokens);
}
async deleteCredentials(serverName: string): Promise<void> {
const tokens = await this.loadTokens();
if (!tokens.has(serverName)) {
throw new Error(`No credentials found for ${serverName}`);
}
tokens.delete(serverName);
if (tokens.size === 0) {
try {
await fs.unlink(this.tokenFilePath);
} catch (error: unknown) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const err = error as NodeJS.ErrnoException;
if (err.code !== 'ENOENT') {
throw error;
}
}
} else {
await this.saveTokens(tokens);
}
}
async listServers(): Promise<string[]> {
const tokens = await this.loadTokens();
return Array.from(tokens.keys());
}
async getAllCredentials(): Promise<Map<string, OAuthCredentials>> {
const tokens = await this.loadTokens();
const result = new Map<string, OAuthCredentials>();
for (const [serverName, credentials] of tokens) {
if (!this.isTokenExpired(credentials)) {
result.set(serverName, credentials);
}
}
return result;
}
async clearAll(): Promise<void> {
try {
await fs.unlink(this.tokenFilePath);
} catch (error: unknown) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const err = error as NodeJS.ErrnoException;
if (err.code !== 'ENOENT') {
throw error;
}
}
}
}
@@ -7,12 +7,12 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { HybridTokenStorage } from './hybrid-token-storage.js';
import { KeychainTokenStorage } from './keychain-token-storage.js';
import { FileTokenStorage } from './file-token-storage.js';
import { type OAuthCredentials, TokenStorageType } from './types.js';
vi.mock('./keychain-token-storage.js', () => ({
KeychainTokenStorage: vi.fn().mockImplementation(() => ({
isAvailable: vi.fn(),
isUsingFileFallback: vi.fn(),
getCredentials: vi.fn(),
setCredentials: vi.fn(),
deleteCredentials: vi.fn(),
@@ -36,19 +36,9 @@ vi.mock('../../core/apiKeyCredentialStorage.js', () => ({
clearApiKey: vi.fn(),
}));
vi.mock('./file-token-storage.js', () => ({
FileTokenStorage: vi.fn().mockImplementation(() => ({
getCredentials: vi.fn(),
setCredentials: vi.fn(),
deleteCredentials: vi.fn(),
listServers: vi.fn(),
getAllCredentials: vi.fn(),
clearAll: vi.fn(),
})),
}));
interface MockStorage {
isAvailable?: ReturnType<typeof vi.fn>;
isUsingFileFallback: ReturnType<typeof vi.fn>;
getCredentials: ReturnType<typeof vi.fn>;
setCredentials: ReturnType<typeof vi.fn>;
deleteCredentials: ReturnType<typeof vi.fn>;
@@ -60,7 +50,6 @@ interface MockStorage {
describe('HybridTokenStorage', () => {
let storage: HybridTokenStorage;
let mockKeychainStorage: MockStorage;
let mockFileStorage: MockStorage;
const originalEnv = process.env;
beforeEach(() => {
@@ -70,15 +59,7 @@ describe('HybridTokenStorage', () => {
// Create mock instances before creating HybridTokenStorage
mockKeychainStorage = {
isAvailable: vi.fn(),
getCredentials: vi.fn(),
setCredentials: vi.fn(),
deleteCredentials: vi.fn(),
listServers: vi.fn(),
getAllCredentials: vi.fn(),
clearAll: vi.fn(),
};
mockFileStorage = {
isUsingFileFallback: vi.fn(),
getCredentials: vi.fn(),
setCredentials: vi.fn(),
deleteCredentials: vi.fn(),
@@ -90,9 +71,6 @@ describe('HybridTokenStorage', () => {
(
KeychainTokenStorage as unknown as ReturnType<typeof vi.fn>
).mockImplementation(() => mockKeychainStorage);
(
FileTokenStorage as unknown as ReturnType<typeof vi.fn>
).mockImplementation(() => mockFileStorage);
storage = new HybridTokenStorage('test-service');
});
@@ -102,74 +80,31 @@ describe('HybridTokenStorage', () => {
});
describe('storage selection', () => {
it('should use keychain when available', async () => {
mockKeychainStorage.isAvailable!.mockResolvedValue(true);
it('should use keychain normally', async () => {
mockKeychainStorage.isUsingFileFallback.mockResolvedValue(false);
mockKeychainStorage.getCredentials.mockResolvedValue(null);
await storage.getCredentials('test-server');
expect(mockKeychainStorage.isAvailable).toHaveBeenCalled();
expect(mockKeychainStorage.getCredentials).toHaveBeenCalledWith(
'test-server',
);
expect(await storage.getStorageType()).toBe(TokenStorageType.KEYCHAIN);
});
it('should use file storage when GEMINI_FORCE_FILE_STORAGE is set', async () => {
process.env['GEMINI_FORCE_FILE_STORAGE'] = 'true';
mockFileStorage.getCredentials.mockResolvedValue(null);
await storage.getCredentials('test-server');
expect(mockKeychainStorage.isAvailable).not.toHaveBeenCalled();
expect(mockFileStorage.getCredentials).toHaveBeenCalledWith(
'test-server',
);
expect(await storage.getStorageType()).toBe(
TokenStorageType.ENCRYPTED_FILE,
);
});
it('should fall back to file storage when keychain is unavailable', async () => {
mockKeychainStorage.isAvailable!.mockResolvedValue(false);
mockFileStorage.getCredentials.mockResolvedValue(null);
await storage.getCredentials('test-server');
expect(mockKeychainStorage.isAvailable).toHaveBeenCalled();
expect(mockFileStorage.getCredentials).toHaveBeenCalledWith(
'test-server',
);
expect(await storage.getStorageType()).toBe(
TokenStorageType.ENCRYPTED_FILE,
);
});
it('should fall back to file storage when keychain throws error', async () => {
mockKeychainStorage.isAvailable!.mockRejectedValue(
new Error('Keychain error'),
);
mockFileStorage.getCredentials.mockResolvedValue(null);
await storage.getCredentials('test-server');
expect(mockKeychainStorage.isAvailable).toHaveBeenCalled();
expect(mockFileStorage.getCredentials).toHaveBeenCalledWith(
'test-server',
);
expect(await storage.getStorageType()).toBe(
TokenStorageType.ENCRYPTED_FILE,
);
});
it('should cache storage selection', async () => {
mockKeychainStorage.isAvailable!.mockResolvedValue(true);
it('should use file storage when isUsingFileFallback is true', async () => {
mockKeychainStorage.isUsingFileFallback.mockResolvedValue(true);
mockKeychainStorage.getCredentials.mockResolvedValue(null);
await storage.getCredentials('test-server');
await storage.getCredentials('another-server');
const forceStorage = new HybridTokenStorage('test-service-forced');
await forceStorage.getCredentials('test-server');
expect(mockKeychainStorage.isAvailable).toHaveBeenCalledTimes(1);
expect(mockKeychainStorage.getCredentials).toHaveBeenCalledWith(
'test-server',
);
expect(await forceStorage.getStorageType()).toBe(
TokenStorageType.ENCRYPTED_FILE,
);
});
});
@@ -184,7 +119,6 @@ describe('HybridTokenStorage', () => {
updatedAt: Date.now(),
};
mockKeychainStorage.isAvailable!.mockResolvedValue(true);
mockKeychainStorage.getCredentials.mockResolvedValue(credentials);
const result = await storage.getCredentials('test-server');
@@ -207,7 +141,6 @@ describe('HybridTokenStorage', () => {
updatedAt: Date.now(),
};
mockKeychainStorage.isAvailable!.mockResolvedValue(true);
mockKeychainStorage.setCredentials.mockResolvedValue(undefined);
await storage.setCredentials(credentials);
@@ -220,7 +153,6 @@ describe('HybridTokenStorage', () => {
describe('deleteCredentials', () => {
it('should delegate to selected storage', async () => {
mockKeychainStorage.isAvailable!.mockResolvedValue(true);
mockKeychainStorage.deleteCredentials.mockResolvedValue(undefined);
await storage.deleteCredentials('test-server');
@@ -234,7 +166,6 @@ describe('HybridTokenStorage', () => {
describe('listServers', () => {
it('should delegate to selected storage', async () => {
const servers = ['server1', 'server2'];
mockKeychainStorage.isAvailable!.mockResolvedValue(true);
mockKeychainStorage.listServers.mockResolvedValue(servers);
const result = await storage.listServers();
@@ -265,7 +196,6 @@ describe('HybridTokenStorage', () => {
],
]);
mockKeychainStorage.isAvailable!.mockResolvedValue(true);
mockKeychainStorage.getAllCredentials.mockResolvedValue(credentialsMap);
const result = await storage.getAllCredentials();
@@ -277,7 +207,6 @@ describe('HybridTokenStorage', () => {
describe('clearAll', () => {
it('should delegate to selected storage', async () => {
mockKeychainStorage.isAvailable!.mockResolvedValue(true);
mockKeychainStorage.clearAll.mockResolvedValue(undefined);
await storage.clearAll();
@@ -5,7 +5,7 @@
*/
import { BaseTokenStorage } from './base-token-storage.js';
import { FileTokenStorage } from './file-token-storage.js';
import { KeychainTokenStorage } from './keychain-token-storage.js';
import {
TokenStorageType,
type TokenStorage,
@@ -13,8 +13,7 @@ import {
} from './types.js';
import { coreEvents } from '../../utils/events.js';
import { TokenStorageInitializationEvent } from '../../telemetry/types.js';
const FORCE_FILE_STORAGE_ENV_VAR = 'GEMINI_FORCE_FILE_STORAGE';
import { FORCE_FILE_STORAGE_ENV_VAR } from '../../services/keychainService.js';
export class HybridTokenStorage extends BaseTokenStorage {
private storage: TokenStorage | null = null;
@@ -28,34 +27,20 @@ export class HybridTokenStorage extends BaseTokenStorage {
private async initializeStorage(): Promise<TokenStorage> {
const forceFileStorage = process.env[FORCE_FILE_STORAGE_ENV_VAR] === 'true';
if (!forceFileStorage) {
try {
const { KeychainTokenStorage } = await import(
'./keychain-token-storage.js'
);
const keychainStorage = new KeychainTokenStorage(this.serviceName);
const keychainStorage = new KeychainTokenStorage(this.serviceName);
this.storage = keychainStorage;
const isAvailable = await keychainStorage.isAvailable();
if (isAvailable) {
this.storage = keychainStorage;
this.storageType = TokenStorageType.KEYCHAIN;
const isUsingFileFallback = await keychainStorage.isUsingFileFallback();
coreEvents.emitTelemetryTokenStorageType(
new TokenStorageInitializationEvent('keychain', forceFileStorage),
);
return this.storage;
}
} catch (_e) {
// Fallback to file storage if keychain fails to initialize
}
}
this.storage = new FileTokenStorage(this.serviceName);
this.storageType = TokenStorageType.ENCRYPTED_FILE;
this.storageType = isUsingFileFallback
? TokenStorageType.ENCRYPTED_FILE
: TokenStorageType.KEYCHAIN;
coreEvents.emitTelemetryTokenStorageType(
new TokenStorageInitializationEvent('encrypted_file', forceFileStorage),
new TokenStorageInitializationEvent(
isUsingFileFallback ? 'encrypted_file' : 'keychain',
forceFileStorage,
),
);
return this.storage;
+1 -1
View File
@@ -6,8 +6,8 @@
export * from './types.js';
export * from './base-token-storage.js';
export * from './file-token-storage.js';
export * from './hybrid-token-storage.js';
export * from './keychain-token-storage.js';
export const DEFAULT_SERVICE_NAME = 'gemini-cli-oauth';
export const FORCE_ENCRYPTED_FILE_ENV_VAR =
@@ -159,6 +159,10 @@ export class KeychainTokenStorage
return this.keychainService.isAvailable();
}
async isUsingFileFallback(): Promise<boolean> {
return this.keychainService.isUsingFileFallback();
}
async setSecret(key: string, value: string): Promise<void> {
await this.keychainService.setPassword(`${SECRET_PREFIX}${key}`, value);
}
+160
View File
@@ -0,0 +1,160 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { promises as fs } from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import * as crypto from 'node:crypto';
import type { Keychain } from './keychainTypes.js';
import { GEMINI_DIR, homedir } from '../utils/paths.js';
export class FileKeychain implements Keychain {
private readonly tokenFilePath: string;
private readonly encryptionKey: Buffer;
constructor() {
const configDir = path.join(homedir(), GEMINI_DIR);
this.tokenFilePath = path.join(configDir, 'gemini-credentials.json');
this.encryptionKey = this.deriveEncryptionKey();
}
private deriveEncryptionKey(): Buffer {
const salt = `${os.hostname()}-${os.userInfo().username}-gemini-cli`;
return crypto.scryptSync('gemini-cli-oauth', salt, 32);
}
private encrypt(text: string): string {
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv('aes-256-gcm', this.encryptionKey, iv);
let encrypted = cipher.update(text, 'utf8', 'hex');
encrypted += cipher.final('hex');
const authTag = cipher.getAuthTag();
return iv.toString('hex') + ':' + authTag.toString('hex') + ':' + encrypted;
}
private decrypt(encryptedData: string): string {
const parts = encryptedData.split(':');
if (parts.length !== 3) {
throw new Error('Invalid encrypted data format');
}
const iv = Buffer.from(parts[0], 'hex');
const authTag = Buffer.from(parts[1], 'hex');
const encrypted = parts[2];
const decipher = crypto.createDecipheriv(
'aes-256-gcm',
this.encryptionKey,
iv,
);
decipher.setAuthTag(authTag);
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
private async ensureDirectoryExists(): Promise<void> {
const dir = path.dirname(this.tokenFilePath);
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
}
private async loadData(): Promise<Record<string, Record<string, string>>> {
try {
const data = await fs.readFile(this.tokenFilePath, 'utf-8');
const decrypted = this.decrypt(data);
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return JSON.parse(decrypted) as Record<string, Record<string, string>>;
} catch (error: unknown) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const err = error as NodeJS.ErrnoException & { message?: string };
if (err.code === 'ENOENT') {
return {};
}
if (
err.message?.includes('Invalid encrypted data format') ||
err.message?.includes(
'Unsupported state or unable to authenticate data',
)
) {
throw new Error(
`Corrupted credentials file detected at: ${this.tokenFilePath}\n` +
`Please delete or rename this file to resolve the issue.`,
);
}
throw error;
}
}
private async saveData(
data: Record<string, Record<string, string>>,
): Promise<void> {
await this.ensureDirectoryExists();
const json = JSON.stringify(data, null, 2);
const encrypted = this.encrypt(json);
await fs.writeFile(this.tokenFilePath, encrypted, { mode: 0o600 });
}
async getPassword(service: string, account: string): Promise<string | null> {
const data = await this.loadData();
return data[service]?.[account] ?? null;
}
async setPassword(
service: string,
account: string,
password: string,
): Promise<void> {
const data = await this.loadData();
if (!data[service]) {
data[service] = {};
}
data[service][account] = password;
await this.saveData(data);
}
async deletePassword(service: string, account: string): Promise<boolean> {
const data = await this.loadData();
if (data[service] && account in data[service]) {
delete data[service][account];
if (Object.keys(data[service]).length === 0) {
delete data[service];
}
if (Object.keys(data).length === 0) {
try {
await fs.unlink(this.tokenFilePath);
} catch (error: unknown) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const err = error as NodeJS.ErrnoException;
if (err.code !== 'ENOENT') {
throw error;
}
}
} else {
await this.saveData(data);
}
return true;
}
return false;
}
async findCredentials(
service: string,
): Promise<Array<{ account: string; password: string }>> {
const data = await this.loadData();
const serviceData = data[service] || {};
return Object.entries(serviceData).map(([account, password]) => ({
account,
password,
}));
}
}
@@ -4,10 +4,19 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import {
describe,
it,
expect,
vi,
beforeEach,
afterEach,
type Mock,
} from 'vitest';
import { KeychainService } from './keychainService.js';
import { coreEvents } from '../utils/events.js';
import { debugLogger } from '../utils/debugLogger.js';
import { FileKeychain } from './fileKeychain.js';
type MockKeychain = {
getPassword: Mock | undefined;
@@ -23,8 +32,19 @@ const mockKeytar: MockKeychain = {
findCredentials: vi.fn(),
};
const mockFileKeychain: MockKeychain = {
getPassword: vi.fn(),
setPassword: vi.fn(),
deletePassword: vi.fn(),
findCredentials: vi.fn(),
};
vi.mock('keytar', () => ({ default: mockKeytar }));
vi.mock('./fileKeychain.js', () => ({
FileKeychain: vi.fn(() => mockFileKeychain),
}));
vi.mock('../utils/events.js', () => ({
coreEvents: { emitTelemetryKeychainAvailability: vi.fn() },
}));
@@ -37,13 +57,15 @@ describe('KeychainService', () => {
let service: KeychainService;
const SERVICE_NAME = 'test-service';
let passwords: Record<string, string> = {};
const originalEnv = process.env;
beforeEach(() => {
vi.clearAllMocks();
process.env = { ...originalEnv };
service = new KeychainService(SERVICE_NAME);
passwords = {};
// Stateful mock implementation to verify behavioral correctness
// Stateful mock implementation for native keychain
mockKeytar.setPassword?.mockImplementation((_svc, acc, val) => {
passwords[acc] = val;
return Promise.resolve();
@@ -64,10 +86,36 @@ describe('KeychainService', () => {
})),
),
);
// Stateful mock implementation for fallback file keychain
mockFileKeychain.setPassword?.mockImplementation((_svc, acc, val) => {
passwords[acc] = val;
return Promise.resolve();
});
mockFileKeychain.getPassword?.mockImplementation((_svc, acc) =>
Promise.resolve(passwords[acc] ?? null),
);
mockFileKeychain.deletePassword?.mockImplementation((_svc, acc) => {
const exists = !!passwords[acc];
delete passwords[acc];
return Promise.resolve(exists);
});
mockFileKeychain.findCredentials?.mockImplementation(() =>
Promise.resolve(
Object.entries(passwords).map(([account, password]) => ({
account,
password,
})),
),
);
});
afterEach(() => {
process.env = originalEnv;
});
describe('isAvailable', () => {
it('should return true and emit telemetry on successful functional test', async () => {
it('should return true and emit telemetry on successful functional test with native keychain', async () => {
const available = await service.isAvailable();
expect(available).toBe(true);
@@ -77,12 +125,13 @@ describe('KeychainService', () => {
);
});
it('should return false, log error, and emit telemetry on failed functional test', async () => {
it('should return true (via fallback), log error, and emit telemetry indicating native is unavailable on failed functional test', async () => {
mockKeytar.setPassword?.mockRejectedValue(new Error('locked'));
const available = await service.isAvailable();
expect(available).toBe(false);
// Because it falls back to FileKeychain, it is always available.
expect(available).toBe(true);
expect(debugLogger.log).toHaveBeenCalledWith(
expect.stringContaining('encountered an error'),
'locked',
@@ -90,15 +139,19 @@ describe('KeychainService', () => {
expect(coreEvents.emitTelemetryKeychainAvailability).toHaveBeenCalledWith(
expect.objectContaining({ available: false }),
);
expect(debugLogger.log).toHaveBeenCalledWith(
expect.stringContaining('Using FileKeychain fallback'),
);
expect(FileKeychain).toHaveBeenCalled();
});
it('should return false, log validation error, and emit telemetry on module load failure', async () => {
it('should return true (via fallback), log validation error, and emit telemetry on module load failure', async () => {
const originalMock = mockKeytar.getPassword;
mockKeytar.getPassword = undefined; // Break schema
const available = await service.isAvailable();
expect(available).toBe(false);
expect(available).toBe(true);
expect(debugLogger.log).toHaveBeenCalledWith(
expect.stringContaining('failed structural validation'),
expect.objectContaining({ getPassword: expect.any(Array) }),
@@ -106,19 +159,31 @@ describe('KeychainService', () => {
expect(coreEvents.emitTelemetryKeychainAvailability).toHaveBeenCalledWith(
expect.objectContaining({ available: false }),
);
expect(FileKeychain).toHaveBeenCalled();
mockKeytar.getPassword = originalMock;
});
it('should log failure if functional test cycle returns false', async () => {
it('should log failure if functional test cycle returns false, then fallback', async () => {
mockKeytar.getPassword?.mockResolvedValue('wrong-password');
const available = await service.isAvailable();
expect(available).toBe(false);
expect(available).toBe(true);
expect(debugLogger.log).toHaveBeenCalledWith(
expect.stringContaining('functional verification failed'),
);
expect(FileKeychain).toHaveBeenCalled();
});
it('should fallback to FileKeychain when GEMINI_FORCE_FILE_STORAGE is true', async () => {
process.env['GEMINI_FORCE_FILE_STORAGE'] = 'true';
const available = await service.isAvailable();
expect(available).toBe(true);
expect(FileKeychain).toHaveBeenCalled();
expect(coreEvents.emitTelemetryKeychainAvailability).toHaveBeenCalledWith(
expect.objectContaining({ available: false }),
);
});
it('should cache the result and handle concurrent initialization attempts once', async () => {
@@ -159,25 +224,5 @@ describe('KeychainService', () => {
});
});
describe('When Unavailable', () => {
beforeEach(() => {
mockKeytar.setPassword?.mockRejectedValue(new Error('Unavailable'));
});
it.each([
{ method: 'getPassword', args: ['acc'] },
{ method: 'setPassword', args: ['acc', 'val'] },
{ method: 'deletePassword', args: ['acc'] },
{ method: 'findCredentials', args: [] },
])('$method should throw a consistent error', async ({ method, args }) => {
await expect(
(
service as unknown as Record<
string,
(...args: unknown[]) => Promise<unknown>
>
)[method](...args),
).rejects.toThrow('Keychain is not available');
});
});
// Removing 'When Unavailable' tests since the service is always available via fallback
});
+37 -12
View File
@@ -14,6 +14,9 @@ import {
KEYCHAIN_TEST_PREFIX,
} from './keychainTypes.js';
import { isRecord } from '../utils/markdownUtils.js';
import { FileKeychain } from './fileKeychain.js';
export const FORCE_FILE_STORAGE_ENV_VAR = 'GEMINI_FORCE_FILE_STORAGE';
/**
* Service for interacting with OS-level secure storage (e.g. keytar).
@@ -31,6 +34,14 @@ export class KeychainService {
return (await this.getKeychain()) !== null;
}
/**
* Returns true if the service is using the encrypted file fallback backend.
*/
async isUsingFileFallback(): Promise<boolean> {
const keychain = await this.getKeychain();
return keychain instanceof FileKeychain;
}
/**
* Retrieves a secret for the given account.
* @throws Error if the keychain is unavailable.
@@ -85,26 +96,40 @@ export class KeychainService {
// High-level orchestration of the loading and testing cycle.
private async initializeKeychain(): Promise<Keychain | null> {
let resultKeychain: Keychain | null = null;
const forceFileStorage = process.env[FORCE_FILE_STORAGE_ENV_VAR] === 'true';
try {
const keychainModule = await this.loadKeychainModule();
if (keychainModule) {
if (await this.isKeychainFunctional(keychainModule)) {
resultKeychain = keychainModule;
} else {
debugLogger.log('Keychain functional verification failed');
if (!forceFileStorage) {
try {
const keychainModule = await this.loadKeychainModule();
if (keychainModule) {
if (await this.isKeychainFunctional(keychainModule)) {
resultKeychain = keychainModule;
} else {
debugLogger.log('Keychain functional verification failed');
}
}
} catch (error) {
// Avoid logging full error objects to prevent PII exposure.
const message = error instanceof Error ? error.message : String(error);
debugLogger.log(
'Keychain initialization encountered an error:',
message,
);
}
} catch (error) {
// Avoid logging full error objects to prevent PII exposure.
const message = error instanceof Error ? error.message : String(error);
debugLogger.log('Keychain initialization encountered an error:', message);
}
coreEvents.emitTelemetryKeychainAvailability(
new KeychainAvailabilityEvent(resultKeychain !== null),
new KeychainAvailabilityEvent(
resultKeychain !== null && !forceFileStorage,
),
);
// Fallback to FileKeychain if native keychain is unavailable or file storage is forced
if (!resultKeychain) {
resultKeychain = new FileKeychain();
debugLogger.log('Using FileKeychain fallback for secure storage.');
}
return resultKeychain;
}
+1 -19
View File
@@ -58,6 +58,7 @@ export function parseMcpToolName(name: string): {
// Remove the prefix
const withoutPrefix = name.slice(MCP_TOOL_PREFIX.length);
// The first segment is the server name, the rest is the tool name
// Must be strictly `server_tool` where neither are empty
const match = withoutPrefix.match(/^([^_]+)_(.+)$/);
if (match) {
return {
@@ -390,25 +391,6 @@ export class DiscoveredMCPTool extends BaseDeclarativeTool<
`${this.serverName}${MCP_QUALIFIED_NAME_SEPARATOR}${this.serverToolName}`,
);
}
asFullyQualifiedTool(): DiscoveredMCPTool {
return new DiscoveredMCPTool(
this.mcpTool,
this.serverName,
this.serverToolName,
this.description,
this.parameterSchema,
this.messageBus,
this.trust,
this.isReadOnly,
this.getFullyQualifiedName(),
this.cliConfig,
this.extensionName,
this.extensionId,
this._toolAnnotations,
);
}
protected createInvocation(
params: ToolParams,
messageBus: MessageBus,
+22 -18
View File
@@ -25,7 +25,8 @@ vi.mock('./tool-names.js', async (importOriginal) => {
...actual,
TOOL_LEGACY_ALIASES: mockedAliases,
isValidToolName: vi.fn().mockImplementation((name: string, options) => {
if (mockedAliases[name]) return true;
if (Object.prototype.hasOwnProperty.call(mockedAliases, name))
return true;
return actual.isValidToolName(name, options);
}),
getToolAliases: vi.fn().mockImplementation((name: string) => {
@@ -55,11 +56,9 @@ describe('tool-names', () => {
expect(isValidToolName(`${DISCOVERED_TOOL_PREFIX}my_tool`)).toBe(true);
});
it('should validate MCP tool names (server__tool)', () => {
expect(isValidToolName('server__tool')).toBe(true);
expect(isValidToolName('my-server__my-tool')).toBe(true);
expect(isValidToolName('my.server__my:tool')).toBe(true);
expect(isValidToolName('my-server...truncated__tool')).toBe(true);
it('should validate modern MCP FQNs (mcp_server_tool)', () => {
expect(isValidToolName('mcp_server_tool')).toBe(true);
expect(isValidToolName('mcp_my-server_my-tool')).toBe(true);
});
it('should validate legacy tool aliases', async () => {
@@ -69,28 +68,33 @@ describe('tool-names', () => {
}
});
it('should reject invalid tool names', () => {
expect(isValidToolName('')).toBe(false);
expect(isValidToolName('invalid-name')).toBe(false);
expect(isValidToolName('server__')).toBe(false);
expect(isValidToolName('__tool')).toBe(false);
expect(isValidToolName('server__tool__extra')).toBe(false);
it('should return false for invalid tool names', () => {
expect(isValidToolName('invalid-tool-name')).toBe(false);
expect(isValidToolName('mcp_server')).toBe(false);
expect(isValidToolName('mcp__tool')).toBe(false);
expect(isValidToolName('mcp_invalid server_tool')).toBe(false);
expect(isValidToolName('mcp_server_invalid tool')).toBe(false);
expect(isValidToolName('mcp_server_')).toBe(false);
});
it('should handle wildcards when allowed', () => {
// Default: not allowed
expect(isValidToolName('*')).toBe(false);
expect(isValidToolName('server__*')).toBe(false);
expect(isValidToolName('mcp_*')).toBe(false);
expect(isValidToolName('mcp_server_*')).toBe(false);
// Explicitly allowed
expect(isValidToolName('*', { allowWildcards: true })).toBe(true);
expect(isValidToolName('server__*', { allowWildcards: true })).toBe(true);
expect(isValidToolName('mcp_*', { allowWildcards: true })).toBe(true);
expect(isValidToolName('mcp_server_*', { allowWildcards: true })).toBe(
true,
);
// Invalid wildcards
expect(isValidToolName('__*', { allowWildcards: true })).toBe(false);
expect(isValidToolName('server__tool*', { allowWildcards: true })).toBe(
false,
);
expect(isValidToolName('mcp__*', { allowWildcards: true })).toBe(false);
expect(
isValidToolName('mcp_server_tool*', { allowWildcards: true }),
).toBe(false);
});
});
+38 -13
View File
@@ -221,6 +221,12 @@ export const DISCOVERED_TOOL_PREFIX = 'discovered_tool_';
/**
* List of all built-in tool names.
*/
import {
isMcpToolName,
parseMcpToolName,
MCP_TOOL_PREFIX,
} from './mcp-tool.js';
export const ALL_BUILTIN_TOOL_NAMES = [
GLOB_TOOL_NAME,
WRITE_TODOS_TOOL_NAME,
@@ -290,25 +296,44 @@ export function isValidToolName(
return true;
}
// MCP tools (format: server__tool)
if (name.includes('__')) {
const parts = name.split('__');
if (parts.length !== 2 || parts[0].length === 0 || parts[1].length === 0) {
// Handle standard MCP FQNs (mcp_server_tool or wildcards mcp_*, mcp_server_*)
if (isMcpToolName(name)) {
// Global wildcard: mcp_*
if (name === `${MCP_TOOL_PREFIX}*` && options.allowWildcards) {
return true;
}
// Explicitly reject names with empty server component (e.g. mcp__tool)
if (name.startsWith(`${MCP_TOOL_PREFIX}_`)) {
return false;
}
const server = parts[0];
const tool = parts[1];
const parsed = parseMcpToolName(name);
// Ensure that both components are populated. parseMcpToolName splits at the second _,
// so `mcp__tool` has serverName="", toolName="tool"
if (parsed.serverName && parsed.toolName) {
// Basic slug validation for server and tool names.
// We allow dots (.) and colons (:) as they are valid in function names and
// used for truncation markers.
const slugRegex = /^[a-z0-9_.:-]+$/i;
if (tool === '*') {
return !!options.allowWildcards;
if (!slugRegex.test(parsed.serverName)) {
return false;
}
if (parsed.toolName === '*') {
return options.allowWildcards === true;
}
// A tool name consisting only of underscores is invalid.
if (/^_*$/.test(parsed.toolName)) {
return false;
}
return slugRegex.test(parsed.toolName);
}
// Basic slug validation for server and tool names.
// We allow dots (.) and colons (:) as they are valid in function names and
// used for truncation markers.
const slugRegex = /^[a-z0-9_.:-]+$/i;
return slugRegex.test(server) && slugRegex.test(tool);
return false;
}
return false;
@@ -310,13 +310,13 @@ describe('ToolRegistry', () => {
excludedTools: ['tool-a'],
},
{
name: 'should match simple MCP tool names, when qualified or unqualified',
tools: [mcpTool, mcpTool.asFullyQualifiedTool()],
name: 'should match simple MCP tool names',
tools: [mcpTool],
excludedTools: [mcpTool.name],
},
{
name: 'should match qualified MCP tool names when qualified or unqualified',
tools: [mcpTool, mcpTool.asFullyQualifiedTool()],
name: 'should match qualified MCP tool names',
tools: [mcpTool],
excludedTools: [mcpTool.name],
},
{
@@ -414,9 +414,9 @@ describe('ToolRegistry', () => {
const toolName = 'my-tool';
const mcpTool = createMCPTool(serverName, toolName, 'desc');
// Register same MCP tool twice (one as alias, one as qualified)
// Register same MCP tool twice
toolRegistry.registerTool(mcpTool);
toolRegistry.registerTool(mcpTool);
toolRegistry.registerTool(mcpTool.asFullyQualifiedTool());
const toolNames = toolRegistry.getAllToolNames();
expect(toolNames).toEqual([`mcp_${serverName}_${toolName}`]);
@@ -698,9 +698,8 @@ describe('ToolRegistry', () => {
const toolName = 'my-tool';
const mcpTool = createMCPTool(serverName, toolName, 'description');
// Register both alias and qualified
toolRegistry.registerTool(mcpTool);
toolRegistry.registerTool(mcpTool.asFullyQualifiedTool());
toolRegistry.registerTool(mcpTool);
const declarations = toolRegistry.getFunctionDeclarations();
expect(declarations).toHaveLength(1);
+15 -20
View File
@@ -222,14 +222,10 @@ export class ToolRegistry {
*/
registerTool(tool: AnyDeclarativeTool): void {
if (this.allKnownTools.has(tool.name)) {
if (tool instanceof DiscoveredMCPTool) {
tool = tool.asFullyQualifiedTool();
} else {
// Decide on behavior: throw error, log warning, or allow overwrite
debugLogger.warn(
`Tool with name "${tool.name}" is already registered. Overwriting.`,
);
}
// Decide on behavior: throw error, log warning, or allow overwrite
debugLogger.warn(
`Tool with name "${tool.name}" is already registered. Overwriting.`,
);
}
this.allKnownTools.set(tool.name, tool);
}
@@ -594,7 +590,17 @@ export class ToolRegistry {
for (const name of toolNames) {
const tool = this.getTool(name);
if (tool) {
declarations.push(tool.getSchema(modelId));
let schema = tool.getSchema(modelId);
// Ensure the schema name matches the qualified name for MCP tools
if (tool instanceof DiscoveredMCPTool) {
schema = {
...schema,
name: tool.getFullyQualifiedName(),
};
}
declarations.push(schema);
}
}
return declarations;
@@ -670,17 +676,6 @@ export class ToolRegistry {
}
}
if (!tool && name.includes('__')) {
for (const t of this.allKnownTools.values()) {
if (t instanceof DiscoveredMCPTool) {
if (t.getFullyQualifiedName() === name) {
tool = t;
break;
}
}
}
}
if (tool && this.isActiveTool(tool)) {
return tool;
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-devtools",
"version": "0.34.0-nightly.20260310.4653b126f",
"version": "0.34.0",
"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.34.0-nightly.20260310.4653b126f",
"version": "0.34.0",
"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.34.0-nightly.20260310.4653b126f",
"version": "0.34.0",
"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.34.0-nightly.20260310.4653b126f",
"version": "0.34.0",
"publisher": "google",
"icon": "assets/icon.png",
"repository": {