mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-23 16:20:57 -07:00
Compare commits
10 Commits
buffer-toggle
...
v0.34.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 04b86e81ac | |||
| 49a86550c3 | |||
| 72bca215fa | |||
| 25bb21ed73 | |||
| 810cd67165 | |||
| 62a4d375a8 | |||
| 4085b4f8e7 | |||
| 881822fabc | |||
| c56e58d7a2 | |||
| ec31811c6c |
Generated
+9
-9
@@ -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
@@ -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,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",
|
||||
|
||||
@@ -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)',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,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": {
|
||||
|
||||
@@ -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)', () => {
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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,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,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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user