mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-08-03 05:31:02 -07:00
Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 04b86e81ac | |||
| 49a86550c3 | |||
| 72bca215fa | |||
| 25bb21ed73 | |||
| 810cd67165 | |||
| 62a4d375a8 | |||
| 4085b4f8e7 | |||
| 881822fabc | |||
| c56e58d7a2 | |||
| ec31811c6c | |||
| 657f19c1f3 | |||
| 524679d23c | |||
| 7c4570339e | |||
| e22d9917b7 | |||
| d63053cb59 | |||
| a9500d6a6c | |||
| acbd914d5e | |||
| 300929a32b | |||
| 519c75f410 | |||
| 3ff68a9e55 | |||
| 5b8ad9cd65 | |||
| 5caa192cfc | |||
| 9eae91a489 |
@@ -1,6 +1,6 @@
|
||||
# Preview release: v0.33.0-preview.4
|
||||
# Preview release: v0.33.0-preview.14
|
||||
|
||||
Released: March 06, 2026
|
||||
Released: March 10, 2026
|
||||
|
||||
Our preview release includes the latest, new, and experimental features. This
|
||||
release may not be as stable as our [latest weekly release](latest.md).
|
||||
@@ -29,6 +29,13 @@ npm install -g @google/gemini-cli@preview
|
||||
|
||||
## What's Changed
|
||||
|
||||
- fix(patch): cherry-pick 1b69637 to release/v0.33.0-preview.13-pr-21467
|
||||
[CONFLICTS] by @gemini-cli-robot in
|
||||
[#21930](https://github.com/google-gemini/gemini-cli/pull/21930)
|
||||
- fix(patch): cherry-pick e5615f4 to release/v0.33.0-preview.12-pr-21037 to
|
||||
patch version v0.33.0-preview.12 and create version 0.33.0-preview.13 by
|
||||
@gemini-cli-robot in
|
||||
[#21922](https://github.com/google-gemini/gemini-cli/pull/21922)
|
||||
- fix(patch): cherry-pick 7ec477d to release/v0.33.0-preview.3-pr-21305 to patch
|
||||
version v0.33.0-preview.3 and create version 0.33.0-preview.4 by
|
||||
@gemini-cli-robot in
|
||||
@@ -198,4 +205,4 @@ npm install -g @google/gemini-cli@preview
|
||||
[#20991](https://github.com/google-gemini/gemini-cli/pull/20991)
|
||||
|
||||
**Full Changelog**:
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.32.0-preview.0...v0.33.0-preview.4
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.32.0-preview.0...v0.33.0-preview.14
|
||||
|
||||
@@ -1003,6 +1003,12 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Default:** `false`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.extensionRegistryURI`** (string):
|
||||
- **Description:** The URI (web URL or local file path) of the extension
|
||||
registry.
|
||||
- **Default:** `"https://geminicli.com/extensions.json"`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.extensionReloading`** (boolean):
|
||||
- **Description:** Enables extension loading/unloading within the CLI session.
|
||||
- **Default:** `false`
|
||||
|
||||
Generated
+844
-1230
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.34.0-nightly.20260304.28af4e127",
|
||||
"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.20260304.28af4e127"
|
||||
"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.20260304.28af4e127",
|
||||
"version": "0.34.0",
|
||||
"description": "Gemini CLI A2A Server",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -36,7 +36,7 @@
|
||||
"winston": "^3.17.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@google/genai": "^1.30.0",
|
||||
"@google/genai": "1.30.0",
|
||||
"@types/express": "^5.0.3",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/supertest": "^6.0.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.34.0-nightly.20260304.28af4e127",
|
||||
"version": "0.34.0",
|
||||
"description": "Gemini CLI",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
@@ -26,12 +26,12 @@
|
||||
"dist"
|
||||
],
|
||||
"config": {
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.34.0-nightly.20260304.28af4e127"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.34.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.12.0",
|
||||
"@google/gemini-cli-core": "file:../core",
|
||||
"@google/genai": "1.41.0",
|
||||
"@google/genai": "1.30.0",
|
||||
"@iarna/toml": "^2.2.5",
|
||||
"@modelcontextprotocol/sdk": "^1.23.0",
|
||||
"ansi-escapes": "^7.3.0",
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import yargs from 'yargs/yargs';
|
||||
import { hideBin } from 'yargs/helpers';
|
||||
import process from 'node:process';
|
||||
import * as path from 'node:path';
|
||||
import { mcpCommand } from '../commands/mcp.js';
|
||||
import { extensionsCommand } from '../commands/extensions.js';
|
||||
import { skillsCommand } from '../commands/skills.js';
|
||||
@@ -33,6 +34,7 @@ import {
|
||||
getAdminErrorMessage,
|
||||
isHeadlessMode,
|
||||
Config,
|
||||
resolveToRealPath,
|
||||
applyAdminAllowlist,
|
||||
getAdminBlockedMcpServersMessage,
|
||||
type HookDefinition,
|
||||
@@ -488,6 +490,15 @@ export async function loadCliConfig(
|
||||
|
||||
const experimentalJitContext = settings.experimental?.jitContext ?? false;
|
||||
|
||||
let extensionRegistryURI: string | undefined = trustedFolder
|
||||
? settings.experimental?.extensionRegistryURI
|
||||
: undefined;
|
||||
if (extensionRegistryURI && !extensionRegistryURI.startsWith('http')) {
|
||||
extensionRegistryURI = resolveToRealPath(
|
||||
path.resolve(cwd, resolvePath(extensionRegistryURI)),
|
||||
);
|
||||
}
|
||||
|
||||
let memoryContent: string | HierarchicalMemory = '';
|
||||
let fileCount = 0;
|
||||
let filePaths: string[] = [];
|
||||
@@ -764,6 +775,7 @@ export async function loadCliConfig(
|
||||
deleteSession: argv.deleteSession,
|
||||
enabledExtensions: argv.extensions,
|
||||
extensionLoader: extensionManager,
|
||||
extensionRegistryURI,
|
||||
enableExtensionReloading: settings.experimental?.extensionReloading,
|
||||
enableAgents: settings.experimental?.enableAgents,
|
||||
plan: settings.experimental?.plan,
|
||||
|
||||
@@ -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)',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -157,6 +157,7 @@ export class ExtensionManager extends ExtensionLoader {
|
||||
async installOrUpdateExtension(
|
||||
installMetadata: ExtensionInstallMetadata,
|
||||
previousExtensionConfig?: ExtensionConfig,
|
||||
requestConsentOverride?: (consent: string) => Promise<boolean>,
|
||||
): Promise<GeminiCLIExtension> {
|
||||
if (
|
||||
this.settings.security?.allowedExtensions &&
|
||||
@@ -247,7 +248,7 @@ export class ExtensionManager extends ExtensionLoader {
|
||||
(result.failureReason === 'no release data' &&
|
||||
installMetadata.type === 'git') ||
|
||||
// Otherwise ask the user if they would like to try a git clone.
|
||||
(await this.requestConsent(
|
||||
(await (requestConsentOverride ?? this.requestConsent)(
|
||||
`Error downloading github release for ${installMetadata.source} with the following error: ${result.errorMessage}.
|
||||
|
||||
Would you like to attempt to install via "git clone" instead?`,
|
||||
@@ -321,7 +322,7 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
|
||||
await maybeRequestConsentOrFail(
|
||||
newExtensionConfig,
|
||||
this.requestConsent,
|
||||
requestConsentOverride ?? this.requestConsent,
|
||||
newHasHooks,
|
||||
previousExtensionConfig,
|
||||
previousHasHooks,
|
||||
@@ -563,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);
|
||||
}
|
||||
}
|
||||
@@ -623,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)),
|
||||
);
|
||||
|
||||
@@ -13,14 +13,24 @@ import {
|
||||
afterEach,
|
||||
type Mock,
|
||||
} from 'vitest';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import {
|
||||
ExtensionRegistryClient,
|
||||
type RegistryExtension,
|
||||
} from './extensionRegistryClient.js';
|
||||
import { fetchWithTimeout } from '@google/gemini-cli-core';
|
||||
import { fetchWithTimeout, resolveToRealPath } from '@google/gemini-cli-core';
|
||||
|
||||
vi.mock('@google/gemini-cli-core', () => ({
|
||||
fetchWithTimeout: vi.fn(),
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
fetchWithTimeout: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
readFile: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockExtensions: RegistryExtension[] = [
|
||||
@@ -279,4 +289,32 @@ describe('ExtensionRegistryClient', () => {
|
||||
expect(ids).not.toContain('dataplex');
|
||||
expect(ids).toContain('conductor');
|
||||
});
|
||||
|
||||
it('should fetch extensions from a local file path', async () => {
|
||||
const filePath = '/path/to/extensions.json';
|
||||
const clientWithFile = new ExtensionRegistryClient(filePath);
|
||||
const mockReadFile = vi.mocked(fs.readFile);
|
||||
mockReadFile.mockResolvedValue(JSON.stringify(mockExtensions));
|
||||
|
||||
const result = await clientWithFile.getExtensions();
|
||||
expect(result.extensions).toHaveLength(3);
|
||||
expect(mockReadFile).toHaveBeenCalledWith(
|
||||
resolveToRealPath(filePath),
|
||||
'utf-8',
|
||||
);
|
||||
});
|
||||
|
||||
it('should fetch extensions from a file:// URL', async () => {
|
||||
const fileUrl = 'file:///path/to/extensions.json';
|
||||
const clientWithFileUrl = new ExtensionRegistryClient(fileUrl);
|
||||
const mockReadFile = vi.mocked(fs.readFile);
|
||||
mockReadFile.mockResolvedValue(JSON.stringify(mockExtensions));
|
||||
|
||||
const result = await clientWithFileUrl.getExtensions();
|
||||
expect(result.extensions).toHaveLength(3);
|
||||
expect(mockReadFile).toHaveBeenCalledWith(
|
||||
resolveToRealPath(fileUrl),
|
||||
'utf-8',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,12 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { fetchWithTimeout } from '@google/gemini-cli-core';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import {
|
||||
fetchWithTimeout,
|
||||
resolveToRealPath,
|
||||
isPrivateIp,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { AsyncFzf } from 'fzf';
|
||||
|
||||
export interface RegistryExtension {
|
||||
@@ -29,12 +34,19 @@ export interface RegistryExtension {
|
||||
}
|
||||
|
||||
export class ExtensionRegistryClient {
|
||||
private static readonly REGISTRY_URL =
|
||||
static readonly DEFAULT_REGISTRY_URL =
|
||||
'https://geminicli.com/extensions.json';
|
||||
private static readonly FETCH_TIMEOUT_MS = 10000; // 10 seconds
|
||||
|
||||
private static fetchPromise: Promise<RegistryExtension[]> | null = null;
|
||||
|
||||
private readonly registryURI: string;
|
||||
|
||||
constructor(registryURI?: string) {
|
||||
this.registryURI =
|
||||
registryURI || ExtensionRegistryClient.DEFAULT_REGISTRY_URL;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
static resetCache() {
|
||||
ExtensionRegistryClient.fetchPromise = null;
|
||||
@@ -97,18 +109,34 @@ export class ExtensionRegistryClient {
|
||||
return ExtensionRegistryClient.fetchPromise;
|
||||
}
|
||||
|
||||
const uri = this.registryURI;
|
||||
ExtensionRegistryClient.fetchPromise = (async () => {
|
||||
try {
|
||||
const response = await fetchWithTimeout(
|
||||
ExtensionRegistryClient.REGISTRY_URL,
|
||||
ExtensionRegistryClient.FETCH_TIMEOUT_MS,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch extensions: ${response.statusText}`);
|
||||
}
|
||||
if (uri.startsWith('http')) {
|
||||
if (isPrivateIp(uri)) {
|
||||
throw new Error(
|
||||
'Private IP addresses are not allowed for the extension registry.',
|
||||
);
|
||||
}
|
||||
const response = await fetchWithTimeout(
|
||||
uri,
|
||||
ExtensionRegistryClient.FETCH_TIMEOUT_MS,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch extensions: ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return (await response.json()) as RegistryExtension[];
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return (await response.json()) as RegistryExtension[];
|
||||
} else {
|
||||
// Handle local file path
|
||||
const filePath = resolveToRealPath(uri);
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return JSON.parse(content) as RegistryExtension[];
|
||||
}
|
||||
} catch (error) {
|
||||
ExtensionRegistryClient.fetchPromise = null;
|
||||
throw error;
|
||||
|
||||
@@ -1791,6 +1791,16 @@ const SETTINGS_SCHEMA = {
|
||||
description: 'Enable extension registry explore UI.',
|
||||
showInDialog: false,
|
||||
},
|
||||
extensionRegistryURI: {
|
||||
type: 'string',
|
||||
label: 'Extension Registry URI',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: 'https://geminicli.com/extensions.json',
|
||||
description:
|
||||
'The URI (web URL or local file path) of the extension registry.',
|
||||
showInDialog: false,
|
||||
},
|
||||
extensionReloading: {
|
||||
type: 'boolean',
|
||||
label: 'Extension Reloading',
|
||||
|
||||
@@ -109,6 +109,7 @@ import { OverflowProvider } from './ui/contexts/OverflowContext.js';
|
||||
import { setupTerminalAndTheme } from './utils/terminalTheme.js';
|
||||
import { profiler } from './ui/components/DebugProfiler.js';
|
||||
import { runDeferredCommand } from './deferred.js';
|
||||
import { cleanupBackgroundLogs } from './utils/logCleanup.js';
|
||||
import { SlashCommandConflictHandler } from './services/SlashCommandConflictHandler.js';
|
||||
|
||||
const SLOW_RENDER_MS = 200;
|
||||
@@ -370,6 +371,7 @@ export async function main() {
|
||||
await Promise.all([
|
||||
cleanupCheckpoints(),
|
||||
cleanupToolOutputFiles(settings.merged),
|
||||
cleanupBackgroundLogs(),
|
||||
]);
|
||||
|
||||
const parseArgsHandle = startupProfiler.start('parse_arguments');
|
||||
|
||||
@@ -3145,7 +3145,7 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('clears the prompt when onCancelSubmit is called with shouldRestorePrompt=false', async () => {
|
||||
it('preserves buffer when cancelling, even if empty (user is in control)', async () => {
|
||||
let unmount: () => void;
|
||||
await act(async () => {
|
||||
const result = renderAppContainer();
|
||||
@@ -3161,7 +3161,45 @@ describe('AppContainer State Management', () => {
|
||||
onCancelSubmit(false);
|
||||
});
|
||||
|
||||
expect(mockSetText).toHaveBeenCalledWith('');
|
||||
// Should NOT modify buffer when cancelling - user is in control
|
||||
expect(mockSetText).not.toHaveBeenCalled();
|
||||
|
||||
unmount!();
|
||||
});
|
||||
|
||||
it('preserves prompt text when cancelling streaming, even if same as last message (regression test for issue #13387)', async () => {
|
||||
// Mock buffer with text that user typed while streaming (same as last message)
|
||||
const promptText = 'What is Python?';
|
||||
mockedUseTextBuffer.mockReturnValue({
|
||||
text: promptText,
|
||||
setText: mockSetText,
|
||||
});
|
||||
|
||||
// Mock input history with same message
|
||||
mockedUseInputHistoryStore.mockReturnValue({
|
||||
inputHistory: [promptText],
|
||||
addInput: vi.fn(),
|
||||
initializeFromLogger: vi.fn(),
|
||||
});
|
||||
|
||||
let unmount: () => void;
|
||||
await act(async () => {
|
||||
const result = renderAppContainer();
|
||||
unmount = result.unmount;
|
||||
});
|
||||
await waitFor(() => expect(capturedUIState).toBeTruthy());
|
||||
|
||||
const { onCancelSubmit } = extractUseGeminiStreamArgs(
|
||||
mockedUseGeminiStream.mock.lastCall!,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
// Simulate Escape key cancelling streaming (shouldRestorePrompt=false)
|
||||
onCancelSubmit(false);
|
||||
});
|
||||
|
||||
// Should NOT call setText - prompt should be preserved regardless of content
|
||||
expect(mockSetText).not.toHaveBeenCalled();
|
||||
|
||||
unmount!();
|
||||
});
|
||||
|
||||
@@ -473,9 +473,11 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
disableMouseEvents();
|
||||
|
||||
// Kill all background shells
|
||||
for (const pid of backgroundShellsRef.current.keys()) {
|
||||
ShellExecutionService.kill(pid);
|
||||
}
|
||||
await Promise.all(
|
||||
Array.from(backgroundShellsRef.current.keys()).map((pid) =>
|
||||
ShellExecutionService.kill(pid),
|
||||
),
|
||||
);
|
||||
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
await ideClient.disconnect();
|
||||
@@ -1220,8 +1222,15 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
return;
|
||||
}
|
||||
|
||||
// If cancelling (shouldRestorePrompt=false), never modify the buffer
|
||||
// User is in control - preserve whatever text they typed, pasted, or restored
|
||||
if (!shouldRestorePrompt) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Restore the last message when shouldRestorePrompt=true
|
||||
const lastUserMessage = inputHistory.at(-1);
|
||||
let textToSet = shouldRestorePrompt ? lastUserMessage || '' : '';
|
||||
let textToSet = lastUserMessage || '';
|
||||
|
||||
const queuedText = getQueuedMessagesText();
|
||||
if (queuedText) {
|
||||
@@ -1229,7 +1238,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
clearQueue();
|
||||
}
|
||||
|
||||
if (textToSet || !shouldRestorePrompt) {
|
||||
if (textToSet) {
|
||||
buffer.setText(textToSet);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -475,14 +475,18 @@ describe('extensionsCommand', () => {
|
||||
mockInstallExtension.mockResolvedValue({ name: extension.url });
|
||||
|
||||
// Call onSelect
|
||||
component.props.onSelect?.(extension);
|
||||
await component.props.onSelect?.(extension);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(inferInstallMetadata).toHaveBeenCalledWith(extension.url);
|
||||
expect(mockInstallExtension).toHaveBeenCalledWith({
|
||||
source: extension.url,
|
||||
type: 'git',
|
||||
});
|
||||
expect(mockInstallExtension).toHaveBeenCalledWith(
|
||||
{
|
||||
source: extension.url,
|
||||
type: 'git',
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
expect(mockContext.ui.removeComponent).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -622,10 +626,14 @@ describe('extensionsCommand', () => {
|
||||
mockInstallExtension.mockResolvedValue({ name: packageName });
|
||||
await installAction!(mockContext, packageName);
|
||||
expect(inferInstallMetadata).toHaveBeenCalledWith(packageName);
|
||||
expect(mockInstallExtension).toHaveBeenCalledWith({
|
||||
source: packageName,
|
||||
type: 'git',
|
||||
});
|
||||
expect(mockInstallExtension).toHaveBeenCalledWith(
|
||||
{
|
||||
source: packageName,
|
||||
type: 'git',
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith({
|
||||
type: MessageType.INFO,
|
||||
text: `Installing extension from "${packageName}"...`,
|
||||
@@ -647,10 +655,14 @@ describe('extensionsCommand', () => {
|
||||
|
||||
await installAction!(mockContext, packageName);
|
||||
expect(inferInstallMetadata).toHaveBeenCalledWith(packageName);
|
||||
expect(mockInstallExtension).toHaveBeenCalledWith({
|
||||
source: packageName,
|
||||
type: 'git',
|
||||
});
|
||||
expect(mockInstallExtension).toHaveBeenCalledWith(
|
||||
{
|
||||
source: packageName,
|
||||
type: 'git',
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith({
|
||||
type: MessageType.ERROR,
|
||||
text: `Failed to install extension from "${packageName}": ${errorMessage}`,
|
||||
|
||||
@@ -279,9 +279,9 @@ async function exploreAction(
|
||||
return {
|
||||
type: 'custom_dialog' as const,
|
||||
component: React.createElement(ExtensionRegistryView, {
|
||||
onSelect: (extension) => {
|
||||
onSelect: async (extension, requestConsentOverride) => {
|
||||
debugLogger.log(`Selected extension: ${extension.extensionName}`);
|
||||
void installAction(context, extension.url);
|
||||
await installAction(context, extension.url, requestConsentOverride);
|
||||
context.ui.removeComponent();
|
||||
},
|
||||
onClose: () => context.ui.removeComponent(),
|
||||
@@ -458,7 +458,11 @@ async function enableAction(context: CommandContext, args: string) {
|
||||
}
|
||||
}
|
||||
|
||||
async function installAction(context: CommandContext, args: string) {
|
||||
async function installAction(
|
||||
context: CommandContext,
|
||||
args: string,
|
||||
requestConsentOverride?: (consent: string) => Promise<boolean>,
|
||||
) {
|
||||
const extensionLoader = context.services.config?.getExtensionLoader();
|
||||
if (!(extensionLoader instanceof ExtensionManager)) {
|
||||
debugLogger.error(
|
||||
@@ -505,8 +509,11 @@ async function installAction(context: CommandContext, args: string) {
|
||||
|
||||
try {
|
||||
const installMetadata = await inferInstallMetadata(source);
|
||||
const extension =
|
||||
await extensionLoader.installOrUpdateExtension(installMetadata);
|
||||
const extension = await extensionLoader.installOrUpdateExtension(
|
||||
installMetadata,
|
||||
undefined,
|
||||
requestConsentOverride,
|
||||
);
|
||||
context.ui.addItem({
|
||||
type: MessageType.INFO,
|
||||
text: `Extension "${extension.name}" installed successfully.`,
|
||||
|
||||
@@ -35,6 +35,10 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
ShellExecutionService: {
|
||||
resizePty: vi.fn(),
|
||||
subscribe: vi.fn(() => vi.fn()),
|
||||
getLogFilePath: vi.fn(
|
||||
(pid) => `~/.gemini/tmp/background-processes/background-${pid}.log`,
|
||||
),
|
||||
getLogDir: vi.fn(() => '~/.gemini/tmp/background-processes'),
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -222,7 +226,7 @@ describe('<BackgroundShellDisplay />', () => {
|
||||
expect(ShellExecutionService.resizePty).toHaveBeenCalledWith(
|
||||
shell1.pid,
|
||||
76,
|
||||
21,
|
||||
20,
|
||||
);
|
||||
|
||||
rerender(
|
||||
@@ -242,7 +246,7 @@ describe('<BackgroundShellDisplay />', () => {
|
||||
expect(ShellExecutionService.resizePty).toHaveBeenCalledWith(
|
||||
shell1.pid,
|
||||
96,
|
||||
27,
|
||||
26,
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -10,6 +10,8 @@ import { useUIActions } from '../contexts/UIActionsContext.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import {
|
||||
ShellExecutionService,
|
||||
shortenPath,
|
||||
tildeifyPath,
|
||||
type AnsiOutput,
|
||||
type AnsiLine,
|
||||
type AnsiToken,
|
||||
@@ -43,8 +45,14 @@ interface BackgroundShellDisplayProps {
|
||||
|
||||
const CONTENT_PADDING_X = 1;
|
||||
const BORDER_WIDTH = 2; // Left and Right border
|
||||
const HEADER_HEIGHT = 3; // 2 for border, 1 for header
|
||||
const MAIN_BORDER_HEIGHT = 2; // Top and Bottom border
|
||||
const HEADER_HEIGHT = 1;
|
||||
const FOOTER_HEIGHT = 1;
|
||||
const TOTAL_OVERHEAD_HEIGHT =
|
||||
MAIN_BORDER_HEIGHT + HEADER_HEIGHT + FOOTER_HEIGHT;
|
||||
const PROCESS_LIST_HEADER_HEIGHT = 3; // 1 padding top, 1 text, 1 margin bottom
|
||||
const TAB_DISPLAY_HORIZONTAL_PADDING = 4;
|
||||
const LOG_PATH_OVERHEAD = 7; // "Log: " (5) + paddingX (2)
|
||||
|
||||
const formatShellCommandForDisplay = (command: string, maxWidth: number) => {
|
||||
const commandFirstLine = command.split('\n')[0];
|
||||
@@ -81,7 +89,7 @@ export const BackgroundShellDisplay = ({
|
||||
if (!activePid) return;
|
||||
|
||||
const ptyWidth = Math.max(1, width - BORDER_WIDTH - CONTENT_PADDING_X * 2);
|
||||
const ptyHeight = Math.max(1, height - HEADER_HEIGHT);
|
||||
const ptyHeight = Math.max(1, height - TOTAL_OVERHEAD_HEIGHT);
|
||||
ShellExecutionService.resizePty(activePid, ptyWidth, ptyHeight);
|
||||
}, [activePid, width, height]);
|
||||
|
||||
@@ -150,7 +158,7 @@ export const BackgroundShellDisplay = ({
|
||||
|
||||
if (keyMatchers[Command.KILL_BACKGROUND_SHELL](key)) {
|
||||
if (highlightedPid) {
|
||||
dismissBackgroundShell(highlightedPid);
|
||||
void dismissBackgroundShell(highlightedPid);
|
||||
// If we killed the active one, the list might update via props
|
||||
}
|
||||
return true;
|
||||
@@ -171,7 +179,7 @@ export const BackgroundShellDisplay = ({
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.KILL_BACKGROUND_SHELL](key)) {
|
||||
dismissBackgroundShell(activeShell.pid);
|
||||
void dismissBackgroundShell(activeShell.pid);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -336,7 +344,10 @@ export const BackgroundShellDisplay = ({
|
||||
}}
|
||||
onHighlight={(pid) => setHighlightedPid(pid)}
|
||||
isFocused={isFocused}
|
||||
maxItemsToShow={Math.max(1, height - HEADER_HEIGHT - 3)} // Adjust for header
|
||||
maxItemsToShow={Math.max(
|
||||
1,
|
||||
height - TOTAL_OVERHEAD_HEIGHT - PROCESS_LIST_HEADER_HEIGHT,
|
||||
)}
|
||||
renderItem={(
|
||||
item,
|
||||
{ isSelected: _isSelected, titleColor: _titleColor },
|
||||
@@ -383,6 +394,23 @@ export const BackgroundShellDisplay = ({
|
||||
);
|
||||
};
|
||||
|
||||
const renderFooter = () => {
|
||||
const pidToDisplay = isListOpenProp
|
||||
? (highlightedPid ?? activePid)
|
||||
: activePid;
|
||||
if (!pidToDisplay) return null;
|
||||
const logPath = ShellExecutionService.getLogFilePath(pidToDisplay);
|
||||
const displayPath = shortenPath(
|
||||
tildeifyPath(logPath),
|
||||
width - LOG_PATH_OVERHEAD,
|
||||
);
|
||||
return (
|
||||
<Box paddingX={1}>
|
||||
<Text color={theme.text.secondary}>Log: {displayPath}</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const renderOutput = () => {
|
||||
const lines = typeof output === 'string' ? output.split('\n') : output;
|
||||
|
||||
@@ -454,6 +482,7 @@ export const BackgroundShellDisplay = ({
|
||||
<Box flexGrow={1} overflow="hidden" paddingX={CONTENT_PADDING_X}>
|
||||
{isListOpenProp ? renderProcessList() : renderOutput()}
|
||||
</Box>
|
||||
{renderFooter()}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -831,7 +831,7 @@ describe('Composer', () => {
|
||||
expect(lastFrame({ allowEmpty: true })).toContain('ShortcutsHint');
|
||||
});
|
||||
|
||||
it('does not show shortcuts hint immediately when buffer has text', async () => {
|
||||
it('hides shortcuts hint when text is typed in buffer', async () => {
|
||||
const uiState = createMockUIState({
|
||||
buffer: { text: 'hello' } as unknown as TextBuffer,
|
||||
cleanUiDetailsVisible: false,
|
||||
@@ -901,16 +901,6 @@ describe('Composer', () => {
|
||||
expect(lastFrame()).not.toContain('ShortcutsHint');
|
||||
});
|
||||
|
||||
it('hides shortcuts hint when text is typed in buffer', async () => {
|
||||
const uiState = createMockUIState({
|
||||
buffer: { text: 'hello' } as unknown as TextBuffer,
|
||||
});
|
||||
|
||||
const { lastFrame } = await renderComposer(uiState);
|
||||
|
||||
expect(lastFrame()).not.toContain('ShortcutsHint');
|
||||
});
|
||||
|
||||
it('hides shortcuts hint while loading in minimal mode', async () => {
|
||||
const uiState = createMockUIState({
|
||||
cleanUiDetailsVisible: false,
|
||||
|
||||
@@ -171,10 +171,10 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
||||
return () => clearTimeout(timeout);
|
||||
}, [canShowShortcutsHint]);
|
||||
|
||||
const shouldReserveSpaceForShortcutsHint =
|
||||
settings.merged.ui.showShortcutsHint && !hideShortcutsHintForSuggestions;
|
||||
const showShortcutsHint =
|
||||
settings.merged.ui.showShortcutsHint &&
|
||||
!hideShortcutsHintForSuggestions &&
|
||||
showShortcutsHintDebounced;
|
||||
shouldReserveSpaceForShortcutsHint && showShortcutsHintDebounced;
|
||||
const showMinimalModeBleedThrough =
|
||||
!hideUiDetailsForSuggestions && Boolean(minimalModeBleedThrough);
|
||||
const showMinimalInlineLoading = !showUiDetails && showLoadingIndicator;
|
||||
@@ -187,7 +187,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
||||
!showUiDetails &&
|
||||
(showMinimalInlineLoading ||
|
||||
showMinimalBleedThroughRow ||
|
||||
showShortcutsHint);
|
||||
shouldReserveSpaceForShortcutsHint);
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -249,6 +249,9 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
||||
marginTop={isNarrow ? 1 : 0}
|
||||
flexDirection="column"
|
||||
alignItems={isNarrow ? 'flex-start' : 'flex-end'}
|
||||
minHeight={
|
||||
showUiDetails && shouldReserveSpaceForShortcutsHint ? 1 : 0
|
||||
}
|
||||
>
|
||||
{showUiDetails && showShortcutsHint && <ShortcutsHint />}
|
||||
</Box>
|
||||
@@ -304,11 +307,13 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
{(showMinimalContextBleedThrough || showShortcutsHint) && (
|
||||
{(showMinimalContextBleedThrough ||
|
||||
shouldReserveSpaceForShortcutsHint) && (
|
||||
<Box
|
||||
marginTop={isNarrow && showMinimalBleedThroughRow ? 1 : 0}
|
||||
flexDirection={isNarrow ? 'column' : 'row'}
|
||||
alignItems={isNarrow ? 'flex-start' : 'flex-end'}
|
||||
minHeight={1}
|
||||
>
|
||||
{showMinimalContextBleedThrough && (
|
||||
<ContextUsageDisplay
|
||||
@@ -317,18 +322,14 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
||||
terminalWidth={uiState.terminalWidth}
|
||||
/>
|
||||
)}
|
||||
{showShortcutsHint && (
|
||||
<Box
|
||||
marginLeft={
|
||||
showMinimalContextBleedThrough && !isNarrow ? 1 : 0
|
||||
}
|
||||
marginTop={
|
||||
showMinimalContextBleedThrough && isNarrow ? 1 : 0
|
||||
}
|
||||
>
|
||||
<ShortcutsHint />
|
||||
</Box>
|
||||
)}
|
||||
<Box
|
||||
marginLeft={
|
||||
showMinimalContextBleedThrough && !isNarrow ? 1 : 0
|
||||
}
|
||||
marginTop={showMinimalContextBleedThrough && isNarrow ? 1 : 0}
|
||||
>
|
||||
{showShortcutsHint && <ShortcutsHint />}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -101,6 +101,12 @@ describe('<Footer />', () => {
|
||||
beforeEach(() => {
|
||||
const root = path.parse(process.cwd()).root;
|
||||
vi.stubEnv('GEMINI_CLI_HOME', path.join(root, 'Users', 'test'));
|
||||
vi.stubEnv('SANDBOX', '');
|
||||
vi.stubEnv('SEATBELT_PROFILE', '');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('renders the component', async () => {
|
||||
@@ -427,15 +433,6 @@ describe('<Footer />', () => {
|
||||
});
|
||||
|
||||
describe('footer configuration filtering (golden snapshots)', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('SANDBOX', '');
|
||||
vi.stubEnv('SEATBELT_PROFILE', '');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('renders complete footer with all sections visible (baseline)', async () => {
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<Footer />,
|
||||
@@ -459,23 +456,21 @@ describe('<Footer />', () => {
|
||||
});
|
||||
|
||||
it('renders footer with all optional sections hidden (minimal footer)', async () => {
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<Footer />,
|
||||
{
|
||||
width: 120,
|
||||
uiState: { sessionStats: mockSessionStats },
|
||||
settings: createMockSettings({
|
||||
ui: {
|
||||
footer: {
|
||||
hideCWD: true,
|
||||
hideSandboxStatus: true,
|
||||
hideModelInfo: true,
|
||||
},
|
||||
const { lastFrame, unmount } = renderWithProviders(<Footer />, {
|
||||
width: 120,
|
||||
uiState: { sessionStats: mockSessionStats },
|
||||
settings: createMockSettings({
|
||||
ui: {
|
||||
footer: {
|
||||
hideCWD: true,
|
||||
hideSandboxStatus: true,
|
||||
hideModelInfo: true,
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
},
|
||||
}),
|
||||
});
|
||||
// Wait for Ink to render
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
expect(normalizeFrame(lastFrame({ allowEmpty: true }))).toMatchSnapshot(
|
||||
'footer-minimal',
|
||||
);
|
||||
@@ -797,21 +792,19 @@ describe('<Footer />', () => {
|
||||
});
|
||||
|
||||
it('handles empty items array', async () => {
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<Footer />,
|
||||
{
|
||||
width: 120,
|
||||
uiState: { sessionStats: mockSessionStats },
|
||||
settings: createMockSettings({
|
||||
ui: {
|
||||
footer: {
|
||||
items: [],
|
||||
},
|
||||
const { lastFrame, unmount } = renderWithProviders(<Footer />, {
|
||||
width: 120,
|
||||
uiState: { sessionStats: mockSessionStats },
|
||||
settings: createMockSettings({
|
||||
ui: {
|
||||
footer: {
|
||||
items: [],
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
},
|
||||
}),
|
||||
});
|
||||
// Wait for Ink to render
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
const output = lastFrame({ allowEmpty: true });
|
||||
expect(output).toBeDefined();
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -410,6 +410,7 @@ describe('<ModelStatsDisplay />', () => {
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('gemini-3-pro-');
|
||||
expect(output).toContain('gemini-3-flash-');
|
||||
expect(output).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ exports[`<BackgroundShellDisplay /> > highlights the focused state 1`] = `
|
||||
│ 1: npm sta.. (PID: 1001) Close (Ctrl+B) | Kill (Ctrl+K) | List │
|
||||
│ (Focused) (Ctrl+L) │
|
||||
│ Starting server... │
|
||||
│ Log: ~/.gemini/tmp/background-processes/background-1001.log │
|
||||
└──────────────────────────────────────────────────────────────────────────────┘
|
||||
"
|
||||
`;
|
||||
@@ -19,6 +20,7 @@ exports[`<BackgroundShellDisplay /> > keeps exit code status color even when sel
|
||||
│ 1. npm start (PID: 1001) │
|
||||
│ 2. tail -f log.txt (PID: 1002) │
|
||||
│ ● 3. exit 0 (PID: 1003) (Exit Code: 0) │
|
||||
│ Log: ~/.gemini/tmp/background-processes/background-1003.log │
|
||||
└──────────────────────────────────────────────────────────────────────────────┘
|
||||
"
|
||||
`;
|
||||
@@ -27,6 +29,7 @@ exports[`<BackgroundShellDisplay /> > renders tabs for multiple shells 1`] = `
|
||||
"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 1: npm start 2: tail -f lo... (PID: 1001) Close (Ctrl+B) | Kill (Ctrl+K) | List (Ctrl+L) │
|
||||
│ Starting server... │
|
||||
│ Log: ~/.gemini/tmp/background-processes/background-1001.log │
|
||||
└──────────────────────────────────────────────────────────────────────────────────────────────────┘
|
||||
"
|
||||
`;
|
||||
@@ -35,6 +38,7 @@ exports[`<BackgroundShellDisplay /> > renders the output of the active shell 1`]
|
||||
"┌──────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 1: ... 2: ... (PID: 1001) Close (Ctrl+B) | Kill (Ctrl+K) | List (Ctrl+L) │
|
||||
│ Starting server... │
|
||||
│ Log: ~/.gemini/tmp/background-processes/background-1001.log │
|
||||
└──────────────────────────────────────────────────────────────────────────────┘
|
||||
"
|
||||
`;
|
||||
@@ -48,6 +52,7 @@ exports[`<BackgroundShellDisplay /> > renders the process list when isListOpenPr
|
||||
│ │
|
||||
│ ● 1. npm start (PID: 1001) │
|
||||
│ 2. tail -f log.txt (PID: 1002) │
|
||||
│ Log: ~/.gemini/tmp/background-processes/background-1001.log │
|
||||
└──────────────────────────────────────────────────────────────────────────────┘
|
||||
"
|
||||
`;
|
||||
@@ -61,6 +66,7 @@ exports[`<BackgroundShellDisplay /> > scrolls to active shell when list opens 1`
|
||||
│ │
|
||||
│ 1. npm start (PID: 1001) │
|
||||
│ ● 2. tail -f log.txt (PID: 1002) │
|
||||
│ Log: ~/.gemini/tmp/background-processes/background-1002.log │
|
||||
└──────────────────────────────────────────────────────────────────────────────┘
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -165,6 +165,29 @@ exports[`<ModelStatsDisplay /> > should handle long role name layout 1`] = `
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<ModelStatsDisplay /> > should handle models with long names (gemini-3-*-preview) without layout breaking 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ Auto (Gemini 3) Stats For Nerds │
|
||||
│ │
|
||||
│ │
|
||||
│ Metric gemini-3-pro-preview gemini-3-flash-preview │
|
||||
│ ────────────────────────────────────────────────────────────────────────── │
|
||||
│ API │
|
||||
│ Requests 10 20 │
|
||||
│ Errors 0 (0.0%) 0 (0.0%) │
|
||||
│ Avg Latency 200ms 50ms │
|
||||
│ Tokens │
|
||||
│ Total 6,000 12,000 │
|
||||
│ ↳ Input 1,000 2,000 │
|
||||
│ ↳ Cache Reads 500 (25.0%) 1,000 (25.0%) │
|
||||
│ ↳ Thoughts 100 200 │
|
||||
│ ↳ Tool 50 100 │
|
||||
│ ↳ Output 4,000 8,000 │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<ModelStatsDisplay /> > should not display conditional rows if no model has data for them 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
|
||||
@@ -67,6 +67,8 @@ export interface SearchableListProps<T extends GenericListItem> {
|
||||
onSearch?: (query: string) => void;
|
||||
/** Whether to reset selection to the top when items change (e.g. after search) */
|
||||
resetSelectionOnItemsChange?: boolean;
|
||||
/** Whether the list is focused and accepts keyboard input. Defaults to true. */
|
||||
isFocused?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -85,6 +87,7 @@ export function SearchableList<T extends GenericListItem>({
|
||||
useSearch,
|
||||
onSearch,
|
||||
resetSelectionOnItemsChange = false,
|
||||
isFocused = true,
|
||||
}: SearchableListProps<T>): React.JSX.Element {
|
||||
const keyMatchers = useKeyMatchers();
|
||||
const { filteredItems, searchBuffer, maxLabelWidth } = useSearch({
|
||||
@@ -111,7 +114,7 @@ export function SearchableList<T extends GenericListItem>({
|
||||
const { activeIndex, setActiveIndex } = useSelectionList({
|
||||
items: selectionItems,
|
||||
onSelect: handleSelectValue,
|
||||
isFocused: true,
|
||||
isFocused,
|
||||
showNumbers: false,
|
||||
wrapAround: true,
|
||||
priority: true,
|
||||
@@ -157,7 +160,7 @@ export function SearchableList<T extends GenericListItem>({
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ isActive: true },
|
||||
{ isActive: isFocused },
|
||||
);
|
||||
|
||||
const visibleItems = filteredItems.slice(
|
||||
@@ -209,7 +212,7 @@ export function SearchableList<T extends GenericListItem>({
|
||||
<TextInput
|
||||
buffer={searchBuffer}
|
||||
placeholder={searchPlaceholder}
|
||||
focus={true}
|
||||
focus={isFocused}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { render } from '../../../test-utils/render.js';
|
||||
import { waitFor } from '../../../test-utils/async.js';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { ExtensionDetails } from './ExtensionDetails.js';
|
||||
import { KeypressProvider } from '../../contexts/KeypressContext.js';
|
||||
import { type RegistryExtension } from '../../../config/extensionRegistryClient.js';
|
||||
|
||||
const mockExtension: RegistryExtension = {
|
||||
id: 'ext1',
|
||||
extensionName: 'Test Extension',
|
||||
extensionDescription: 'A test extension description',
|
||||
fullName: 'author/test-extension',
|
||||
extensionVersion: '1.2.3',
|
||||
rank: 1,
|
||||
stars: 123,
|
||||
url: 'https://github.com/author/test-extension',
|
||||
repoDescription: 'Repo description',
|
||||
avatarUrl: '',
|
||||
lastUpdated: '2023-10-27',
|
||||
hasMCP: true,
|
||||
hasContext: true,
|
||||
hasHooks: true,
|
||||
hasSkills: true,
|
||||
hasCustomCommands: true,
|
||||
isGoogleOwned: true,
|
||||
licenseKey: 'Apache-2.0',
|
||||
};
|
||||
|
||||
describe('ExtensionDetails', () => {
|
||||
let mockOnBack: ReturnType<typeof vi.fn>;
|
||||
let mockOnInstall: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockOnBack = vi.fn();
|
||||
mockOnInstall = vi.fn();
|
||||
});
|
||||
|
||||
const renderDetails = (isInstalled = false) =>
|
||||
render(
|
||||
<KeypressProvider>
|
||||
<ExtensionDetails
|
||||
extension={mockExtension}
|
||||
onBack={mockOnBack}
|
||||
onInstall={mockOnInstall}
|
||||
isInstalled={isInstalled}
|
||||
/>
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
it('should render extension details correctly', async () => {
|
||||
const { lastFrame } = renderDetails();
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Test Extension');
|
||||
expect(lastFrame()).toContain('v1.2.3');
|
||||
expect(lastFrame()).toContain('123');
|
||||
expect(lastFrame()).toContain('[G]');
|
||||
expect(lastFrame()).toContain('author/test-extension');
|
||||
expect(lastFrame()).toContain('A test extension description');
|
||||
expect(lastFrame()).toContain('MCP');
|
||||
expect(lastFrame()).toContain('Context file');
|
||||
expect(lastFrame()).toContain('Hooks');
|
||||
expect(lastFrame()).toContain('Skills');
|
||||
expect(lastFrame()).toContain('Commands');
|
||||
});
|
||||
});
|
||||
|
||||
it('should show install prompt when not installed', async () => {
|
||||
const { lastFrame } = renderDetails(false);
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('[Enter] Install');
|
||||
expect(lastFrame()).not.toContain('Already Installed');
|
||||
});
|
||||
});
|
||||
|
||||
it('should show already installed message when installed', async () => {
|
||||
const { lastFrame } = renderDetails(true);
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Already Installed');
|
||||
expect(lastFrame()).not.toContain('[Enter] Install');
|
||||
});
|
||||
});
|
||||
|
||||
it('should call onBack when Escape is pressed', async () => {
|
||||
const { stdin } = renderDetails();
|
||||
await React.act(async () => {
|
||||
stdin.write('\x1b'); // Escape
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(mockOnBack).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should call onInstall when Enter is pressed and not installed', async () => {
|
||||
const { stdin } = renderDetails(false);
|
||||
await React.act(async () => {
|
||||
stdin.write('\r'); // Enter
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(mockOnInstall).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should NOT call onInstall when Enter is pressed and already installed', async () => {
|
||||
vi.useFakeTimers();
|
||||
const { stdin } = renderDetails(true);
|
||||
await React.act(async () => {
|
||||
stdin.write('\r'); // Enter
|
||||
});
|
||||
// Advance timers to trigger the keypress flush
|
||||
await React.act(async () => {
|
||||
vi.runAllTimers();
|
||||
});
|
||||
expect(mockOnInstall).not.toHaveBeenCalled();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useState } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import type { RegistryExtension } from '../../../config/extensionRegistryClient.js';
|
||||
import { useKeypress } from '../../hooks/useKeypress.js';
|
||||
import { Command } from '../../key/keyMatchers.js';
|
||||
import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
|
||||
export interface ExtensionDetailsProps {
|
||||
extension: RegistryExtension;
|
||||
onBack: () => void;
|
||||
onInstall: (
|
||||
requestConsentOverride: (consent: string) => Promise<boolean>,
|
||||
) => void | Promise<void>;
|
||||
isInstalled: boolean;
|
||||
}
|
||||
|
||||
export function ExtensionDetails({
|
||||
extension,
|
||||
onBack,
|
||||
onInstall,
|
||||
isInstalled,
|
||||
}: ExtensionDetailsProps): React.JSX.Element {
|
||||
const keyMatchers = useKeyMatchers();
|
||||
const [consentRequest, setConsentRequest] = useState<{
|
||||
prompt: string;
|
||||
resolve: (value: boolean) => void;
|
||||
} | null>(null);
|
||||
const [isInstalling, setIsInstalling] = useState(false);
|
||||
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (consentRequest) {
|
||||
if (keyMatchers[Command.ESCAPE](key)) {
|
||||
consentRequest.resolve(false);
|
||||
setConsentRequest(null);
|
||||
setIsInstalling(false);
|
||||
return true;
|
||||
}
|
||||
if (keyMatchers[Command.RETURN](key)) {
|
||||
consentRequest.resolve(true);
|
||||
setConsentRequest(null);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.ESCAPE](key)) {
|
||||
onBack();
|
||||
return true;
|
||||
}
|
||||
if (keyMatchers[Command.RETURN](key) && !isInstalled && !isInstalling) {
|
||||
setIsInstalling(true);
|
||||
void onInstall(
|
||||
(prompt: string) =>
|
||||
new Promise((resolve) => {
|
||||
setConsentRequest({ prompt, resolve });
|
||||
}),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ isActive: true, priority: true },
|
||||
);
|
||||
|
||||
if (consentRequest) {
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
paddingX={1}
|
||||
paddingY={0}
|
||||
height="100%"
|
||||
borderStyle="round"
|
||||
borderColor={theme.status.warning}
|
||||
>
|
||||
<Box marginBottom={1}>
|
||||
<Text color={theme.text.primary}>{consentRequest.prompt}</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1} />
|
||||
<Box flexDirection="row" justifyContent="space-between" marginTop={1}>
|
||||
<Text color={theme.text.secondary}>[Esc] Cancel</Text>
|
||||
<Text color={theme.text.primary}>[Enter] Accept</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (isInstalling) {
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
paddingX={1}
|
||||
paddingY={0}
|
||||
height="100%"
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
>
|
||||
<Text color={theme.text.primary}>
|
||||
Installing {extension.extensionName}...
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
paddingX={1}
|
||||
paddingY={0}
|
||||
height="100%"
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
>
|
||||
{/* Header Row */}
|
||||
<Box flexDirection="row" justifyContent="space-between" marginBottom={1}>
|
||||
<Box>
|
||||
<Text color={theme.text.secondary}>
|
||||
{'>'} Extensions {'>'}{' '}
|
||||
</Text>
|
||||
<Text color={theme.text.primary} bold>
|
||||
{extension.extensionName}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexDirection="row">
|
||||
<Text color={theme.text.secondary}>
|
||||
{extension.extensionVersion ? `v${extension.extensionVersion}` : ''}{' '}
|
||||
|{' '}
|
||||
</Text>
|
||||
<Text color={theme.status.warning}>⭐ </Text>
|
||||
<Text color={theme.text.secondary}>
|
||||
{String(extension.stars || 0)} |{' '}
|
||||
</Text>
|
||||
{extension.isGoogleOwned && (
|
||||
<Text color={theme.text.primary}>[G] </Text>
|
||||
)}
|
||||
<Text color={theme.text.primary}>{extension.fullName}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Description */}
|
||||
<Box marginBottom={1}>
|
||||
<Text color={theme.text.primary}>
|
||||
{extension.extensionDescription || extension.repoDescription}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Features List */}
|
||||
<Box flexDirection="row" marginBottom={1}>
|
||||
{[
|
||||
extension.hasMCP && { label: 'MCP', color: theme.text.primary },
|
||||
extension.hasContext && {
|
||||
label: 'Context file',
|
||||
color: theme.status.error,
|
||||
},
|
||||
extension.hasHooks && { label: 'Hooks', color: theme.status.warning },
|
||||
extension.hasSkills && {
|
||||
label: 'Skills',
|
||||
color: theme.status.success,
|
||||
},
|
||||
extension.hasCustomCommands && {
|
||||
label: 'Commands',
|
||||
color: theme.text.primary,
|
||||
},
|
||||
]
|
||||
.filter((f): f is { label: string; color: string } => !!f)
|
||||
.map((feature, index, array) => (
|
||||
<Box key={feature.label} flexDirection="row">
|
||||
<Text color={feature.color}>{feature.label} </Text>
|
||||
{index < array.length - 1 && (
|
||||
<Box marginRight={1}>
|
||||
<Text color={theme.text.secondary}>|</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{/* Details about MCP / Context */}
|
||||
{extension.hasMCP && (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text color={theme.text.primary}>
|
||||
This extension will run the following MCP servers:
|
||||
</Text>
|
||||
<Box marginLeft={2}>
|
||||
<Text color={theme.text.primary}>
|
||||
* {extension.extensionName} (local)
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{extension.hasContext && (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text color={theme.text.primary}>
|
||||
This extension will append info to your gemini.md context using
|
||||
gemini.md
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Spacer to push warning to bottom */}
|
||||
<Box flexGrow={1} />
|
||||
|
||||
{/* Warning Box */}
|
||||
{!isInstalled && (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={theme.status.warning}
|
||||
paddingX={1}
|
||||
paddingY={0}
|
||||
>
|
||||
<Text color={theme.text.primary}>
|
||||
The extension you are about to install may have been created by a
|
||||
third-party developer and sourced{'\n'}
|
||||
from a public repository. Google does not vet, endorse, or guarantee
|
||||
the functionality or security{'\n'}
|
||||
of extensions. Please carefully inspect any extension and its source
|
||||
code before installing to{'\n'}
|
||||
understand the permissions it requires and the actions it may
|
||||
perform.
|
||||
</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.primary}>[{'Enter'}] Install</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
{isInstalled && (
|
||||
<Box flexDirection="row" marginTop={1} justifyContent="center">
|
||||
<Text color={theme.status.success}>Already Installed</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -132,6 +132,9 @@ describe('ExtensionRegistryView', () => {
|
||||
|
||||
vi.mocked(useConfig).mockReturnValue({
|
||||
getEnableExtensionReloading: vi.fn().mockReturnValue(false),
|
||||
getExtensionRegistryURI: vi
|
||||
.fn()
|
||||
.mockReturnValue('https://geminicli.com/extensions.json'),
|
||||
} as unknown as ReturnType<typeof useConfig>);
|
||||
});
|
||||
|
||||
@@ -203,4 +206,34 @@ describe('ExtensionRegistryView', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should call onSelect when extension is selected and Enter is pressed in details', async () => {
|
||||
const { stdin, lastFrame } = renderView();
|
||||
|
||||
// Select the first extension in the list (Enter opens details)
|
||||
await React.act(async () => {
|
||||
stdin.write('\r');
|
||||
});
|
||||
|
||||
// Verify we are in details view
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('author/ext1');
|
||||
expect(lastFrame()).toContain('[Enter] Install');
|
||||
});
|
||||
|
||||
// Ensure onSelect hasn't been called yet
|
||||
expect(mockOnSelect).not.toHaveBeenCalled();
|
||||
|
||||
// Press Enter again in the details view to trigger install
|
||||
await React.act(async () => {
|
||||
stdin.write('\r');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnSelect).toHaveBeenCalledWith(
|
||||
mockExtensions[0],
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useMemo, useCallback } from 'react';
|
||||
import { useMemo, useCallback, useState } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import type { RegistryExtension } from '../../../config/extensionRegistryClient.js';
|
||||
|
||||
@@ -23,9 +23,13 @@ import type { ExtensionManager } from '../../../config/extension-manager.js';
|
||||
import { useRegistrySearch } from '../../hooks/useRegistrySearch.js';
|
||||
|
||||
import { useUIState } from '../../contexts/UIStateContext.js';
|
||||
import { ExtensionDetails } from './ExtensionDetails.js';
|
||||
|
||||
export interface ExtensionRegistryViewProps {
|
||||
onSelect?: (extension: RegistryExtension) => void;
|
||||
onSelect?: (
|
||||
extension: RegistryExtension,
|
||||
requestConsentOverride?: (consent: string) => Promise<boolean>,
|
||||
) => void | Promise<void>;
|
||||
onClose?: () => void;
|
||||
extensionManager: ExtensionManager;
|
||||
}
|
||||
@@ -39,9 +43,14 @@ export function ExtensionRegistryView({
|
||||
onClose,
|
||||
extensionManager,
|
||||
}: ExtensionRegistryViewProps): React.JSX.Element {
|
||||
const { extensions, loading, error, search } = useExtensionRegistry();
|
||||
const config = useConfig();
|
||||
const { extensions, loading, error, search } = useExtensionRegistry(
|
||||
'',
|
||||
config.getExtensionRegistryURI(),
|
||||
);
|
||||
const { terminalHeight, staticExtraHeight } = useUIState();
|
||||
const [selectedExtension, setSelectedExtension] =
|
||||
useState<RegistryExtension | null>(null);
|
||||
|
||||
const { extensionsUpdateState } = useExtensionUpdates(
|
||||
extensionManager,
|
||||
@@ -49,7 +58,9 @@ export function ExtensionRegistryView({
|
||||
config.getEnableExtensionReloading(),
|
||||
);
|
||||
|
||||
const installedExtensions = extensionManager.getExtensions();
|
||||
const [installedExtensions, setInstalledExtensions] = useState(() =>
|
||||
extensionManager.getExtensions(),
|
||||
);
|
||||
|
||||
const items: ExtensionItem[] = useMemo(
|
||||
() =>
|
||||
@@ -62,11 +73,28 @@ export function ExtensionRegistryView({
|
||||
[extensions],
|
||||
);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(item: ExtensionItem) => {
|
||||
onSelect?.(item.extension);
|
||||
const handleSelect = useCallback((item: ExtensionItem) => {
|
||||
setSelectedExtension(item.extension);
|
||||
}, []);
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
setSelectedExtension(null);
|
||||
}, []);
|
||||
|
||||
const handleInstall = useCallback(
|
||||
async (
|
||||
extension: RegistryExtension,
|
||||
requestConsentOverride?: (consent: string) => Promise<boolean>,
|
||||
) => {
|
||||
await onSelect?.(extension, requestConsentOverride);
|
||||
|
||||
// Refresh installed extensions list
|
||||
setInstalledExtensions(extensionManager.getExtensions());
|
||||
|
||||
// Go back to the search page (list view)
|
||||
setSelectedExtension(null);
|
||||
},
|
||||
[onSelect],
|
||||
[onSelect, extensionManager],
|
||||
);
|
||||
|
||||
const renderItem = useCallback(
|
||||
@@ -203,19 +231,41 @@ export function ExtensionRegistryView({
|
||||
}
|
||||
|
||||
return (
|
||||
<SearchableList<ExtensionItem>
|
||||
title="Extensions"
|
||||
items={items}
|
||||
onSelect={handleSelect}
|
||||
onClose={onClose || (() => {})}
|
||||
searchPlaceholder="Search extension gallery"
|
||||
renderItem={renderItem}
|
||||
header={header}
|
||||
footer={footer}
|
||||
maxItemsToShow={maxItemsToShow}
|
||||
useSearch={useRegistrySearch}
|
||||
onSearch={search}
|
||||
resetSelectionOnItemsChange={true}
|
||||
/>
|
||||
<>
|
||||
<Box
|
||||
display={selectedExtension ? 'none' : 'flex'}
|
||||
flexDirection="column"
|
||||
width="100%"
|
||||
height="100%"
|
||||
>
|
||||
<SearchableList<ExtensionItem>
|
||||
title="Extensions"
|
||||
items={items}
|
||||
onSelect={handleSelect}
|
||||
onClose={onClose || (() => {})}
|
||||
searchPlaceholder="Search extension gallery"
|
||||
renderItem={renderItem}
|
||||
header={header}
|
||||
footer={footer}
|
||||
maxItemsToShow={maxItemsToShow}
|
||||
useSearch={useRegistrySearch}
|
||||
onSearch={search}
|
||||
resetSelectionOnItemsChange={true}
|
||||
isFocused={!selectedExtension}
|
||||
/>
|
||||
</Box>
|
||||
{selectedExtension && (
|
||||
<ExtensionDetails
|
||||
extension={selectedExtension}
|
||||
onBack={handleBack}
|
||||
onInstall={async (requestConsentOverride) => {
|
||||
await handleInstall(selectedExtension, requestConsentOverride);
|
||||
}}
|
||||
isInstalled={installedExtensions.some(
|
||||
(e) => e.name === selectedExtension.extensionName,
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ export interface UIActions {
|
||||
revealCleanUiDetailsTemporarily: (durationMs?: number) => void;
|
||||
handleWarning: (message: string) => void;
|
||||
setEmbeddedShellFocused: (value: boolean) => void;
|
||||
dismissBackgroundShell: (pid: number) => void;
|
||||
dismissBackgroundShell: (pid: number) => Promise<void>;
|
||||
setActiveBackgroundShellPid: (pid: number) => void;
|
||||
setIsBackgroundShellListOpen: (isOpen: boolean) => void;
|
||||
setAuthContext: (context: { requiresRestart?: boolean }) => void;
|
||||
|
||||
@@ -830,8 +830,8 @@ describe('useShellCommandProcessor', () => {
|
||||
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.dismissBackgroundShell(1001);
|
||||
await act(async () => {
|
||||
await result.current.dismissBackgroundShell(1001);
|
||||
});
|
||||
|
||||
expect(mockShellKill).toHaveBeenCalledWith(1001);
|
||||
@@ -936,8 +936,8 @@ describe('useShellCommandProcessor', () => {
|
||||
expect(shell?.exitCode).toBe(1);
|
||||
|
||||
// Now dismiss it
|
||||
act(() => {
|
||||
result.current.dismissBackgroundShell(999);
|
||||
await act(async () => {
|
||||
await result.current.dismissBackgroundShell(999);
|
||||
});
|
||||
expect(result.current.backgroundShellCount).toBe(0);
|
||||
});
|
||||
|
||||
@@ -205,11 +205,11 @@ export const useShellCommandProcessor = (
|
||||
}, [state.activeShellPtyId, activeToolPtyId, m]);
|
||||
|
||||
const dismissBackgroundShell = useCallback(
|
||||
(pid: number) => {
|
||||
async (pid: number) => {
|
||||
const shell = state.backgroundShells.get(pid);
|
||||
if (shell) {
|
||||
if (shell.status === 'running') {
|
||||
ShellExecutionService.kill(pid);
|
||||
await ShellExecutionService.kill(pid);
|
||||
}
|
||||
dispatch({ type: 'DISMISS_SHELL', pid });
|
||||
m.backgroundedPids.delete(pid);
|
||||
|
||||
@@ -19,12 +19,16 @@ export interface UseExtensionRegistryResult {
|
||||
|
||||
export function useExtensionRegistry(
|
||||
initialQuery = '',
|
||||
registryURI?: string,
|
||||
): UseExtensionRegistryResult {
|
||||
const [extensions, setExtensions] = useState<RegistryExtension[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const client = useMemo(() => new ExtensionRegistryClient(), []);
|
||||
const client = useMemo(
|
||||
() => new ExtensionRegistryClient(registryURI),
|
||||
[registryURI],
|
||||
);
|
||||
|
||||
// Ref to track the latest query to avoid race conditions
|
||||
const latestQueryRef = useRef(initialQuery);
|
||||
|
||||
@@ -1063,10 +1063,6 @@ export const useGeminiStream = (
|
||||
'Response stopped due to prohibited image content.',
|
||||
[FinishReason.NO_IMAGE]:
|
||||
'Response stopped because no image was generated.',
|
||||
[FinishReason.IMAGE_RECITATION]:
|
||||
'Response stopped due to image recitation policy.',
|
||||
[FinishReason.IMAGE_OTHER]:
|
||||
'Response stopped due to other image-related reasons.',
|
||||
};
|
||||
|
||||
const message = finishReasonMessages[finishReason];
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
import {
|
||||
promises as fs,
|
||||
type PathLike,
|
||||
type Dirent,
|
||||
type Stats,
|
||||
} from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { cleanupBackgroundLogs } from './logCleanup.js';
|
||||
|
||||
vi.mock('@google/gemini-cli-core', () => ({
|
||||
ShellExecutionService: {
|
||||
getLogDir: vi.fn().mockReturnValue('/tmp/gemini/tmp/background-processes'),
|
||||
},
|
||||
debugLogger: {
|
||||
debug: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('node:fs', () => ({
|
||||
promises: {
|
||||
access: vi.fn(),
|
||||
readdir: vi.fn(),
|
||||
stat: vi.fn(),
|
||||
unlink: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('logCleanup', () => {
|
||||
const logDir = '/tmp/gemini/tmp/background-processes';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should skip cleanup if the directory does not exist', async () => {
|
||||
vi.mocked(fs.access).mockRejectedValue(new Error('ENOENT'));
|
||||
|
||||
await cleanupBackgroundLogs();
|
||||
|
||||
expect(fs.access).toHaveBeenCalledWith(logDir);
|
||||
expect(fs.readdir).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip cleanup if the directory is empty', async () => {
|
||||
vi.mocked(fs.access).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.readdir).mockResolvedValue([]);
|
||||
|
||||
await cleanupBackgroundLogs();
|
||||
|
||||
expect(fs.readdir).toHaveBeenCalledWith(logDir, { withFileTypes: true });
|
||||
expect(fs.unlink).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should delete log files older than 7 days', async () => {
|
||||
const now = Date.now();
|
||||
const oldTime = now - 8 * 24 * 60 * 60 * 1000; // 8 days ago
|
||||
const newTime = now - 1 * 24 * 60 * 60 * 1000; // 1 day ago
|
||||
|
||||
const entries = [
|
||||
{ name: 'old.log', isFile: () => true },
|
||||
{ name: 'new.log', isFile: () => true },
|
||||
{ name: 'not-a-log.txt', isFile: () => true },
|
||||
{ name: 'some-dir', isFile: () => false },
|
||||
] as Dirent[];
|
||||
|
||||
vi.mocked(fs.access).mockResolvedValue(undefined);
|
||||
vi.mocked(
|
||||
fs.readdir as (
|
||||
path: PathLike,
|
||||
options: { withFileTypes: true },
|
||||
) => Promise<Dirent[]>,
|
||||
).mockResolvedValue(entries);
|
||||
vi.mocked(fs.stat).mockImplementation((filePath: PathLike) => {
|
||||
const pathStr = filePath.toString();
|
||||
if (pathStr.endsWith('old.log')) {
|
||||
return Promise.resolve({ mtime: new Date(oldTime) } as Stats);
|
||||
}
|
||||
if (pathStr.endsWith('new.log')) {
|
||||
return Promise.resolve({ mtime: new Date(newTime) } as Stats);
|
||||
}
|
||||
return Promise.resolve({ mtime: new Date(now) } as Stats);
|
||||
});
|
||||
vi.mocked(fs.unlink).mockResolvedValue(undefined);
|
||||
|
||||
await cleanupBackgroundLogs();
|
||||
|
||||
expect(fs.unlink).toHaveBeenCalledTimes(1);
|
||||
expect(fs.unlink).toHaveBeenCalledWith(path.join(logDir, 'old.log'));
|
||||
expect(fs.unlink).not.toHaveBeenCalledWith(path.join(logDir, 'new.log'));
|
||||
});
|
||||
|
||||
it('should handle errors during file deletion gracefully', async () => {
|
||||
const now = Date.now();
|
||||
const oldTime = now - 8 * 24 * 60 * 60 * 1000;
|
||||
|
||||
const entries = [{ name: 'old.log', isFile: () => true }];
|
||||
|
||||
vi.mocked(fs.access).mockResolvedValue(undefined);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
vi.mocked(fs.readdir).mockResolvedValue(entries as any);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
vi.mocked(fs.stat).mockResolvedValue({ mtime: new Date(oldTime) } as any);
|
||||
vi.mocked(fs.unlink).mockRejectedValue(new Error('Permission denied'));
|
||||
|
||||
await expect(cleanupBackgroundLogs()).resolves.not.toThrow();
|
||||
expect(fs.unlink).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* @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 { ShellExecutionService, debugLogger } from '@google/gemini-cli-core';
|
||||
|
||||
const RETENTION_PERIOD_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||
|
||||
/**
|
||||
* Cleans up background process log files older than 7 days.
|
||||
* Scans ~/.gemini/tmp/background-processes/ for .log files.
|
||||
*
|
||||
* @param debugMode Whether to log detailed debug information.
|
||||
*/
|
||||
export async function cleanupBackgroundLogs(
|
||||
debugMode: boolean = false,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const logDir = ShellExecutionService.getLogDir();
|
||||
|
||||
// Check if the directory exists
|
||||
try {
|
||||
await fs.access(logDir);
|
||||
} catch {
|
||||
// Directory doesn't exist, nothing to clean up
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = await fs.readdir(logDir, { withFileTypes: true });
|
||||
const now = Date.now();
|
||||
let deletedCount = 0;
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isFile() && entry.name.endsWith('.log')) {
|
||||
const filePath = path.join(logDir, entry.name);
|
||||
try {
|
||||
const stats = await fs.stat(filePath);
|
||||
if (now - stats.mtime.getTime() > RETENTION_PERIOD_MS) {
|
||||
await fs.unlink(filePath);
|
||||
deletedCount++;
|
||||
}
|
||||
} catch (error) {
|
||||
if (debugMode) {
|
||||
debugLogger.debug(
|
||||
`Failed to process log file ${entry.name}:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (deletedCount > 0 && debugMode) {
|
||||
debugLogger.debug(`Cleaned up ${deletedCount} expired background logs.`);
|
||||
}
|
||||
} catch (error) {
|
||||
// Best-effort cleanup, don't let it crash the CLI
|
||||
if (debugMode) {
|
||||
debugLogger.warn('Background log cleanup failed:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-core",
|
||||
"version": "0.34.0-nightly.20260304.28af4e127",
|
||||
"version": "0.34.0",
|
||||
"description": "Gemini CLI Core",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
@@ -26,7 +26,7 @@
|
||||
"@google-cloud/logging": "^11.2.1",
|
||||
"@google-cloud/opentelemetry-cloud-monitoring-exporter": "^0.21.0",
|
||||
"@google-cloud/opentelemetry-cloud-trace-exporter": "^3.0.0",
|
||||
"@google/genai": "1.41.0",
|
||||
"@google/genai": "1.30.0",
|
||||
"@grpc/grpc-js": "^1.14.3",
|
||||
"@iarna/toml": "^2.2.5",
|
||||
"@joshua.litt/get-ripgrep": "^0.0.3",
|
||||
@@ -61,7 +61,7 @@
|
||||
"fdir": "^6.4.6",
|
||||
"fzf": "^0.5.2",
|
||||
"glob": "^12.0.0",
|
||||
"google-auth-library": "^10.5.0",
|
||||
"google-auth-library": "^9.11.0",
|
||||
"html-to-text": "^9.0.5",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"ignore": "^7.0.0",
|
||||
|
||||
@@ -302,7 +302,7 @@ describe('A2AClientManager', () => {
|
||||
expect(call.message.taskId).toBe(expectedTaskId);
|
||||
});
|
||||
|
||||
it('should throw prefixed error on failure', async () => {
|
||||
it('should propagate the original error on failure', async () => {
|
||||
sendMessageStreamMock.mockImplementationOnce(() => {
|
||||
throw new Error('Network error');
|
||||
});
|
||||
@@ -312,9 +312,7 @@ describe('A2AClientManager', () => {
|
||||
for await (const _ of stream) {
|
||||
// consume
|
||||
}
|
||||
}).rejects.toThrow(
|
||||
'[A2AClientManager] sendMessageStream Error [TestAgent]: Network error',
|
||||
);
|
||||
}).rejects.toThrow('Network error');
|
||||
});
|
||||
|
||||
it('should throw an error if the agent is not found', async () => {
|
||||
|
||||
@@ -26,6 +26,7 @@ import { v4 as uuidv4 } from 'uuid';
|
||||
import { Agent as UndiciAgent } from 'undici';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
import { safeLookup } from '../utils/fetch.js';
|
||||
import { classifyAgentError } from './a2a-errors.js';
|
||||
|
||||
// Remote agents can take 10+ minutes (e.g. Deep Research).
|
||||
// Use a dedicated dispatcher so the global 5-min timeout isn't affected.
|
||||
@@ -131,18 +132,22 @@ export class A2AClientManager {
|
||||
},
|
||||
);
|
||||
|
||||
const factory = new ClientFactory(options);
|
||||
const client = await factory.createFromUrl(agentCardUrl, '');
|
||||
const agentCard = await client.getAgentCard();
|
||||
try {
|
||||
const factory = new ClientFactory(options);
|
||||
const client = await factory.createFromUrl(agentCardUrl, '');
|
||||
const agentCard = await client.getAgentCard();
|
||||
|
||||
this.clients.set(name, client);
|
||||
this.agentCards.set(name, agentCard);
|
||||
this.clients.set(name, client);
|
||||
this.agentCards.set(name, agentCard);
|
||||
|
||||
debugLogger.debug(
|
||||
`[A2AClientManager] Loaded agent '${name}' from ${agentCardUrl}`,
|
||||
);
|
||||
debugLogger.debug(
|
||||
`[A2AClientManager] Loaded agent '${name}' from ${agentCardUrl}`,
|
||||
);
|
||||
|
||||
return agentCard;
|
||||
return agentCard;
|
||||
} catch (error: unknown) {
|
||||
throw classifyAgentError(name, agentCardUrl, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -183,19 +188,9 @@ export class A2AClientManager {
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
yield* client.sendMessageStream(messageParams, {
|
||||
signal: options?.signal,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const prefix = `[A2AClientManager] sendMessageStream Error [${agentName}]`;
|
||||
if (error instanceof Error) {
|
||||
throw new Error(`${prefix}: ${error.message}`, { cause: error });
|
||||
}
|
||||
throw new Error(
|
||||
`${prefix}: Unexpected error during sendMessageStream: ${String(error)}`,
|
||||
);
|
||||
}
|
||||
yield* client.sendMessageStream(messageParams, {
|
||||
signal: options?.signal,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
A2AAgentError,
|
||||
AgentCardNotFoundError,
|
||||
AgentCardAuthError,
|
||||
AgentAuthConfigMissingError,
|
||||
AgentConnectionError,
|
||||
classifyAgentError,
|
||||
} from './a2a-errors.js';
|
||||
|
||||
describe('A2A Error Types', () => {
|
||||
describe('A2AAgentError', () => {
|
||||
it('should set name, agentName, and userMessage', () => {
|
||||
const error = new A2AAgentError('my-agent', 'internal msg', 'user msg');
|
||||
expect(error.name).toBe('A2AAgentError');
|
||||
expect(error.agentName).toBe('my-agent');
|
||||
expect(error.message).toBe('internal msg');
|
||||
expect(error.userMessage).toBe('user msg');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AgentCardNotFoundError', () => {
|
||||
it('should produce a user-friendly 404 message', () => {
|
||||
const error = new AgentCardNotFoundError(
|
||||
'my-agent',
|
||||
'https://example.com/card',
|
||||
);
|
||||
expect(error.name).toBe('AgentCardNotFoundError');
|
||||
expect(error.agentName).toBe('my-agent');
|
||||
expect(error.userMessage).toContain('404');
|
||||
expect(error.userMessage).toContain('https://example.com/card');
|
||||
expect(error.userMessage).toContain('agent_card_url');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AgentCardAuthError', () => {
|
||||
it('should produce a user-friendly 401 message', () => {
|
||||
const error = new AgentCardAuthError(
|
||||
'secure-agent',
|
||||
'https://example.com/card',
|
||||
401,
|
||||
);
|
||||
expect(error.name).toBe('AgentCardAuthError');
|
||||
expect(error.statusCode).toBe(401);
|
||||
expect(error.userMessage).toContain('401');
|
||||
expect(error.userMessage).toContain('Unauthorized');
|
||||
expect(error.userMessage).toContain('"auth" configuration');
|
||||
});
|
||||
|
||||
it('should produce a user-friendly 403 message', () => {
|
||||
const error = new AgentCardAuthError(
|
||||
'secure-agent',
|
||||
'https://example.com/card',
|
||||
403,
|
||||
);
|
||||
expect(error.statusCode).toBe(403);
|
||||
expect(error.userMessage).toContain('403');
|
||||
expect(error.userMessage).toContain('Forbidden');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AgentAuthConfigMissingError', () => {
|
||||
it('should list missing config fields', () => {
|
||||
const error = new AgentAuthConfigMissingError(
|
||||
'api-agent',
|
||||
'API Key (x-api-key): Send x-api-key in header',
|
||||
[
|
||||
'Authentication is required but not configured',
|
||||
"Scheme 'api_key' requires apiKey authentication",
|
||||
],
|
||||
);
|
||||
expect(error.name).toBe('AgentAuthConfigMissingError');
|
||||
expect(error.requiredAuth).toContain('API Key');
|
||||
expect(error.missingFields).toHaveLength(2);
|
||||
expect(error.userMessage).toContain('API Key');
|
||||
expect(error.userMessage).toContain('no auth is configured');
|
||||
expect(error.userMessage).toContain('Missing:');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AgentConnectionError', () => {
|
||||
it('should wrap the original error cause', () => {
|
||||
const cause = new Error('ECONNREFUSED');
|
||||
const error = new AgentConnectionError(
|
||||
'my-agent',
|
||||
'https://example.com/card',
|
||||
cause,
|
||||
);
|
||||
expect(error.name).toBe('AgentConnectionError');
|
||||
expect(error.userMessage).toContain('ECONNREFUSED');
|
||||
expect(error.userMessage).toContain('https://example.com/card');
|
||||
});
|
||||
|
||||
it('should handle non-Error causes', () => {
|
||||
const error = new AgentConnectionError(
|
||||
'my-agent',
|
||||
'https://example.com/card',
|
||||
'raw string error',
|
||||
);
|
||||
expect(error.userMessage).toContain('raw string error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyAgentError', () => {
|
||||
it('should classify a 404 error message', () => {
|
||||
const raw = new Error('HTTP 404: Not Found');
|
||||
const result = classifyAgentError(
|
||||
'agent-a',
|
||||
'https://example.com/card',
|
||||
raw,
|
||||
);
|
||||
expect(result).toBeInstanceOf(AgentCardNotFoundError);
|
||||
expect(result.agentName).toBe('agent-a');
|
||||
});
|
||||
|
||||
it('should classify a "not found" error message (case-insensitive)', () => {
|
||||
const raw = new Error('Agent card not found at the given URL');
|
||||
const result = classifyAgentError(
|
||||
'agent-a',
|
||||
'https://example.com/card',
|
||||
raw,
|
||||
);
|
||||
expect(result).toBeInstanceOf(AgentCardNotFoundError);
|
||||
});
|
||||
|
||||
it('should classify a 401 error message', () => {
|
||||
const raw = new Error('Request failed with status 401');
|
||||
const result = classifyAgentError(
|
||||
'agent-b',
|
||||
'https://example.com/card',
|
||||
raw,
|
||||
);
|
||||
expect(result).toBeInstanceOf(AgentCardAuthError);
|
||||
expect((result as AgentCardAuthError).statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('should classify an "unauthorized" error message', () => {
|
||||
const raw = new Error('Unauthorized access to agent card');
|
||||
const result = classifyAgentError(
|
||||
'agent-b',
|
||||
'https://example.com/card',
|
||||
raw,
|
||||
);
|
||||
expect(result).toBeInstanceOf(AgentCardAuthError);
|
||||
});
|
||||
|
||||
it('should classify a 403 error message', () => {
|
||||
const raw = new Error('HTTP 403 Forbidden');
|
||||
const result = classifyAgentError(
|
||||
'agent-c',
|
||||
'https://example.com/card',
|
||||
raw,
|
||||
);
|
||||
expect(result).toBeInstanceOf(AgentCardAuthError);
|
||||
expect((result as AgentCardAuthError).statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it('should fall back to AgentConnectionError for unknown errors', () => {
|
||||
const raw = new Error('Something completely unexpected');
|
||||
const result = classifyAgentError(
|
||||
'agent-d',
|
||||
'https://example.com/card',
|
||||
raw,
|
||||
);
|
||||
expect(result).toBeInstanceOf(AgentConnectionError);
|
||||
});
|
||||
|
||||
it('should classify ECONNREFUSED as AgentConnectionError', () => {
|
||||
const raw = new Error('ECONNREFUSED 127.0.0.1:8080');
|
||||
const result = classifyAgentError(
|
||||
'agent-d',
|
||||
'https://example.com/card',
|
||||
raw,
|
||||
);
|
||||
expect(result).toBeInstanceOf(AgentConnectionError);
|
||||
});
|
||||
|
||||
it('should handle non-Error values', () => {
|
||||
const result = classifyAgentError(
|
||||
'agent-e',
|
||||
'https://example.com/card',
|
||||
'some string error',
|
||||
);
|
||||
expect(result).toBeInstanceOf(AgentConnectionError);
|
||||
});
|
||||
|
||||
describe('cause chain inspection', () => {
|
||||
it('should detect 404 in a nested cause', () => {
|
||||
const inner = new Error('HTTP 404 Not Found');
|
||||
const outer = new Error('fetch failed', { cause: inner });
|
||||
const result = classifyAgentError(
|
||||
'agent-nested',
|
||||
'https://example.com/card',
|
||||
outer,
|
||||
);
|
||||
expect(result).toBeInstanceOf(AgentCardNotFoundError);
|
||||
});
|
||||
|
||||
it('should detect 401 in a deeply nested cause', () => {
|
||||
const innermost = new Error('Server returned 401');
|
||||
const middle = new Error('Request error', { cause: innermost });
|
||||
const outer = new Error('fetch failed', { cause: middle });
|
||||
const result = classifyAgentError(
|
||||
'agent-deep',
|
||||
'https://example.com/card',
|
||||
outer,
|
||||
);
|
||||
expect(result).toBeInstanceOf(AgentCardAuthError);
|
||||
expect((result as AgentCardAuthError).statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('should detect ECONNREFUSED error code in cause chain', () => {
|
||||
const inner = Object.assign(new Error('connect failed'), {
|
||||
code: 'ECONNREFUSED',
|
||||
});
|
||||
const outer = new Error('fetch failed', { cause: inner });
|
||||
const result = classifyAgentError(
|
||||
'agent-conn',
|
||||
'https://example.com/card',
|
||||
outer,
|
||||
);
|
||||
expect(result).toBeInstanceOf(AgentConnectionError);
|
||||
});
|
||||
|
||||
it('should detect status property on error objects in cause chain', () => {
|
||||
const inner = Object.assign(new Error('Bad response'), {
|
||||
status: 403,
|
||||
});
|
||||
const outer = new Error('agent card resolution failed', {
|
||||
cause: inner,
|
||||
});
|
||||
const result = classifyAgentError(
|
||||
'agent-status',
|
||||
'https://example.com/card',
|
||||
outer,
|
||||
);
|
||||
expect(result).toBeInstanceOf(AgentCardAuthError);
|
||||
expect((result as AgentCardAuthError).statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it('should detect status on a plain-object cause (non-Error)', () => {
|
||||
const outer = new Error('fetch failed');
|
||||
// Some HTTP libs set cause to a plain object, not an Error instance
|
||||
(outer as unknown as { cause: unknown }).cause = {
|
||||
message: 'Unauthorized',
|
||||
status: 401,
|
||||
};
|
||||
const result = classifyAgentError(
|
||||
'agent-plain-cause',
|
||||
'https://example.com/card',
|
||||
outer,
|
||||
);
|
||||
expect(result).toBeInstanceOf(AgentCardAuthError);
|
||||
expect((result as AgentCardAuthError).statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('should detect statusCode on a plain-object cause (non-Error)', () => {
|
||||
const outer = new Error('fetch failed');
|
||||
(outer as unknown as { cause: unknown }).cause = {
|
||||
message: 'Forbidden',
|
||||
statusCode: 403,
|
||||
};
|
||||
const result = classifyAgentError(
|
||||
'agent-plain-cause-403',
|
||||
'https://example.com/card',
|
||||
outer,
|
||||
);
|
||||
expect(result).toBeInstanceOf(AgentCardAuthError);
|
||||
expect((result as AgentCardAuthError).statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it('should classify ENOTFOUND as AgentConnectionError, not 404', () => {
|
||||
// ENOTFOUND (DNS resolution failure) should NOT be misclassified
|
||||
// as a 404 despite containing "NOTFOUND" in the error code.
|
||||
const inner = Object.assign(
|
||||
new Error('getaddrinfo ENOTFOUND example.invalid'),
|
||||
{
|
||||
code: 'ENOTFOUND',
|
||||
},
|
||||
);
|
||||
const outer = new Error('fetch failed', { cause: inner });
|
||||
const result = classifyAgentError(
|
||||
'agent-dns',
|
||||
'https://example.invalid/card',
|
||||
outer,
|
||||
);
|
||||
expect(result).toBeInstanceOf(AgentConnectionError);
|
||||
expect(result).not.toBeInstanceOf(AgentCardNotFoundError);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* @fileoverview Custom error types for A2A remote agent operations.
|
||||
* Provides structured, user-friendly error messages for common failure modes
|
||||
* during agent card fetching, authentication, and communication.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Base class for all A2A agent errors.
|
||||
* Provides a `userMessage` field with a human-readable description.
|
||||
*/
|
||||
export class A2AAgentError extends Error {
|
||||
/** A user-friendly message suitable for display in the CLI. */
|
||||
readonly userMessage: string;
|
||||
/** The agent name associated with this error. */
|
||||
readonly agentName: string;
|
||||
|
||||
constructor(
|
||||
agentName: string,
|
||||
message: string,
|
||||
userMessage: string,
|
||||
options?: ErrorOptions,
|
||||
) {
|
||||
super(message, options);
|
||||
this.name = 'A2AAgentError';
|
||||
this.agentName = agentName;
|
||||
this.userMessage = userMessage;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown when the agent card URL returns a 404 Not Found response.
|
||||
*/
|
||||
export class AgentCardNotFoundError extends A2AAgentError {
|
||||
constructor(agentName: string, agentCardUrl: string) {
|
||||
const message = `Agent card not found at ${agentCardUrl} (HTTP 404)`;
|
||||
const userMessage = `Agent card not found (404) at ${agentCardUrl}. Verify the agent_card_url in your agent definition.`;
|
||||
super(agentName, message, userMessage);
|
||||
this.name = 'AgentCardNotFoundError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown when the agent card URL returns a 401/403 response,
|
||||
* indicating an authentication or authorization failure.
|
||||
*/
|
||||
export class AgentCardAuthError extends A2AAgentError {
|
||||
readonly statusCode: number;
|
||||
|
||||
constructor(agentName: string, agentCardUrl: string, statusCode: 401 | 403) {
|
||||
const statusText = statusCode === 401 ? 'Unauthorized' : 'Forbidden';
|
||||
const message = `Agent card request returned ${statusCode} ${statusText} for ${agentCardUrl}`;
|
||||
const userMessage = `Authentication failed (${statusCode} ${statusText}) at ${agentCardUrl}. Check the "auth" configuration in your agent definition.`;
|
||||
super(agentName, message, userMessage);
|
||||
this.name = 'AgentCardAuthError';
|
||||
this.statusCode = statusCode;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown when the agent card's security schemes require authentication
|
||||
* but the agent definition does not include the necessary auth configuration.
|
||||
*/
|
||||
export class AgentAuthConfigMissingError extends A2AAgentError {
|
||||
/** Human-readable description of required authentication schemes. */
|
||||
readonly requiredAuth: string;
|
||||
/** Specific fields or config entries that are missing. */
|
||||
readonly missingFields: string[];
|
||||
|
||||
constructor(
|
||||
agentName: string,
|
||||
requiredAuth: string,
|
||||
missingFields: string[],
|
||||
) {
|
||||
const message = `Agent "${agentName}" requires authentication but none is configured`;
|
||||
const userMessage = `Agent requires ${requiredAuth} but no auth is configured. Missing: ${missingFields.join(', ')}`;
|
||||
super(agentName, message, userMessage);
|
||||
this.name = 'AgentAuthConfigMissingError';
|
||||
this.requiredAuth = requiredAuth;
|
||||
this.missingFields = missingFields;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown when a generic/unexpected network or server error occurs
|
||||
* while fetching the agent card or communicating with the remote agent.
|
||||
*/
|
||||
export class AgentConnectionError extends A2AAgentError {
|
||||
constructor(agentName: string, agentCardUrl: string, cause: unknown) {
|
||||
const causeMessage = cause instanceof Error ? cause.message : String(cause);
|
||||
const message = `Failed to connect to agent "${agentName}" at ${agentCardUrl}: ${causeMessage}`;
|
||||
const userMessage = `Connection failed for ${agentCardUrl}: ${causeMessage}`;
|
||||
super(agentName, message, userMessage, { cause });
|
||||
this.name = 'AgentConnectionError';
|
||||
}
|
||||
}
|
||||
|
||||
/** Shape of an error-like object in a cause chain (Error, HTTP response, or plain object). */
|
||||
interface ErrorLikeObject {
|
||||
message?: string;
|
||||
code?: string;
|
||||
status?: number;
|
||||
statusCode?: number;
|
||||
cause?: unknown;
|
||||
}
|
||||
|
||||
/** Type guard for objects that may carry error metadata (message, code, status, cause). */
|
||||
function isErrorLikeObject(val: unknown): val is ErrorLikeObject {
|
||||
return typeof val === 'object' && val !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects all error messages from an error's cause chain into a single string
|
||||
* for pattern matching. This is necessary because the A2A SDK and Node's fetch
|
||||
* often wrap the real error (e.g. HTTP status) deep inside nested causes.
|
||||
*/
|
||||
function collectErrorMessages(error: unknown): string {
|
||||
const parts: string[] = [];
|
||||
let current: unknown = error;
|
||||
let depth = 0;
|
||||
const maxDepth = 10;
|
||||
|
||||
while (current && depth < maxDepth) {
|
||||
if (isErrorLikeObject(current)) {
|
||||
// Save reference before instanceof narrows the type from ErrorLikeObject to Error.
|
||||
const obj = current;
|
||||
|
||||
if (current instanceof Error) {
|
||||
parts.push(current.message);
|
||||
} else if (typeof obj.message === 'string') {
|
||||
parts.push(obj.message);
|
||||
}
|
||||
|
||||
if (typeof obj.code === 'string') {
|
||||
parts.push(obj.code);
|
||||
}
|
||||
|
||||
if (typeof obj.status === 'number') {
|
||||
parts.push(String(obj.status));
|
||||
} else if (typeof obj.statusCode === 'number') {
|
||||
parts.push(String(obj.statusCode));
|
||||
}
|
||||
|
||||
current = obj.cause;
|
||||
} else if (typeof current === 'string') {
|
||||
parts.push(current);
|
||||
break;
|
||||
} else {
|
||||
parts.push(String(current));
|
||||
break;
|
||||
}
|
||||
depth++;
|
||||
}
|
||||
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to classify a raw error from the A2A SDK into a typed A2AAgentError.
|
||||
*
|
||||
* Inspects the error message and full cause chain for HTTP status codes and
|
||||
* well-known patterns to produce a structured, user-friendly error.
|
||||
*
|
||||
* @param agentName The name of the agent being loaded.
|
||||
* @param agentCardUrl The URL of the agent card.
|
||||
* @param error The raw error caught during agent loading.
|
||||
* @returns A classified A2AAgentError subclass.
|
||||
*/
|
||||
export function classifyAgentError(
|
||||
agentName: string,
|
||||
agentCardUrl: string,
|
||||
error: unknown,
|
||||
): A2AAgentError {
|
||||
// Collect messages from the entire cause chain for thorough matching.
|
||||
const fullErrorText = collectErrorMessages(error);
|
||||
|
||||
// Check for well-known connection error codes in the cause chain.
|
||||
// NOTE: This is checked before the 404 pattern as a defensive measure
|
||||
// to prevent DNS errors (ENOTFOUND) from being misclassified as 404s.
|
||||
if (
|
||||
/\b(ECONNREFUSED|ENOTFOUND|EHOSTUNREACH|ETIMEDOUT)\b/i.test(fullErrorText)
|
||||
) {
|
||||
return new AgentConnectionError(agentName, agentCardUrl, error);
|
||||
}
|
||||
|
||||
// Check for HTTP status code patterns across the full cause chain.
|
||||
if (/\b404\b|\bnot[\s_-]?found\b/i.test(fullErrorText)) {
|
||||
return new AgentCardNotFoundError(agentName, agentCardUrl);
|
||||
}
|
||||
|
||||
if (/\b401\b|unauthorized/i.test(fullErrorText)) {
|
||||
return new AgentCardAuthError(agentName, agentCardUrl, 401);
|
||||
}
|
||||
|
||||
if (/\b403\b|forbidden/i.test(fullErrorText)) {
|
||||
return new AgentCardAuthError(agentName, agentCardUrl, 403);
|
||||
}
|
||||
|
||||
// Fallback to a generic connection error.
|
||||
return new AgentConnectionError(agentName, agentCardUrl, error);
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* @fileoverview Automation overlay utilities for visual indication during browser automation.
|
||||
*
|
||||
* Provides functions to inject and remove a pulsating blue border overlay
|
||||
* that indicates when the browser is under AI agent control.
|
||||
*
|
||||
* Uses the Web Animations API instead of injected <style> tags so the
|
||||
* animation works on sites with strict Content Security Policies (e.g. google.com).
|
||||
*
|
||||
* The script strings are passed to chrome-devtools-mcp's evaluate_script tool
|
||||
* which expects a plain function expression (NOT an IIFE).
|
||||
*/
|
||||
|
||||
import type { BrowserManager } from './browserManager.js';
|
||||
import { debugLogger } from '../../utils/debugLogger.js';
|
||||
|
||||
const OVERLAY_ELEMENT_ID = '__gemini_automation_overlay';
|
||||
|
||||
/**
|
||||
* Builds the JavaScript function string that injects the automation overlay.
|
||||
*
|
||||
* Returns a plain arrow-function expression (no trailing invocation) because
|
||||
* chrome-devtools-mcp's evaluate_script tool invokes it internally.
|
||||
*
|
||||
* Avoids nested template literals by using string concatenation for cssText.
|
||||
*/
|
||||
function buildInjectionScript(): string {
|
||||
return `() => {
|
||||
const id = '${OVERLAY_ELEMENT_ID}';
|
||||
const existing = document.getElementById(id);
|
||||
if (existing) existing.remove();
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = id;
|
||||
overlay.setAttribute('aria-hidden', 'true');
|
||||
overlay.setAttribute('role', 'presentation');
|
||||
|
||||
Object.assign(overlay.style, {
|
||||
position: 'fixed',
|
||||
top: '0',
|
||||
left: '0',
|
||||
right: '0',
|
||||
bottom: '0',
|
||||
zIndex: '2147483647',
|
||||
pointerEvents: 'none',
|
||||
border: '6px solid rgba(66, 133, 244, 1.0)',
|
||||
});
|
||||
|
||||
document.documentElement.appendChild(overlay);
|
||||
|
||||
try {
|
||||
overlay.animate([
|
||||
{ borderColor: 'rgba(66,133,244,0.3)', boxShadow: 'inset 0 0 8px rgba(66,133,244,0.15)' },
|
||||
{ borderColor: 'rgba(66,133,244,1.0)', boxShadow: 'inset 0 0 16px rgba(66,133,244,0.5)' },
|
||||
{ borderColor: 'rgba(66,133,244,0.3)', boxShadow: 'inset 0 0 8px rgba(66,133,244,0.15)' }
|
||||
], { duration: 2000, iterations: Infinity, easing: 'ease-in-out' });
|
||||
} catch (e) {
|
||||
// Silently ignore animation errors, as they can happen on sites with strict CSP.
|
||||
// The border itself is the most important visual indicator.
|
||||
}
|
||||
|
||||
return 'overlay-injected';
|
||||
}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the JavaScript function string that removes the automation overlay.
|
||||
*/
|
||||
function buildRemovalScript(): string {
|
||||
return `() => {
|
||||
const el = document.getElementById('${OVERLAY_ELEMENT_ID}');
|
||||
if (el) el.remove();
|
||||
return 'overlay-removed';
|
||||
}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Injects the automation overlay into the current page.
|
||||
*/
|
||||
export async function injectAutomationOverlay(
|
||||
browserManager: BrowserManager,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
try {
|
||||
debugLogger.log('Injecting automation overlay...');
|
||||
|
||||
const result = await browserManager.callTool(
|
||||
'evaluate_script',
|
||||
{ function: buildInjectionScript() },
|
||||
signal,
|
||||
);
|
||||
|
||||
if (result.isError) {
|
||||
debugLogger.warn('Failed to inject automation overlay:', result);
|
||||
} else {
|
||||
debugLogger.log('Automation overlay injected successfully');
|
||||
}
|
||||
} catch (error) {
|
||||
debugLogger.warn('Error injecting automation overlay:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the automation overlay from the current page.
|
||||
*/
|
||||
export async function removeAutomationOverlay(
|
||||
browserManager: BrowserManager,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
try {
|
||||
debugLogger.log('Removing automation overlay...');
|
||||
|
||||
const result = await browserManager.callTool(
|
||||
'evaluate_script',
|
||||
{ function: buildRemovalScript() },
|
||||
signal,
|
||||
);
|
||||
|
||||
if (result.isError) {
|
||||
debugLogger.warn('Failed to remove automation overlay:', result);
|
||||
} else {
|
||||
debugLogger.log('Automation overlay removed successfully');
|
||||
}
|
||||
} catch (error) {
|
||||
debugLogger.warn('Error removing automation overlay:', error);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
createBrowserAgentDefinition,
|
||||
cleanupBrowserAgent,
|
||||
} from './browserAgentFactory.js';
|
||||
import { injectAutomationOverlay } from './automationOverlay.js';
|
||||
import { makeFakeConfig } from '../../test-utils/config.js';
|
||||
import type { Config } from '../../config/config.js';
|
||||
import type { MessageBus } from '../../confirmation-bus/message-bus.js';
|
||||
@@ -35,6 +36,10 @@ vi.mock('./browserManager.js', () => ({
|
||||
BrowserManager: vi.fn(() => mockBrowserManager),
|
||||
}));
|
||||
|
||||
vi.mock('./automationOverlay.js', () => ({
|
||||
injectAutomationOverlay: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock('../../utils/debugLogger.js', () => ({
|
||||
debugLogger: {
|
||||
log: vi.fn(),
|
||||
@@ -55,6 +60,8 @@ describe('browserAgentFactory', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
vi.mocked(injectAutomationOverlay).mockClear();
|
||||
|
||||
// Reset mock implementations
|
||||
mockBrowserManager.ensureConnection.mockResolvedValue(undefined);
|
||||
mockBrowserManager.getDiscoveredTools.mockResolvedValue([
|
||||
@@ -99,6 +106,28 @@ describe('browserAgentFactory', () => {
|
||||
expect(mockBrowserManager.ensureConnection).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should inject automation overlay when not in headless mode', async () => {
|
||||
await createBrowserAgentDefinition(mockConfig, mockMessageBus);
|
||||
expect(injectAutomationOverlay).toHaveBeenCalledWith(mockBrowserManager);
|
||||
});
|
||||
|
||||
it('should not inject automation overlay when in headless mode', async () => {
|
||||
const headlessConfig = makeFakeConfig({
|
||||
agents: {
|
||||
overrides: {
|
||||
browser_agent: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
browser: {
|
||||
headless: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
await createBrowserAgentDefinition(headlessConfig, mockMessageBus);
|
||||
expect(injectAutomationOverlay).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return agent definition with discovered tools', async () => {
|
||||
const { definition } = await createBrowserAgentDefinition(
|
||||
mockConfig,
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
} from './browserAgentDefinition.js';
|
||||
import { createMcpDeclarativeTools } from './mcpToolWrapper.js';
|
||||
import { createAnalyzeScreenshotTool } from './analyzeScreenshot.js';
|
||||
import { injectAutomationOverlay } from './automationOverlay.js';
|
||||
import { debugLogger } from '../../utils/debugLogger.js';
|
||||
|
||||
/**
|
||||
@@ -61,6 +62,15 @@ export async function createBrowserAgentDefinition(
|
||||
printOutput('Browser connected with isolated MCP client.');
|
||||
}
|
||||
|
||||
// Inject automation overlay if not in headless mode
|
||||
const browserConfig = config.getBrowserAgentConfig();
|
||||
if (!browserConfig?.customConfig?.headless) {
|
||||
if (printOutput) {
|
||||
printOutput('Injecting automation overlay...');
|
||||
}
|
||||
await injectAutomationOverlay(browserManager);
|
||||
}
|
||||
|
||||
// Create declarative tools from dynamically discovered MCP tools
|
||||
// These tools dispatch to browserManager's isolated client
|
||||
const mcpTools = await createMcpDeclarativeTools(browserManager, messageBus);
|
||||
|
||||
@@ -8,6 +8,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { BrowserManager } from './browserManager.js';
|
||||
import { makeFakeConfig } from '../../test-utils/config.js';
|
||||
import type { Config } from '../../config/config.js';
|
||||
import { injectAutomationOverlay } from './automationOverlay.js';
|
||||
|
||||
// Mock the MCP SDK
|
||||
vi.mock('@modelcontextprotocol/sdk/client/index.js', () => ({
|
||||
@@ -42,6 +43,10 @@ vi.mock('../../utils/debugLogger.js', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('./automationOverlay.js', () => ({
|
||||
injectAutomationOverlay: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||
|
||||
@@ -50,6 +55,7 @@ describe('BrowserManager', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(injectAutomationOverlay).mockClear();
|
||||
|
||||
// Setup mock config
|
||||
mockConfig = makeFakeConfig({
|
||||
@@ -411,4 +417,81 @@ describe('BrowserManager', () => {
|
||||
expect(client.close).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('overlay re-injection in callTool', () => {
|
||||
it('should re-inject overlay after click in non-headless mode', async () => {
|
||||
const manager = new BrowserManager(mockConfig);
|
||||
await manager.callTool('click', { uid: '1_2' });
|
||||
|
||||
expect(injectAutomationOverlay).toHaveBeenCalledWith(manager, undefined);
|
||||
});
|
||||
|
||||
it('should re-inject overlay after navigate_page in non-headless mode', async () => {
|
||||
const manager = new BrowserManager(mockConfig);
|
||||
await manager.callTool('navigate_page', { url: 'https://example.com' });
|
||||
|
||||
expect(injectAutomationOverlay).toHaveBeenCalledWith(manager, undefined);
|
||||
});
|
||||
|
||||
it('should re-inject overlay after click_at, new_page, press_key, handle_dialog', async () => {
|
||||
const manager = new BrowserManager(mockConfig);
|
||||
for (const tool of [
|
||||
'click_at',
|
||||
'new_page',
|
||||
'press_key',
|
||||
'handle_dialog',
|
||||
]) {
|
||||
vi.mocked(injectAutomationOverlay).mockClear();
|
||||
await manager.callTool(tool, {});
|
||||
expect(injectAutomationOverlay).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('should NOT re-inject overlay after read-only tools', async () => {
|
||||
const manager = new BrowserManager(mockConfig);
|
||||
for (const tool of [
|
||||
'take_snapshot',
|
||||
'take_screenshot',
|
||||
'get_console_message',
|
||||
'fill',
|
||||
]) {
|
||||
vi.mocked(injectAutomationOverlay).mockClear();
|
||||
await manager.callTool(tool, {});
|
||||
expect(injectAutomationOverlay).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it('should NOT re-inject overlay when headless is true', async () => {
|
||||
const headlessConfig = makeFakeConfig({
|
||||
agents: {
|
||||
overrides: { browser_agent: { enabled: true } },
|
||||
browser: { headless: true },
|
||||
},
|
||||
});
|
||||
const manager = new BrowserManager(headlessConfig);
|
||||
await manager.callTool('click', { uid: '1_2' });
|
||||
|
||||
expect(injectAutomationOverlay).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should NOT re-inject overlay when tool returns an error result', async () => {
|
||||
vi.mocked(Client).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
connect: vi.fn().mockResolvedValue(undefined),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
listTools: vi.fn().mockResolvedValue({ tools: [] }),
|
||||
callTool: vi.fn().mockResolvedValue({
|
||||
content: [{ type: 'text', text: 'Element not found' }],
|
||||
isError: true,
|
||||
}),
|
||||
}) as unknown as InstanceType<typeof Client>,
|
||||
);
|
||||
|
||||
const manager = new BrowserManager(mockConfig);
|
||||
await manager.callTool('click', { uid: 'bad' });
|
||||
|
||||
expect(injectAutomationOverlay).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,6 +24,7 @@ import { debugLogger } from '../../utils/debugLogger.js';
|
||||
import type { Config } from '../../config/config.js';
|
||||
import { Storage } from '../../config/storage.js';
|
||||
import * as path from 'node:path';
|
||||
import { injectAutomationOverlay } from './automationOverlay.js';
|
||||
|
||||
// Pin chrome-devtools-mcp version for reproducibility.
|
||||
const CHROME_DEVTOOLS_MCP_VERSION = '0.17.1';
|
||||
@@ -34,6 +35,27 @@ const BROWSER_PROFILE_DIR = 'cli-browser-profile';
|
||||
// Default timeout for MCP operations
|
||||
const MCP_TIMEOUT_MS = 60_000;
|
||||
|
||||
/**
|
||||
* Tools that can cause a full-page navigation (explicitly or implicitly).
|
||||
*
|
||||
* When any of these completes successfully, the current page DOM is replaced
|
||||
* and the injected automation overlay is lost. BrowserManager re-injects the
|
||||
* overlay after every successful call to one of these tools.
|
||||
*
|
||||
* Note: chrome-devtools-mcp is a pure request/response server and emits no
|
||||
* MCP notifications, so listening for page-load events via the protocol is
|
||||
* not possible. Intercepting at callTool() is the equivalent mechanism.
|
||||
*/
|
||||
const POTENTIALLY_NAVIGATING_TOOLS = new Set([
|
||||
'click', // clicking a link navigates
|
||||
'click_at', // coordinate click can also follow a link
|
||||
'navigate_page',
|
||||
'new_page',
|
||||
'select_page', // switching pages can lose the overlay
|
||||
'press_key', // Enter on a focused link/form triggers navigation
|
||||
'handle_dialog', // confirming beforeunload can trigger navigation
|
||||
]);
|
||||
|
||||
/**
|
||||
* Content item from an MCP tool call response.
|
||||
* Can be text or image (for take_screenshot).
|
||||
@@ -70,7 +92,16 @@ export class BrowserManager {
|
||||
private mcpTransport: StdioClientTransport | undefined;
|
||||
private discoveredTools: McpTool[] = [];
|
||||
|
||||
constructor(private config: Config) {}
|
||||
/**
|
||||
* Whether to inject the automation overlay.
|
||||
* Always false in headless mode (no visible window to decorate).
|
||||
*/
|
||||
private readonly shouldInjectOverlay: boolean;
|
||||
|
||||
constructor(private config: Config) {
|
||||
const browserConfig = config.getBrowserAgentConfig();
|
||||
this.shouldInjectOverlay = !browserConfig?.customConfig?.headless;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the raw MCP SDK Client for direct tool calls.
|
||||
@@ -120,28 +151,49 @@ export class BrowserManager {
|
||||
{ timeout: MCP_TIMEOUT_MS },
|
||||
);
|
||||
|
||||
let result: McpToolCallResult;
|
||||
|
||||
// If no signal, just await directly
|
||||
if (!signal) {
|
||||
return this.toResult(await callPromise);
|
||||
}
|
||||
|
||||
// Race the call against the abort signal
|
||||
let onAbort: (() => void) | undefined;
|
||||
try {
|
||||
const result = await Promise.race([
|
||||
callPromise,
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
onAbort = () =>
|
||||
reject(signal.reason ?? new Error('Operation cancelled'));
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
}),
|
||||
]);
|
||||
return this.toResult(result);
|
||||
} finally {
|
||||
if (onAbort) {
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
result = this.toResult(await callPromise);
|
||||
} else {
|
||||
// Race the call against the abort signal
|
||||
let onAbort: (() => void) | undefined;
|
||||
try {
|
||||
const raw = await Promise.race([
|
||||
callPromise,
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
onAbort = () =>
|
||||
reject(signal.reason ?? new Error('Operation cancelled'));
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
}),
|
||||
]);
|
||||
result = this.toResult(raw);
|
||||
} finally {
|
||||
if (onAbort) {
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-inject the automation overlay after any tool that can cause a
|
||||
// full-page navigation (including implicit navigations from clicking links).
|
||||
// chrome-devtools-mcp emits no MCP notifications, so callTool() is the
|
||||
// only interception point we have — equivalent to a page-load listener.
|
||||
if (
|
||||
this.shouldInjectOverlay &&
|
||||
!result.isError &&
|
||||
POTENTIALLY_NAVIGATING_TOOLS.has(toolName) &&
|
||||
!signal?.aborted
|
||||
) {
|
||||
try {
|
||||
await injectAutomationOverlay(this, signal);
|
||||
} catch {
|
||||
// Never let overlay failures interrupt the tool result
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -39,8 +39,8 @@ class McpToolInvocation extends BaseToolInvocation<
|
||||
ToolResult
|
||||
> {
|
||||
constructor(
|
||||
private readonly browserManager: BrowserManager,
|
||||
private readonly toolName: string,
|
||||
protected readonly browserManager: BrowserManager,
|
||||
protected readonly toolName: string,
|
||||
params: Record<string, unknown>,
|
||||
messageBus: MessageBus,
|
||||
) {
|
||||
@@ -280,7 +280,7 @@ class McpDeclarativeTool extends DeclarativeTool<
|
||||
ToolResult
|
||||
> {
|
||||
constructor(
|
||||
private readonly browserManager: BrowserManager,
|
||||
protected readonly browserManager: BrowserManager,
|
||||
name: string,
|
||||
description: string,
|
||||
parameterSchema: unknown,
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
type ToolCallConfirmationDetails,
|
||||
type PolicyUpdateOptions,
|
||||
} from '../../tools/tools.js';
|
||||
import { makeFakeConfig } from '../../test-utils/config.js';
|
||||
|
||||
interface TestableConfirmation {
|
||||
getConfirmationDetails(
|
||||
@@ -29,6 +30,7 @@ describe('mcpToolWrapper Confirmation', () => {
|
||||
let mockMessageBus: MessageBus;
|
||||
|
||||
beforeEach(() => {
|
||||
makeFakeConfig(); // ensure config module is loaded
|
||||
mockBrowserManager = {
|
||||
getDiscoveredTools: vi
|
||||
.fn()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -48,6 +48,8 @@ vi.mock('./a2a-client-manager.js', () => ({
|
||||
vi.mock('./auth-provider/factory.js', () => ({
|
||||
A2AAuthProviderFactory: {
|
||||
create: vi.fn(),
|
||||
validateAuthConfig: vi.fn().mockReturnValue({ valid: true }),
|
||||
describeRequiredAuth: vi.fn().mockReturnValue('API key required'),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -665,6 +667,111 @@ describe('AgentRegistry', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should emit error feedback with userMessage when A2AAgentError is thrown', async () => {
|
||||
const { AgentConnectionError } = await import('./a2a-errors.js');
|
||||
const feedbackSpy = vi
|
||||
.spyOn(coreEvents, 'emitFeedback')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
const remoteAgent: AgentDefinition = {
|
||||
kind: 'remote',
|
||||
name: 'FailAgent',
|
||||
description: 'An agent that fails to load',
|
||||
agentCardUrl: 'https://unreachable.example.com/card',
|
||||
inputConfig: { inputSchema: { type: 'object' } },
|
||||
};
|
||||
|
||||
const a2aError = new AgentConnectionError(
|
||||
'FailAgent',
|
||||
'https://unreachable.example.com/card',
|
||||
new Error('ECONNREFUSED'),
|
||||
);
|
||||
|
||||
vi.mocked(A2AClientManager.getInstance).mockReturnValue({
|
||||
loadAgent: vi.fn().mockRejectedValue(a2aError),
|
||||
} as unknown as A2AClientManager);
|
||||
|
||||
await registry.testRegisterAgent(remoteAgent);
|
||||
|
||||
expect(feedbackSpy).toHaveBeenCalledWith(
|
||||
'error',
|
||||
`[FailAgent] ${a2aError.userMessage}`,
|
||||
);
|
||||
expect(registry.getDefinition('FailAgent')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should emit generic error feedback for non-A2AAgentError failures', async () => {
|
||||
const feedbackSpy = vi
|
||||
.spyOn(coreEvents, 'emitFeedback')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
const remoteAgent: AgentDefinition = {
|
||||
kind: 'remote',
|
||||
name: 'FailAgent',
|
||||
description: 'An agent that fails',
|
||||
agentCardUrl: 'https://example.com/card',
|
||||
inputConfig: { inputSchema: { type: 'object' } },
|
||||
};
|
||||
|
||||
vi.mocked(A2AClientManager.getInstance).mockReturnValue({
|
||||
loadAgent: vi.fn().mockRejectedValue(new Error('unexpected crash')),
|
||||
} as unknown as A2AClientManager);
|
||||
|
||||
await registry.testRegisterAgent(remoteAgent);
|
||||
|
||||
expect(feedbackSpy).toHaveBeenCalledWith(
|
||||
'error',
|
||||
'[FailAgent] Failed to load remote agent: unexpected crash',
|
||||
);
|
||||
expect(registry.getDefinition('FailAgent')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should emit warning feedback when auth config is missing for secured agent', async () => {
|
||||
const feedbackSpy = vi
|
||||
.spyOn(coreEvents, 'emitFeedback')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
vi.mocked(A2AAuthProviderFactory.validateAuthConfig).mockReturnValue({
|
||||
valid: false,
|
||||
diff: { requiredSchemes: ['api_key'], missingConfig: ['api_key'] },
|
||||
});
|
||||
vi.mocked(A2AAuthProviderFactory.describeRequiredAuth).mockReturnValue(
|
||||
'apiKey (header: x-api-key)',
|
||||
);
|
||||
|
||||
const remoteAgent: AgentDefinition = {
|
||||
kind: 'remote',
|
||||
name: 'SecuredAgent',
|
||||
description: 'A secured remote agent',
|
||||
agentCardUrl: 'https://example.com/card',
|
||||
inputConfig: { inputSchema: { type: 'object' } },
|
||||
// No auth configured
|
||||
};
|
||||
|
||||
vi.mocked(A2AClientManager.getInstance).mockReturnValue({
|
||||
loadAgent: vi.fn().mockResolvedValue({
|
||||
name: 'SecuredAgent',
|
||||
securitySchemes: {
|
||||
api_key: {
|
||||
type: 'apiKey',
|
||||
in: 'header',
|
||||
name: 'x-api-key',
|
||||
},
|
||||
},
|
||||
}),
|
||||
} as unknown as A2AClientManager);
|
||||
|
||||
await registry.testRegisterAgent(remoteAgent);
|
||||
|
||||
// Agent should still be registered (ADC fallback)
|
||||
expect(registry.getDefinition('SecuredAgent')).toBeDefined();
|
||||
// But a warning should have been emitted
|
||||
expect(feedbackSpy).toHaveBeenCalledWith(
|
||||
'warning',
|
||||
expect.stringContaining('SecuredAgent'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should surface an error if remote agent registration fails', async () => {
|
||||
const remoteAgent: AgentDefinition = {
|
||||
kind: 'remote',
|
||||
@@ -685,7 +792,7 @@ describe('AgentRegistry', () => {
|
||||
|
||||
expect(feedbackSpy).toHaveBeenCalledWith(
|
||||
'error',
|
||||
`Error loading A2A agent "FailingRemoteAgent": 401 Unauthorized`,
|
||||
`[FailingRemoteAgent] Failed to load remote agent: 401 Unauthorized`,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
ModelConfigService,
|
||||
} from '../services/modelConfigService.js';
|
||||
import { PolicyDecision, PRIORITY_SUBAGENT_TOOL } from '../policy/types.js';
|
||||
import { A2AAgentError, AgentAuthConfigMissingError } from './a2a-errors.js';
|
||||
|
||||
/**
|
||||
* Returns the model config alias for a given agent definition.
|
||||
@@ -366,6 +367,9 @@ export class AgentRegistry {
|
||||
|
||||
/**
|
||||
* Registers a remote agent definition asynchronously.
|
||||
* Provides robust error handling with user-friendly messages for:
|
||||
* - Agent card fetch failures (404, 401/403, network errors)
|
||||
* - Missing authentication configuration
|
||||
*/
|
||||
protected async registerRemoteAgent<TOutput extends z.ZodTypeAny>(
|
||||
definition: AgentDefinition<TOutput>,
|
||||
@@ -408,7 +412,7 @@ export class AgentRegistry {
|
||||
remoteDef.originalDescription = remoteDef.description;
|
||||
}
|
||||
|
||||
// Log remote A2A agent registration for visibility.
|
||||
// Load the remote A2A agent card and register.
|
||||
try {
|
||||
const clientManager = A2AClientManager.getInstance();
|
||||
let authHandler: AuthenticationHandler | undefined;
|
||||
@@ -432,6 +436,30 @@ export class AgentRegistry {
|
||||
authHandler,
|
||||
);
|
||||
|
||||
// Validate auth configuration against the agent card's security schemes.
|
||||
if (agentCard.securitySchemes) {
|
||||
const validation = A2AAuthProviderFactory.validateAuthConfig(
|
||||
definition.auth,
|
||||
agentCard.securitySchemes,
|
||||
);
|
||||
if (!validation.valid && validation.diff) {
|
||||
const requiredAuth = A2AAuthProviderFactory.describeRequiredAuth(
|
||||
agentCard.securitySchemes,
|
||||
);
|
||||
const authError = new AgentAuthConfigMissingError(
|
||||
definition.name,
|
||||
requiredAuth,
|
||||
validation.diff.missingConfig,
|
||||
);
|
||||
coreEvents.emitFeedback(
|
||||
'warning',
|
||||
`[${definition.name}] Agent requires authentication: ${requiredAuth}`,
|
||||
);
|
||||
debugLogger.warn(`[AgentRegistry] ${authError.message}`);
|
||||
// Still register the agent — the user can fix config and retry.
|
||||
}
|
||||
}
|
||||
|
||||
const userDescription = remoteDef.originalDescription;
|
||||
const agentDescription = agentCard.description;
|
||||
const descriptions: string[] = [];
|
||||
@@ -464,9 +492,22 @@ export class AgentRegistry {
|
||||
this.agents.set(definition.name, definition);
|
||||
this.addAgentPolicy(definition);
|
||||
} catch (e) {
|
||||
const errorMessage = `Error loading A2A agent "${definition.name}": ${e instanceof Error ? e.message : String(e)}`;
|
||||
debugLogger.warn(`[AgentRegistry] ${errorMessage}`, e);
|
||||
coreEvents.emitFeedback('error', errorMessage);
|
||||
// Surface structured, user-friendly error messages for known failure modes.
|
||||
if (e instanceof A2AAgentError) {
|
||||
coreEvents.emitFeedback(
|
||||
'error',
|
||||
`[${definition.name}] ${e.userMessage}`,
|
||||
);
|
||||
} else {
|
||||
coreEvents.emitFeedback(
|
||||
'error',
|
||||
`[${definition.name}] Failed to load remote agent: ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
}
|
||||
debugLogger.warn(
|
||||
`[AgentRegistry] Error loading A2A agent "${definition.name}":`,
|
||||
e,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -613,4 +613,75 @@ describe('RemoteAgentInvocation', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should use A2AAgentError.userMessage for structured errors', async () => {
|
||||
const { AgentConnectionError } = await import('./a2a-errors.js');
|
||||
const a2aError = new AgentConnectionError(
|
||||
'test-agent',
|
||||
'http://test-agent/card',
|
||||
new Error('ECONNREFUSED'),
|
||||
);
|
||||
|
||||
mockClientManager.getClient.mockReturnValue(undefined);
|
||||
mockClientManager.loadAgent.mockRejectedValue(a2aError);
|
||||
|
||||
const invocation = new RemoteAgentInvocation(
|
||||
mockDefinition,
|
||||
{ query: 'hi' },
|
||||
mockMessageBus,
|
||||
);
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
|
||||
expect(result.error).toBeDefined();
|
||||
expect(result.returnDisplay).toContain(a2aError.userMessage);
|
||||
});
|
||||
|
||||
it('should use generic message for non-A2AAgentError errors', async () => {
|
||||
mockClientManager.getClient.mockReturnValue(undefined);
|
||||
mockClientManager.loadAgent.mockRejectedValue(
|
||||
new Error('something unexpected'),
|
||||
);
|
||||
|
||||
const invocation = new RemoteAgentInvocation(
|
||||
mockDefinition,
|
||||
{ query: 'hi' },
|
||||
mockMessageBus,
|
||||
);
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
|
||||
expect(result.error).toBeDefined();
|
||||
expect(result.returnDisplay).toContain(
|
||||
'Error calling remote agent: something unexpected',
|
||||
);
|
||||
});
|
||||
|
||||
it('should include partial output when error occurs mid-stream', async () => {
|
||||
mockClientManager.getClient.mockReturnValue({});
|
||||
mockClientManager.sendMessageStream.mockImplementation(
|
||||
async function* () {
|
||||
yield {
|
||||
kind: 'message',
|
||||
messageId: 'msg-1',
|
||||
role: 'agent',
|
||||
parts: [{ kind: 'text', text: 'Partial response' }],
|
||||
};
|
||||
// Raw errors propagate from the A2A SDK — no wrapping or classification.
|
||||
throw new Error('connection reset');
|
||||
},
|
||||
);
|
||||
|
||||
const invocation = new RemoteAgentInvocation(
|
||||
mockDefinition,
|
||||
{ query: 'hi' },
|
||||
mockMessageBus,
|
||||
);
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
|
||||
expect(result.error).toBeDefined();
|
||||
// Should contain both the partial output and the error message
|
||||
expect(result.returnDisplay).toContain('Partial response');
|
||||
expect(result.returnDisplay).toContain('connection reset');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,6 +28,7 @@ import { debugLogger } from '../utils/debugLogger.js';
|
||||
import { safeJsonToMarkdown } from '../utils/markdownUtils.js';
|
||||
import type { AnsiOutput } from '../utils/terminalSerializer.js';
|
||||
import { A2AAuthProviderFactory } from './auth-provider/factory.js';
|
||||
import { A2AAgentError } from './a2a-errors.js';
|
||||
|
||||
/**
|
||||
* Authentication handler implementation using Google Application Default Credentials (ADC).
|
||||
@@ -228,7 +229,8 @@ export class RemoteAgentInvocation extends BaseToolInvocation<
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
const partialOutput = reassembler.toString();
|
||||
const errorMessage = `Error calling remote agent: ${error instanceof Error ? error.message : String(error)}`;
|
||||
// Surface structured, user-friendly error messages.
|
||||
const errorMessage = this.formatExecutionError(error);
|
||||
const fullDisplay = partialOutput
|
||||
? `${partialOutput}\n\n${errorMessage}`
|
||||
: errorMessage;
|
||||
@@ -245,4 +247,22 @@ export class RemoteAgentInvocation extends BaseToolInvocation<
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats an execution error into a user-friendly message.
|
||||
* Recognizes typed A2AAgentError subclasses and falls back to
|
||||
* a generic message for unknown errors.
|
||||
*/
|
||||
private formatExecutionError(error: unknown): string {
|
||||
// All A2A-specific errors include a human-friendly `userMessage` on the
|
||||
// A2AAgentError base class. Rely on that to avoid duplicating messages
|
||||
// for specific subclasses, which improves maintainability.
|
||||
if (error instanceof A2AAgentError) {
|
||||
return error.userMessage;
|
||||
}
|
||||
|
||||
return `Error calling remote agent: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -550,6 +550,7 @@ export interface ConfigParameters {
|
||||
skipNextSpeakerCheck?: boolean;
|
||||
shellExecutionConfig?: ShellExecutionConfig;
|
||||
extensionManagement?: boolean;
|
||||
extensionRegistryURI?: string;
|
||||
truncateToolOutputThreshold?: number;
|
||||
eventEmitter?: EventEmitter;
|
||||
useWriteTodos?: boolean;
|
||||
@@ -738,6 +739,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
private readonly useAlternateBuffer: boolean;
|
||||
private shellExecutionConfig: ShellExecutionConfig;
|
||||
private readonly extensionManagement: boolean = true;
|
||||
private readonly extensionRegistryURI: string | undefined;
|
||||
private readonly truncateToolOutputThreshold: number;
|
||||
private compressionTruncationCounter = 0;
|
||||
private initialized = false;
|
||||
@@ -969,6 +971,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
this.shellToolInactivityTimeout =
|
||||
(params.shellToolInactivityTimeout ?? 300) * 1000; // 5 minutes
|
||||
this.extensionManagement = params.extensionManagement ?? true;
|
||||
this.extensionRegistryURI = params.extensionRegistryURI;
|
||||
this.enableExtensionReloading = params.enableExtensionReloading ?? false;
|
||||
this.storage = new Storage(this.targetDir, this._sessionId);
|
||||
this.storage.setCustomPlansDir(params.planSettings?.directory);
|
||||
@@ -1303,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> {
|
||||
@@ -1840,6 +1847,10 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
return this.extensionsEnabled;
|
||||
}
|
||||
|
||||
getExtensionRegistryURI(): string | undefined {
|
||||
return this.extensionRegistryURI;
|
||||
}
|
||||
|
||||
getMcpClientManager(): McpClientManager | undefined {
|
||||
return this.mcpClientManager;
|
||||
}
|
||||
@@ -2527,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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -851,7 +851,7 @@ Use the following guidelines to optimize your search and read patterns.
|
||||
- **Explaining Changes:** After completing a code modification or file operation *do not* provide summaries unless asked.
|
||||
- **Do Not revert changes:** Do not revert changes to the codebase unless asked to do so by the user. Only revert changes made by you if they have resulted in an error or if the user has explicitly asked you to revert the changes.
|
||||
- **Explain Before Acting:** Never call tools in silence. You MUST provide a concise, one-sentence explanation of your intent or strategy immediately before executing tool calls. This is essential for transparency, especially when confirming a request or answering a question. Silence is only acceptable for repetitive, low-level discovery operations (e.g., sequential file reads) where narration would be noisy.
|
||||
- **Non-Interactive Environment:** You are running in a headless/CI environment and cannot interact with the user. Do not ask the user questions or request additional information, as the session will terminate. Use your best judgment to complete the task. If a tool fails because it requires interactive authentication or user input, do not retry it indefinitely; instead, explain the limitation and suggest how the user can provide the required data (e.g., via environment variables).
|
||||
- **Non-Interactive Environment:** You are running in a headless/CI environment and cannot interact with the user. Do not ask the user questions or request additional information, as the session will terminate. Use your best judgment to complete the task. If a tool fails because it requires user interaction, do not retry it indefinitely; instead, explain the limitation and suggest how the user can provide the required data (e.g., via environment variables).
|
||||
|
||||
# Hook Context
|
||||
|
||||
@@ -973,7 +973,7 @@ Use the following guidelines to optimize your search and read patterns.
|
||||
- **Explaining Changes:** After completing a code modification or file operation *do not* provide summaries unless asked.
|
||||
- **Do Not revert changes:** Do not revert changes to the codebase unless asked to do so by the user. Only revert changes made by you if they have resulted in an error or if the user has explicitly asked you to revert the changes.
|
||||
- **Explain Before Acting:** Never call tools in silence. You MUST provide a concise, one-sentence explanation of your intent or strategy immediately before executing tool calls. This is essential for transparency, especially when confirming a request or answering a question. Silence is only acceptable for repetitive, low-level discovery operations (e.g., sequential file reads) where narration would be noisy.
|
||||
- **Non-Interactive Environment:** You are running in a headless/CI environment and cannot interact with the user. Do not ask the user questions or request additional information, as the session will terminate. Use your best judgment to complete the task. If a tool fails because it requires interactive authentication or user input, do not retry it indefinitely; instead, explain the limitation and suggest how the user can provide the required data (e.g., via environment variables).
|
||||
- **Non-Interactive Environment:** You are running in a headless/CI environment and cannot interact with the user. Do not ask the user questions or request additional information, as the session will terminate. Use your best judgment to complete the task. If a tool fails because it requires user interaction, do not retry it indefinitely; instead, explain the limitation and suggest how the user can provide the required data (e.g., via environment variables).
|
||||
|
||||
# Hook Context
|
||||
|
||||
|
||||
@@ -219,5 +219,8 @@ export * from './agents/types.js';
|
||||
export * from './utils/stdio.js';
|
||||
export * from './utils/terminal.js';
|
||||
|
||||
// Export voice utilities
|
||||
export * from './voice/responseFormatter.js';
|
||||
|
||||
// Export types from @google/genai
|
||||
export type { Content, Part, FunctionCall } from '@google/genai';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -573,7 +573,7 @@ function mandateConflictResolution(hasHierarchicalMemory: boolean): string {
|
||||
function mandateContinueWork(interactive: boolean): string {
|
||||
if (interactive) return '';
|
||||
return `
|
||||
- **Non-Interactive Environment:** You are running in a headless/CI environment and cannot interact with the user. Do not ask the user questions or request additional information, as the session will terminate. Use your best judgment to complete the task. If a tool fails because it requires user iteraction, do not retry it indefinitely; instead, explain the limitation and suggest how the user can provide the required data (e.g., via environment variables).`;
|
||||
- **Non-Interactive Environment:** You are running in a headless/CI environment and cannot interact with the user. Do not ask the user questions or request additional information, as the session will terminate. Use your best judgment to complete the task. If a tool fails because it requires user interaction, do not retry it indefinitely; instead, explain the limitation and suggest how the user can provide the required data (e.g., via environment variables).`;
|
||||
}
|
||||
|
||||
function workflowStepResearch(options: PrimaryWorkflowsOptions): string {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
afterEach,
|
||||
type Mock,
|
||||
} from 'vitest';
|
||||
|
||||
import EventEmitter from 'node:events';
|
||||
import type { Readable } from 'node:stream';
|
||||
import { type ChildProcess } from 'node:child_process';
|
||||
@@ -28,14 +29,44 @@ const mockPtySpawn = vi.hoisted(() => vi.fn());
|
||||
const mockCpSpawn = vi.hoisted(() => vi.fn());
|
||||
const mockIsBinary = vi.hoisted(() => vi.fn());
|
||||
const mockPlatform = vi.hoisted(() => vi.fn());
|
||||
const mockHomedir = vi.hoisted(() => vi.fn());
|
||||
const mockMkdirSync = vi.hoisted(() => vi.fn());
|
||||
const mockCreateWriteStream = vi.hoisted(() => vi.fn());
|
||||
const mockGetPty = vi.hoisted(() => vi.fn());
|
||||
const mockSerializeTerminalToObject = vi.hoisted(() => vi.fn());
|
||||
const mockResolveExecutable = vi.hoisted(() => vi.fn());
|
||||
const mockDebugLogger = vi.hoisted(() => ({
|
||||
log: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
}));
|
||||
|
||||
// Top-level Mocks
|
||||
vi.mock('../config/storage.js', () => ({
|
||||
Storage: {
|
||||
getGlobalTempDir: vi.fn().mockReturnValue('/mock/temp'),
|
||||
},
|
||||
}));
|
||||
vi.mock('../utils/debugLogger.js', () => ({
|
||||
debugLogger: mockDebugLogger,
|
||||
}));
|
||||
vi.mock('@lydell/node-pty', () => ({
|
||||
spawn: mockPtySpawn,
|
||||
}));
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs')>();
|
||||
return {
|
||||
...actual,
|
||||
default: {
|
||||
...actual,
|
||||
mkdirSync: mockMkdirSync,
|
||||
createWriteStream: mockCreateWriteStream,
|
||||
},
|
||||
mkdirSync: mockMkdirSync,
|
||||
createWriteStream: mockCreateWriteStream,
|
||||
};
|
||||
});
|
||||
vi.mock('../utils/shell-utils.js', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('../utils/shell-utils.js')>();
|
||||
@@ -57,6 +88,7 @@ vi.mock('../utils/textUtils.js', () => ({
|
||||
vi.mock('node:os', () => ({
|
||||
default: {
|
||||
platform: mockPlatform,
|
||||
homedir: mockHomedir,
|
||||
constants: {
|
||||
signals: {
|
||||
SIGTERM: 15,
|
||||
@@ -65,6 +97,7 @@ vi.mock('node:os', () => ({
|
||||
},
|
||||
},
|
||||
platform: mockPlatform,
|
||||
homedir: mockHomedir,
|
||||
constants: {
|
||||
signals: {
|
||||
SIGTERM: 15,
|
||||
@@ -159,6 +192,8 @@ describe('ShellExecutionService', () => {
|
||||
buffer: {
|
||||
active: {
|
||||
viewportY: number;
|
||||
length: number;
|
||||
getLine: Mock;
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -201,6 +236,8 @@ describe('ShellExecutionService', () => {
|
||||
buffer: {
|
||||
active: {
|
||||
viewportY: 0,
|
||||
length: 0,
|
||||
getLine: vi.fn(),
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -432,13 +469,20 @@ describe('ShellExecutionService', () => {
|
||||
});
|
||||
|
||||
describe('pty interaction', () => {
|
||||
let ptySpy: { mockRestore(): void };
|
||||
beforeEach(() => {
|
||||
vi.spyOn(ShellExecutionService['activePtys'], 'get').mockReturnValue({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
ptyProcess: mockPtyProcess as any,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
headlessTerminal: mockHeadlessTerminal as any,
|
||||
});
|
||||
ptySpy = vi
|
||||
.spyOn(ShellExecutionService['activePtys'], 'get')
|
||||
.mockReturnValue({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
ptyProcess: mockPtyProcess as any,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
headlessTerminal: mockHeadlessTerminal as any,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
ptySpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should write to the pty and trigger a render', async () => {
|
||||
@@ -667,6 +711,163 @@ describe('ShellExecutionService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Backgrounding', () => {
|
||||
let mockWriteStream: { write: Mock; end: Mock; on: Mock };
|
||||
let mockBgChildProcess: EventEmitter & Partial<ChildProcess>;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockWriteStream = {
|
||||
write: vi.fn(),
|
||||
end: vi.fn().mockImplementation((cb) => cb?.()),
|
||||
on: vi.fn(),
|
||||
};
|
||||
|
||||
mockMkdirSync.mockReturnValue(undefined);
|
||||
mockCreateWriteStream.mockReturnValue(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mockWriteStream as any,
|
||||
);
|
||||
mockHomedir.mockReturnValue('/mock/home');
|
||||
|
||||
mockBgChildProcess = new EventEmitter() as EventEmitter &
|
||||
Partial<ChildProcess>;
|
||||
mockBgChildProcess.stdout = new EventEmitter() as Readable;
|
||||
mockBgChildProcess.stderr = new EventEmitter() as Readable;
|
||||
mockBgChildProcess.kill = vi.fn();
|
||||
Object.defineProperty(mockBgChildProcess, 'pid', {
|
||||
value: 99999,
|
||||
configurable: true,
|
||||
});
|
||||
mockCpSpawn.mockReturnValue(mockBgChildProcess);
|
||||
|
||||
// Explicitly clear state between runs
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(ShellExecutionService as any).backgroundLogStreams.clear();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(ShellExecutionService as any).activePtys.clear();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(ShellExecutionService as any).activeChildProcesses.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(ShellExecutionService as any).backgroundLogStreams.clear();
|
||||
});
|
||||
|
||||
it('should move a running pty process to the background and start logging', async () => {
|
||||
const abortController = new AbortController();
|
||||
const handle = await ShellExecutionService.execute(
|
||||
'long-running-pty',
|
||||
'/',
|
||||
onOutputEventMock,
|
||||
abortController.signal,
|
||||
true,
|
||||
shellExecutionConfig,
|
||||
);
|
||||
|
||||
// Use the registered onData listener
|
||||
const onDataListener = mockPtyProcess.onData.mock.calls[0][0];
|
||||
onDataListener('initial pty output');
|
||||
|
||||
// Wait for async write to headless terminal
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
mockSerializeTerminalToObject.mockReturnValue([
|
||||
[{ text: 'initial pty output', fg: '', bg: '' }],
|
||||
]);
|
||||
|
||||
// Background the process
|
||||
ShellExecutionService.background(handle.pid!);
|
||||
|
||||
const result = await handle.result;
|
||||
expect(result.backgrounded).toBe(true);
|
||||
expect(result.output).toContain('initial pty output');
|
||||
|
||||
expect(mockMkdirSync).toHaveBeenCalledWith(
|
||||
expect.stringContaining('background-processes'),
|
||||
{ recursive: true },
|
||||
);
|
||||
|
||||
// Verify initial output was written
|
||||
expect(
|
||||
mockWriteStream.write.mock.calls.some((call) =>
|
||||
call[0].includes('initial pty output'),
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
await ShellExecutionService.kill(handle.pid!);
|
||||
expect(mockWriteStream.end).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should continue logging after backgrounding for child_process', async () => {
|
||||
mockGetPty.mockResolvedValue(null); // Force child_process fallback
|
||||
|
||||
const abortController = new AbortController();
|
||||
const handle = await ShellExecutionService.execute(
|
||||
'long-running-cp',
|
||||
'/',
|
||||
onOutputEventMock,
|
||||
abortController.signal,
|
||||
true,
|
||||
shellExecutionConfig,
|
||||
);
|
||||
|
||||
// Trigger data before backgrounding
|
||||
mockBgChildProcess.stdout?.emit('data', Buffer.from('initial cp output'));
|
||||
await new Promise((resolve) => process.nextTick(resolve));
|
||||
|
||||
ShellExecutionService.background(handle.pid!);
|
||||
|
||||
const result = await handle.result;
|
||||
expect(result.backgrounded).toBe(true);
|
||||
expect(result.output).toBe('initial cp output');
|
||||
|
||||
expect(
|
||||
mockWriteStream.write.mock.calls.some((call) =>
|
||||
call[0].includes('initial cp output'),
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
// Subsequent output
|
||||
mockBgChildProcess.stdout?.emit('data', Buffer.from('more cp output'));
|
||||
await new Promise((resolve) => process.nextTick(resolve));
|
||||
expect(mockWriteStream.write).toHaveBeenCalledWith('more cp output');
|
||||
|
||||
await ShellExecutionService.kill(handle.pid!);
|
||||
expect(mockWriteStream.end).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should log a warning if background log setup fails', async () => {
|
||||
const abortController = new AbortController();
|
||||
const handle = await ShellExecutionService.execute(
|
||||
'failing-log-setup',
|
||||
'/',
|
||||
onOutputEventMock,
|
||||
abortController.signal,
|
||||
true,
|
||||
shellExecutionConfig,
|
||||
);
|
||||
|
||||
// Mock mkdirSync to fail
|
||||
const error = new Error('Permission denied');
|
||||
mockMkdirSync.mockImplementationOnce(() => {
|
||||
throw error;
|
||||
});
|
||||
|
||||
// Background the process
|
||||
ShellExecutionService.background(handle.pid!);
|
||||
|
||||
const result = await handle.result;
|
||||
expect(result.backgrounded).toBe(true);
|
||||
expect(mockDebugLogger.warn).toHaveBeenCalledWith(
|
||||
'Failed to setup background logging:',
|
||||
error,
|
||||
);
|
||||
|
||||
await ShellExecutionService.kill(handle.pid!);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Binary Output', () => {
|
||||
it('should detect binary output and switch to progress events', async () => {
|
||||
mockIsBinary.mockReturnValueOnce(true);
|
||||
@@ -894,7 +1095,7 @@ describe('ShellExecutionService', () => {
|
||||
'destroy',
|
||||
);
|
||||
|
||||
ShellExecutionService.kill(pid);
|
||||
await ShellExecutionService.kill(pid);
|
||||
|
||||
expect(storedDestroySpy).toHaveBeenCalled();
|
||||
expect(ShellExecutionService['activePtys'].has(pid)).toBe(false);
|
||||
@@ -974,7 +1175,10 @@ describe('ShellExecutionService child_process fallback', () => {
|
||||
// Helper function to run a standard execution simulation
|
||||
const simulateExecution = async (
|
||||
command: string,
|
||||
simulation: (cp: typeof mockChildProcess, ac: AbortController) => void,
|
||||
simulation: (
|
||||
cp: typeof mockChildProcess,
|
||||
ac: AbortController,
|
||||
) => void | Promise<void>,
|
||||
) => {
|
||||
const abortController = new AbortController();
|
||||
const handle = await ShellExecutionService.execute(
|
||||
@@ -987,7 +1191,7 @@ describe('ShellExecutionService child_process fallback', () => {
|
||||
);
|
||||
|
||||
await new Promise((resolve) => process.nextTick(resolve));
|
||||
simulation(mockChildProcess, abortController);
|
||||
await simulation(mockChildProcess, abortController);
|
||||
const result = await handle.result;
|
||||
return { result, handle, abortController };
|
||||
};
|
||||
@@ -1315,9 +1519,9 @@ describe('ShellExecutionService child_process fallback', () => {
|
||||
describe('Platform-Specific Behavior', () => {
|
||||
it('should use powershell.exe on Windows', async () => {
|
||||
mockPlatform.mockReturnValue('win32');
|
||||
await simulateExecution('dir "foo bar"', (cp) =>
|
||||
cp.emit('exit', 0, null),
|
||||
);
|
||||
await simulateExecution('dir "foo bar"', (cp) => {
|
||||
cp.emit('exit', 0, null);
|
||||
});
|
||||
|
||||
expect(mockCpSpawn).toHaveBeenCalledWith(
|
||||
'powershell.exe',
|
||||
@@ -1332,7 +1536,9 @@ describe('ShellExecutionService child_process fallback', () => {
|
||||
|
||||
it('should use bash and detached process group on Linux', async () => {
|
||||
mockPlatform.mockReturnValue('linux');
|
||||
await simulateExecution('ls "foo bar"', (cp) => cp.emit('exit', 0, null));
|
||||
await simulateExecution('ls "foo bar"', (cp) => {
|
||||
cp.emit('exit', 0, null);
|
||||
});
|
||||
|
||||
expect(mockCpSpawn).toHaveBeenCalledWith(
|
||||
'bash',
|
||||
|
||||
@@ -9,6 +9,8 @@ import { getPty, type PtyImplementation } from '../utils/getPty.js';
|
||||
import { spawn as cpSpawn, type ChildProcess } from 'node:child_process';
|
||||
import { TextDecoder } from 'node:util';
|
||||
import os from 'node:os';
|
||||
import fs, { mkdirSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type { IPty } from '@lydell/node-pty';
|
||||
import { getCachedEncodingForBuffer } from '../utils/systemEncoding.js';
|
||||
import {
|
||||
@@ -18,6 +20,8 @@ import {
|
||||
} from '../utils/shell-utils.js';
|
||||
import { isBinary } from '../utils/textUtils.js';
|
||||
import pkg from '@xterm/headless';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
import { Storage } from '../config/storage.js';
|
||||
import {
|
||||
serializeTerminalToObject,
|
||||
type AnsiOutput,
|
||||
@@ -152,20 +156,37 @@ interface ActiveChildProcess {
|
||||
};
|
||||
}
|
||||
|
||||
const getFullBufferText = (terminal: pkg.Terminal): string => {
|
||||
const findLastContentLine = (
|
||||
buffer: pkg.IBuffer,
|
||||
startLine: number,
|
||||
): number => {
|
||||
const lineCount = buffer.length;
|
||||
for (let i = lineCount - 1; i >= startLine; i--) {
|
||||
const line = buffer.getLine(i);
|
||||
if (line && line.translateToString(true).length > 0) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
|
||||
const getFullBufferText = (terminal: pkg.Terminal, startLine = 0): string => {
|
||||
const buffer = terminal.buffer.active;
|
||||
const lines: string[] = [];
|
||||
for (let i = 0; i < buffer.length; i++) {
|
||||
|
||||
const lastContentLine = findLastContentLine(buffer, startLine);
|
||||
|
||||
if (lastContentLine === -1 || lastContentLine < startLine) return '';
|
||||
|
||||
for (let i = startLine; i <= lastContentLine; i++) {
|
||||
const line = buffer.getLine(i);
|
||||
if (!line) {
|
||||
lines.push('');
|
||||
continue;
|
||||
}
|
||||
// If the NEXT line is wrapped, it means it's a continuation of THIS line.
|
||||
// We should not trim the right side of this line because trailing spaces
|
||||
// might be significant parts of the wrapped content.
|
||||
// If it's not wrapped, we trim normally.
|
||||
|
||||
let trimRight = true;
|
||||
if (i + 1 < buffer.length) {
|
||||
if (i + 1 <= lastContentLine) {
|
||||
const nextLine = buffer.getLine(i + 1);
|
||||
if (nextLine?.isWrapped) {
|
||||
trimRight = false;
|
||||
@@ -181,12 +202,56 @@ const getFullBufferText = (terminal: pkg.Terminal): string => {
|
||||
}
|
||||
}
|
||||
|
||||
// Remove trailing empty lines
|
||||
while (lines.length > 0 && lines[lines.length - 1] === '') {
|
||||
lines.pop();
|
||||
return lines.join('\n');
|
||||
};
|
||||
|
||||
const writeBufferToLogStream = (
|
||||
terminal: pkg.Terminal,
|
||||
stream: fs.WriteStream,
|
||||
startLine = 0,
|
||||
): number => {
|
||||
const buffer = terminal.buffer.active;
|
||||
const lastContentLine = findLastContentLine(buffer, startLine);
|
||||
|
||||
if (lastContentLine === -1 || lastContentLine < startLine) return startLine;
|
||||
|
||||
for (let i = startLine; i <= lastContentLine; i++) {
|
||||
const line = buffer.getLine(i);
|
||||
if (!line) {
|
||||
stream.write('\n');
|
||||
continue;
|
||||
}
|
||||
|
||||
let trimRight = true;
|
||||
if (i + 1 <= lastContentLine) {
|
||||
const nextLine = buffer.getLine(i + 1);
|
||||
if (nextLine?.isWrapped) {
|
||||
trimRight = false;
|
||||
}
|
||||
}
|
||||
|
||||
const lineContent = line.translateToString(trimRight);
|
||||
const stripped = stripAnsi(lineContent);
|
||||
|
||||
if (line.isWrapped) {
|
||||
stream.write(stripped);
|
||||
} else {
|
||||
if (i > startLine) {
|
||||
stream.write('\n');
|
||||
}
|
||||
stream.write(stripped);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
// Ensure it ends with a newline if we wrote anything and the next line is not wrapped
|
||||
if (lastContentLine >= startLine) {
|
||||
const nextLine = terminal.buffer.active.getLine(lastContentLine + 1);
|
||||
if (!nextLine?.isWrapped) {
|
||||
stream.write('\n');
|
||||
}
|
||||
}
|
||||
|
||||
return lastContentLine + 1;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -198,10 +263,43 @@ const getFullBufferText = (terminal: pkg.Terminal): string => {
|
||||
export class ShellExecutionService {
|
||||
private static activePtys = new Map<number, ActivePty>();
|
||||
private static activeChildProcesses = new Map<number, ActiveChildProcess>();
|
||||
private static backgroundLogPids = new Set<number>();
|
||||
private static backgroundLogStreams = new Map<number, fs.WriteStream>();
|
||||
private static exitedPtyInfo = new Map<
|
||||
number,
|
||||
{ exitCode: number; signal?: number }
|
||||
>();
|
||||
|
||||
static getLogDir(): string {
|
||||
return path.join(Storage.getGlobalTempDir(), 'background-processes');
|
||||
}
|
||||
|
||||
static getLogFilePath(pid: number): string {
|
||||
return path.join(this.getLogDir(), `background-${pid}.log`);
|
||||
}
|
||||
|
||||
private static syncBackgroundLog(pid: number, content: string): void {
|
||||
if (!this.backgroundLogPids.has(pid)) return;
|
||||
|
||||
const stream = this.backgroundLogStreams.get(pid);
|
||||
if (stream && content) {
|
||||
// Strip ANSI escape codes before logging
|
||||
stream.write(stripAnsi(content));
|
||||
}
|
||||
}
|
||||
|
||||
private static async cleanupLogStream(pid: number): Promise<void> {
|
||||
const stream = this.backgroundLogStreams.get(pid);
|
||||
if (stream) {
|
||||
await new Promise<void>((resolve) => {
|
||||
stream.end(() => resolve());
|
||||
});
|
||||
this.backgroundLogStreams.delete(pid);
|
||||
}
|
||||
|
||||
this.backgroundLogPids.delete(pid);
|
||||
}
|
||||
|
||||
private static activeResolvers = new Map<
|
||||
number,
|
||||
(res: ShellExecutionResult) => void
|
||||
@@ -432,7 +530,15 @@ export class ShellExecutionService {
|
||||
chunk: decodedChunk,
|
||||
};
|
||||
onOutputEvent(event);
|
||||
if (child.pid) ShellExecutionService.emitEvent(child.pid, event);
|
||||
if (child.pid) {
|
||||
ShellExecutionService.emitEvent(child.pid, event);
|
||||
if (ShellExecutionService.backgroundLogPids.has(child.pid)) {
|
||||
ShellExecutionService.syncBackgroundLog(
|
||||
child.pid,
|
||||
decodedChunk,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const totalBytes = state.outputChunks.reduce(
|
||||
@@ -468,17 +574,21 @@ export class ShellExecutionService {
|
||||
const exitSignal = signal ? os.constants.signals[signal] : null;
|
||||
|
||||
if (child.pid) {
|
||||
const pid = child.pid;
|
||||
const event: ShellOutputEvent = {
|
||||
type: 'exit',
|
||||
exitCode,
|
||||
signal: exitSignal,
|
||||
};
|
||||
onOutputEvent(event);
|
||||
ShellExecutionService.emitEvent(child.pid, event);
|
||||
ShellExecutionService.emitEvent(pid, event);
|
||||
|
||||
this.activeChildProcesses.delete(child.pid);
|
||||
this.activeResolvers.delete(child.pid);
|
||||
this.activeListeners.delete(child.pid);
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
ShellExecutionService.cleanupLogStream(pid).then(() => {
|
||||
this.activeChildProcesses.delete(pid);
|
||||
this.activeResolvers.delete(pid);
|
||||
this.activeListeners.delete(pid);
|
||||
});
|
||||
}
|
||||
|
||||
resolve({
|
||||
@@ -800,6 +910,16 @@ export class ShellExecutionService {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
ShellExecutionService.backgroundLogPids.has(ptyProcess.pid)
|
||||
) {
|
||||
ShellExecutionService.syncBackgroundLog(
|
||||
ptyProcess.pid,
|
||||
decodedChunk,
|
||||
);
|
||||
}
|
||||
|
||||
isWriting = true;
|
||||
headlessTerminal.write(decodedChunk, () => {
|
||||
render();
|
||||
@@ -832,7 +952,6 @@ export class ShellExecutionService {
|
||||
({ exitCode, signal }: { exitCode: number; signal?: number }) => {
|
||||
exited = true;
|
||||
abortSignal.removeEventListener('abort', abortHandler);
|
||||
this.activePtys.delete(ptyProcess.pid);
|
||||
// Attempt to destroy the PTY to ensure FD is closed
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
@@ -853,31 +972,36 @@ export class ShellExecutionService {
|
||||
5 * 60 * 1000,
|
||||
).unref();
|
||||
|
||||
this.activePtys.delete(ptyProcess.pid);
|
||||
this.activeResolvers.delete(ptyProcess.pid);
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
ShellExecutionService.cleanupLogStream(ptyProcess.pid).then(
|
||||
() => {
|
||||
this.activePtys.delete(ptyProcess.pid);
|
||||
this.activeResolvers.delete(ptyProcess.pid);
|
||||
|
||||
const event: ShellOutputEvent = {
|
||||
type: 'exit',
|
||||
exitCode,
|
||||
signal: signal ?? null,
|
||||
};
|
||||
onOutputEvent(event);
|
||||
ShellExecutionService.emitEvent(ptyProcess.pid, event);
|
||||
this.activeListeners.delete(ptyProcess.pid);
|
||||
const event: ShellOutputEvent = {
|
||||
type: 'exit',
|
||||
exitCode,
|
||||
signal: signal ?? null,
|
||||
};
|
||||
onOutputEvent(event);
|
||||
ShellExecutionService.emitEvent(ptyProcess.pid, event);
|
||||
this.activeListeners.delete(ptyProcess.pid);
|
||||
|
||||
const finalBuffer = Buffer.concat(outputChunks);
|
||||
const finalBuffer = Buffer.concat(outputChunks);
|
||||
|
||||
resolve({
|
||||
rawOutput: finalBuffer,
|
||||
output: getFullBufferText(headlessTerminal),
|
||||
exitCode,
|
||||
signal: signal ?? null,
|
||||
error,
|
||||
aborted: abortSignal.aborted,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
pid: ptyProcess.pid,
|
||||
executionMethod: ptyInfo?.name ?? 'node-pty',
|
||||
});
|
||||
resolve({
|
||||
rawOutput: finalBuffer,
|
||||
output: getFullBufferText(headlessTerminal),
|
||||
exitCode,
|
||||
signal: signal ?? null,
|
||||
error,
|
||||
aborted: abortSignal.aborted,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
pid: ptyProcess.pid,
|
||||
executionMethod: ptyInfo?.name ?? 'node-pty',
|
||||
});
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
if (abortSignal.aborted) {
|
||||
@@ -1050,10 +1174,12 @@ export class ShellExecutionService {
|
||||
*
|
||||
* @param pid The process ID to kill.
|
||||
*/
|
||||
static kill(pid: number): void {
|
||||
static async kill(pid: number): Promise<void> {
|
||||
const activePty = this.activePtys.get(pid);
|
||||
const activeChild = this.activeChildProcesses.get(pid);
|
||||
|
||||
await this.cleanupLogStream(pid);
|
||||
|
||||
if (activeChild) {
|
||||
killProcessGroup({ pid }).catch(() => {});
|
||||
this.activeChildProcesses.delete(pid);
|
||||
@@ -1079,44 +1205,53 @@ export class ShellExecutionService {
|
||||
*/
|
||||
static background(pid: number): void {
|
||||
const resolve = this.activeResolvers.get(pid);
|
||||
if (resolve) {
|
||||
let output = '';
|
||||
const rawOutput = Buffer.from('');
|
||||
if (!resolve) return;
|
||||
|
||||
const activePty = this.activePtys.get(pid);
|
||||
const activeChild = this.activeChildProcesses.get(pid);
|
||||
const activePty = this.activePtys.get(pid);
|
||||
const activeChild = this.activeChildProcesses.get(pid);
|
||||
if (!activePty && !activeChild) return;
|
||||
|
||||
const output = activePty
|
||||
? getFullBufferText(activePty.headlessTerminal)
|
||||
: (activeChild?.state.output ?? '');
|
||||
const executionMethod = activePty ? 'node-pty' : 'child_process';
|
||||
|
||||
const logPath = this.getLogFilePath(pid);
|
||||
const logDir = this.getLogDir();
|
||||
try {
|
||||
mkdirSync(logDir, { recursive: true });
|
||||
const stream = fs.createWriteStream(logPath, { flags: 'w' });
|
||||
stream.on('error', (err) => {
|
||||
debugLogger.warn('Background log stream error:', err);
|
||||
});
|
||||
this.backgroundLogStreams.set(pid, stream);
|
||||
|
||||
if (activePty) {
|
||||
output = getFullBufferText(activePty.headlessTerminal);
|
||||
resolve({
|
||||
rawOutput,
|
||||
output,
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
error: null,
|
||||
aborted: false,
|
||||
pid,
|
||||
executionMethod: 'node-pty',
|
||||
backgrounded: true,
|
||||
});
|
||||
writeBufferToLogStream(activePty.headlessTerminal, stream, 0);
|
||||
} else if (activeChild) {
|
||||
output = activeChild.state.output;
|
||||
|
||||
resolve({
|
||||
rawOutput,
|
||||
output,
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
error: null,
|
||||
aborted: false,
|
||||
pid,
|
||||
executionMethod: 'child_process',
|
||||
backgrounded: true,
|
||||
});
|
||||
if (output) {
|
||||
stream.write(stripAnsi(output) + '\n');
|
||||
}
|
||||
}
|
||||
|
||||
this.activeResolvers.delete(pid);
|
||||
} catch (e) {
|
||||
debugLogger.warn('Failed to setup background logging:', e);
|
||||
}
|
||||
|
||||
this.backgroundLogPids.add(pid);
|
||||
|
||||
resolve({
|
||||
rawOutput: Buffer.from(''),
|
||||
output,
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
error: null,
|
||||
aborted: false,
|
||||
pid,
|
||||
executionMethod,
|
||||
backgrounded: true,
|
||||
});
|
||||
|
||||
this.activeResolvers.delete(pid);
|
||||
}
|
||||
|
||||
static subscribe(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { formatForSpeech } from './responseFormatter.js';
|
||||
|
||||
describe('formatForSpeech', () => {
|
||||
describe('edge cases', () => {
|
||||
it('should return empty string for empty input', () => {
|
||||
expect(formatForSpeech('')).toBe('');
|
||||
});
|
||||
|
||||
it('should return plain text unchanged', () => {
|
||||
expect(formatForSpeech('Hello world')).toBe('Hello world');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ANSI escape codes', () => {
|
||||
it('should strip color codes', () => {
|
||||
expect(formatForSpeech('\x1b[31mError\x1b[0m')).toBe('Error');
|
||||
});
|
||||
|
||||
it('should strip bold/dim codes', () => {
|
||||
expect(formatForSpeech('\x1b[1mBold\x1b[22m text')).toBe('Bold text');
|
||||
});
|
||||
|
||||
it('should strip cursor movement codes', () => {
|
||||
expect(formatForSpeech('line1\x1b[2Kline2')).toBe('line1line2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('markdown stripping', () => {
|
||||
it('should strip bold markers **text**', () => {
|
||||
expect(formatForSpeech('**Error**: something went wrong')).toBe(
|
||||
'Error: something went wrong',
|
||||
);
|
||||
});
|
||||
|
||||
it('should strip bold markers __text__', () => {
|
||||
expect(formatForSpeech('__Error__: something')).toBe('Error: something');
|
||||
});
|
||||
|
||||
it('should strip italic markers *text*', () => {
|
||||
expect(formatForSpeech('*note*: pay attention')).toBe(
|
||||
'note: pay attention',
|
||||
);
|
||||
});
|
||||
|
||||
it('should strip inline code backticks', () => {
|
||||
expect(formatForSpeech('Run `npm install` first')).toBe(
|
||||
'Run npm install first',
|
||||
);
|
||||
});
|
||||
|
||||
it('should strip blockquote prefix', () => {
|
||||
expect(formatForSpeech('> This is a quote')).toBe('This is a quote');
|
||||
});
|
||||
|
||||
it('should strip heading markers', () => {
|
||||
expect(formatForSpeech('# Results\n## Details')).toBe('Results\nDetails');
|
||||
});
|
||||
|
||||
it('should replace markdown links with link text', () => {
|
||||
expect(formatForSpeech('[Gemini API](https://ai.google.dev)')).toBe(
|
||||
'Gemini API',
|
||||
);
|
||||
});
|
||||
|
||||
it('should strip unordered list markers', () => {
|
||||
expect(formatForSpeech('- item one\n- item two')).toBe(
|
||||
'item one\nitem two',
|
||||
);
|
||||
});
|
||||
|
||||
it('should strip ordered list markers', () => {
|
||||
expect(formatForSpeech('1. first\n2. second')).toBe('first\nsecond');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fenced code blocks', () => {
|
||||
it('should unwrap a plain code block', () => {
|
||||
expect(formatForSpeech('```\nconsole.log("hi")\n```')).toBe(
|
||||
'console.log("hi")',
|
||||
);
|
||||
});
|
||||
|
||||
it('should unwrap a language-tagged code block', () => {
|
||||
expect(formatForSpeech('```typescript\nconst x = 1;\n```')).toBe(
|
||||
'const x = 1;',
|
||||
);
|
||||
});
|
||||
|
||||
it('should summarise a JSON object code block above threshold', () => {
|
||||
const json = JSON.stringify({ status: 'ok', count: 42, items: [] });
|
||||
// Pass jsonThreshold lower than the json string length (38 chars)
|
||||
const result = formatForSpeech(`\`\`\`json\n${json}\n\`\`\``, {
|
||||
jsonThreshold: 10,
|
||||
});
|
||||
expect(result).toBe('(JSON object with 3 keys)');
|
||||
});
|
||||
|
||||
it('should summarise a JSON array code block above threshold', () => {
|
||||
const json = JSON.stringify([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
|
||||
// Pass jsonThreshold lower than the json string length (23 chars)
|
||||
const result = formatForSpeech(`\`\`\`\n${json}\n\`\`\``, {
|
||||
jsonThreshold: 10,
|
||||
});
|
||||
expect(result).toBe('(JSON array with 10 items)');
|
||||
});
|
||||
|
||||
it('should summarise a large JSON object using default threshold', () => {
|
||||
// Build a JSON object whose stringified form exceeds the default 80-char threshold
|
||||
const big = {
|
||||
status: 'success',
|
||||
count: 42,
|
||||
items: ['alpha', 'beta', 'gamma'],
|
||||
meta: { page: 1, totalPages: 10 },
|
||||
timestamp: '2026-03-03T00:00:00Z',
|
||||
};
|
||||
const json = JSON.stringify(big);
|
||||
expect(json.length).toBeGreaterThan(80);
|
||||
const result = formatForSpeech(`\`\`\`json\n${json}\n\`\`\``);
|
||||
expect(result).toBe('(JSON object with 5 keys)');
|
||||
});
|
||||
|
||||
it('should not summarise a tiny JSON value', () => {
|
||||
// Below the default 80-char threshold → keep as-is
|
||||
const result = formatForSpeech('```json\n{"a":1}\n```', {
|
||||
jsonThreshold: 80,
|
||||
});
|
||||
expect(result).toBe('{"a":1}');
|
||||
});
|
||||
});
|
||||
|
||||
describe('path abbreviation', () => {
|
||||
it('should abbreviate a deep Unix path (default depth 3)', () => {
|
||||
const result = formatForSpeech(
|
||||
'at /home/user/project/packages/core/src/tools/file.ts',
|
||||
);
|
||||
expect(result).toContain('\u2026/src/tools/file.ts');
|
||||
expect(result).not.toContain('/home/user/project');
|
||||
});
|
||||
|
||||
it('should convert :line suffix to "line N"', () => {
|
||||
const result = formatForSpeech(
|
||||
'Error at /home/user/project/src/tools/file.ts:142',
|
||||
);
|
||||
expect(result).toContain('line 142');
|
||||
});
|
||||
|
||||
it('should drop column from :line:col suffix', () => {
|
||||
const result = formatForSpeech(
|
||||
'Error at /home/user/project/src/tools/file.ts:142:7',
|
||||
);
|
||||
expect(result).toContain('line 142');
|
||||
expect(result).not.toContain(':7');
|
||||
});
|
||||
|
||||
it('should respect custom pathDepth option', () => {
|
||||
const result = formatForSpeech(
|
||||
'/home/user/project/packages/core/src/file.ts',
|
||||
{ pathDepth: 2 },
|
||||
);
|
||||
expect(result).toContain('\u2026/src/file.ts');
|
||||
});
|
||||
|
||||
it('should not abbreviate a short path within depth', () => {
|
||||
const result = formatForSpeech('/src/file.ts', { pathDepth: 3 });
|
||||
// Only 2 segments — no abbreviation needed
|
||||
expect(result).toBe('/src/file.ts');
|
||||
});
|
||||
|
||||
it('should abbreviate a Windows path on a non-C drive', () => {
|
||||
const result = formatForSpeech(
|
||||
'D:\\Users\\project\\packages\\core\\src\\file.ts',
|
||||
{ pathDepth: 3 },
|
||||
);
|
||||
expect(result).toContain('\u2026/core/src/file.ts');
|
||||
expect(result).not.toContain('D:\\Users\\project');
|
||||
});
|
||||
|
||||
it('should convert :line on a Windows path on a non-C drive', () => {
|
||||
const result = formatForSpeech(
|
||||
'Error at D:\\Users\\project\\src\\tools\\file.ts:55',
|
||||
);
|
||||
expect(result).toContain('line 55');
|
||||
expect(result).not.toContain('D:\\Users\\project');
|
||||
});
|
||||
|
||||
it('should abbreviate a Unix path containing a scoped npm package segment', () => {
|
||||
const result = formatForSpeech(
|
||||
'at /home/user/project/node_modules/@google/gemini-cli-core/src/index.ts:12:3',
|
||||
{ pathDepth: 5 },
|
||||
);
|
||||
expect(result).toContain('line 12');
|
||||
expect(result).not.toContain(':3');
|
||||
expect(result).toContain('@google');
|
||||
});
|
||||
});
|
||||
|
||||
describe('stack trace collapsing', () => {
|
||||
it('should collapse a multi-frame stack trace', () => {
|
||||
const trace = [
|
||||
'Error: ENOENT',
|
||||
' at Object.open (/project/src/file.ts:10:5)',
|
||||
' at Module._load (/project/node_modules/loader.js:20:3)',
|
||||
' at Function.Module._load (/project/node_modules/loader.js:30:3)',
|
||||
].join('\n');
|
||||
|
||||
const result = formatForSpeech(trace);
|
||||
expect(result).toContain('and 2 more frames');
|
||||
expect(result).not.toContain('Module._load');
|
||||
});
|
||||
|
||||
it('should not collapse a single stack frame', () => {
|
||||
const trace =
|
||||
'Error: ENOENT\n at Object.open (/project/src/file.ts:10:5)';
|
||||
const result = formatForSpeech(trace);
|
||||
expect(result).not.toContain('more frames');
|
||||
});
|
||||
|
||||
it('should preserve surrounding text when collapsing a stack trace', () => {
|
||||
const input = [
|
||||
'Operation failed.',
|
||||
' at Object.open (/project/src/file.ts:10:5)',
|
||||
' at Module._load (/project/node_modules/loader.js:20:3)',
|
||||
' at Function.load (/project/node_modules/loader.js:30:3)',
|
||||
'Please try again.',
|
||||
].join('\n');
|
||||
|
||||
const result = formatForSpeech(input);
|
||||
expect(result).toContain('Operation failed.');
|
||||
expect(result).toContain('Please try again.');
|
||||
expect(result).toContain('and 2 more frames');
|
||||
});
|
||||
});
|
||||
|
||||
describe('truncation', () => {
|
||||
it('should truncate output longer than maxLength', () => {
|
||||
const long = 'word '.repeat(200);
|
||||
const result = formatForSpeech(long, { maxLength: 50 });
|
||||
expect(result.length).toBeLessThanOrEqual(
|
||||
50 + '\u2026 (1000 chars total)'.length,
|
||||
);
|
||||
expect(result).toContain('\u2026');
|
||||
expect(result).toContain('chars total');
|
||||
});
|
||||
|
||||
it('should not truncate output within maxLength', () => {
|
||||
const short = 'Hello world';
|
||||
expect(formatForSpeech(short, { maxLength: 500 })).toBe('Hello world');
|
||||
});
|
||||
});
|
||||
|
||||
describe('whitespace normalisation', () => {
|
||||
it('should collapse more than two consecutive blank lines', () => {
|
||||
const result = formatForSpeech('para1\n\n\n\n\npara2');
|
||||
expect(result).toBe('para1\n\npara2');
|
||||
});
|
||||
|
||||
it('should trim leading and trailing whitespace', () => {
|
||||
expect(formatForSpeech(' hello ')).toBe('hello');
|
||||
});
|
||||
});
|
||||
|
||||
describe('real-world examples', () => {
|
||||
it('should clean an ENOENT error with markdown and path', () => {
|
||||
const input =
|
||||
'**Error**: `ENOENT: no such file or directory`\n> at /home/user/project/packages/core/src/tools/file-utils.ts:142:7';
|
||||
const result = formatForSpeech(input);
|
||||
expect(result).not.toContain('**');
|
||||
expect(result).not.toContain('`');
|
||||
expect(result).not.toContain('>');
|
||||
expect(result).toContain('Error');
|
||||
expect(result).toContain('ENOENT');
|
||||
expect(result).toContain('line 142');
|
||||
});
|
||||
|
||||
it('should clean a heading + list response', () => {
|
||||
const input = '# Results\n- item one\n- item two\n- item three';
|
||||
const result = formatForSpeech(input);
|
||||
expect(result).toBe('Results\nitem one\nitem two\nitem three');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Options for formatForSpeech().
|
||||
*/
|
||||
export interface FormatForSpeechOptions {
|
||||
/**
|
||||
* Maximum output length in characters before truncating.
|
||||
* @default 500
|
||||
*/
|
||||
maxLength?: number;
|
||||
/**
|
||||
* Number of trailing path segments to keep when abbreviating absolute paths.
|
||||
* @default 3
|
||||
*/
|
||||
pathDepth?: number;
|
||||
/**
|
||||
* Maximum number of characters in a JSON value before summarising it.
|
||||
* @default 80
|
||||
*/
|
||||
jsonThreshold?: number;
|
||||
}
|
||||
|
||||
// ANSI escape sequences (CSI, OSC, etc.)
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const ANSI_RE = /\x1b(?:\[[0-9;]*[mGKHF]|\][^\x07\x1b]*\x07|[()][AB012])/g;
|
||||
|
||||
// Fenced code blocks ```lang\n...\n```
|
||||
const CODE_FENCE_RE = /```[^\n]*\n([\s\S]*?)```/g;
|
||||
|
||||
// Inline code `...`
|
||||
const INLINE_CODE_RE = /`([^`]+)`/g;
|
||||
|
||||
// Bold/italic markers **text**, *text*, __text__, _text_
|
||||
// Exclude newlines so the pattern cannot span multiple lines and accidentally
|
||||
// consume list markers that haven't been stripped yet.
|
||||
const BOLD_ITALIC_RE = /\*{1,2}([^*\n]+)\*{1,2}|_{1,2}([^_\n]+)_{1,2}/g;
|
||||
|
||||
// Blockquote prefix "> "
|
||||
const BLOCKQUOTE_RE = /^>\s?/gm;
|
||||
|
||||
// ATX headings # heading
|
||||
const HEADING_RE = /^#{1,6}\s+/gm;
|
||||
|
||||
// Markdown links [text](url)
|
||||
const LINK_RE = /\[([^\]]+)\]\([^)]+\)/g;
|
||||
|
||||
// Markdown list markers "- " or "* " or "N. " at line start
|
||||
const LIST_MARKER_RE = /^[ \t]*(?:[-*]|\d+\.)\s+/gm;
|
||||
|
||||
// Two or more consecutive stack-trace frames (Node.js style " at …" lines).
|
||||
// Matching blocks of ≥2 lets us replace each group in-place, preserving any
|
||||
// text that follows the trace rather than appending it to the end.
|
||||
const STACK_BLOCK_RE = /(?:^[ \t]+at [^\n]+(?:\n|$)){2,}/gm;
|
||||
|
||||
// Absolute Unix paths optionally ending with :line or :line:col
|
||||
// Hyphen placed at start of char class to avoid useless-escape lint error
|
||||
const UNIX_PATH_RE =
|
||||
/(?:^|(?<=\s|[(`"']))(\/[-\w.@]+(?:\/[-\w.@]+)*)(:\d+(?::\d+)?)?/g;
|
||||
|
||||
// Absolute Windows paths C:\... or C:/... (any drive letter)
|
||||
const WIN_PATH_RE =
|
||||
/(?:^|(?<=\s|[(`"']))([A-Za-z]:[/\\][-\w. ]+(?:[/\\][-\w. ]+)*)(:\d+(?::\d+)?)?/g;
|
||||
|
||||
/**
|
||||
* Abbreviates an absolute path to at most `depth` trailing segments,
|
||||
* prefixed with "…". Optionally converts `:line` suffix to `line N`.
|
||||
*/
|
||||
function abbreviatePath(
|
||||
full: string,
|
||||
suffix: string | undefined,
|
||||
depth: number,
|
||||
): string {
|
||||
const segments = full.split(/[/\\]/).filter(Boolean);
|
||||
const kept = segments.length > depth ? segments.slice(-depth) : segments;
|
||||
const abbreviated =
|
||||
segments.length > depth ? `\u2026/${kept.join('/')}` : full;
|
||||
|
||||
if (!suffix) return abbreviated;
|
||||
// Convert ":142" → " line 142", ":142:7" → " line 142"
|
||||
const lineNum = suffix.split(':').filter(Boolean)[0];
|
||||
return `${abbreviated} line ${lineNum}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Summarises a JSON string as "(JSON object with N keys)" or
|
||||
* "(JSON array with N items)", falling back to the original if parsing fails.
|
||||
*/
|
||||
function summariseJson(jsonStr: string): string {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(jsonStr);
|
||||
if (Array.isArray(parsed)) {
|
||||
return `(JSON array with ${parsed.length} item${parsed.length === 1 ? '' : 's'})`;
|
||||
}
|
||||
if (parsed !== null && typeof parsed === 'object') {
|
||||
const keys = Object.keys(parsed).length;
|
||||
return `(JSON object with ${keys} key${keys === 1 ? '' : 's'})`;
|
||||
}
|
||||
} catch {
|
||||
// not valid JSON — leave as-is
|
||||
}
|
||||
return jsonStr;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms a markdown/ANSI-formatted string into speech-ready plain text.
|
||||
*
|
||||
* Transformations applied (in order):
|
||||
* 1. Strip ANSI escape codes
|
||||
* 2. Collapse fenced code blocks to their content (or a JSON summary)
|
||||
* 3. Collapse stack traces to first frame + count
|
||||
* 4. Strip markdown syntax (bold, italic, blockquotes, headings, links, lists, inline code)
|
||||
* 5. Abbreviate deep absolute paths
|
||||
* 6. Normalise whitespace
|
||||
* 7. Truncate to maxLength
|
||||
*/
|
||||
export function formatForSpeech(
|
||||
text: string,
|
||||
options?: FormatForSpeechOptions,
|
||||
): string {
|
||||
const maxLength = options?.maxLength ?? 500;
|
||||
const pathDepth = options?.pathDepth ?? 3;
|
||||
const jsonThreshold = options?.jsonThreshold ?? 80;
|
||||
|
||||
if (!text) return '';
|
||||
|
||||
let out = text;
|
||||
|
||||
// 1. Strip ANSI escape codes
|
||||
out = out.replace(ANSI_RE, '');
|
||||
|
||||
// 2. Fenced code blocks — try to summarise JSON content, else keep text
|
||||
out = out.replace(CODE_FENCE_RE, (_match, body: string) => {
|
||||
const trimmed = body.trim();
|
||||
if (trimmed.length > jsonThreshold) {
|
||||
const summary = summariseJson(trimmed);
|
||||
if (summary !== trimmed) return summary;
|
||||
}
|
||||
return trimmed;
|
||||
});
|
||||
|
||||
// 3. Collapse stack traces: replace each contiguous block of ≥2 frames
|
||||
// in-place so that any text after the trace is preserved in order.
|
||||
out = out.replace(STACK_BLOCK_RE, (block) => {
|
||||
const lines = block
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map((l) => l.trim());
|
||||
const rest = lines.length - 1;
|
||||
return `${lines[0]} (and ${rest} more frame${rest === 1 ? '' : 's'})\n`;
|
||||
});
|
||||
|
||||
// 4. Strip markdown syntax
|
||||
out = out
|
||||
.replace(INLINE_CODE_RE, '$1')
|
||||
.replace(BOLD_ITALIC_RE, (_m, g1?: string, g2?: string) => g1 ?? g2 ?? '')
|
||||
.replace(BLOCKQUOTE_RE, '')
|
||||
.replace(HEADING_RE, '')
|
||||
.replace(LINK_RE, '$1')
|
||||
.replace(LIST_MARKER_RE, '');
|
||||
|
||||
// 5. Abbreviate absolute paths
|
||||
// Windows paths first to avoid the leading letter being caught by Unix RE
|
||||
out = out.replace(WIN_PATH_RE, (_m, full: string, suffix?: string) =>
|
||||
abbreviatePath(full, suffix, pathDepth),
|
||||
);
|
||||
out = out.replace(UNIX_PATH_RE, (_m, full: string, suffix?: string) =>
|
||||
abbreviatePath(full, suffix, pathDepth),
|
||||
);
|
||||
|
||||
// 6. Normalise whitespace: collapse multiple blank lines, trim
|
||||
out = out.replace(/\n{3,}/g, '\n\n').trim();
|
||||
|
||||
// 7. Truncate
|
||||
if (out.length > maxLength) {
|
||||
const total = out.length;
|
||||
out = out.slice(0, maxLength).trimEnd() + `\u2026 (${total} chars total)`;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-devtools",
|
||||
"version": "0.34.0-nightly.20260304.28af4e127",
|
||||
"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.20260304.28af4e127",
|
||||
"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.20260304.28af4e127",
|
||||
"version": "0.34.0",
|
||||
"private": true,
|
||||
"main": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
|
||||
@@ -34,7 +34,7 @@ SOFTWARE.
|
||||
License text not found.
|
||||
|
||||
============================================================
|
||||
ajv@6.14.0
|
||||
ajv@6.12.6
|
||||
(https://github.com/ajv-validator/ajv.git)
|
||||
|
||||
The MIT License (MIT)
|
||||
@@ -1676,33 +1676,6 @@ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
||||
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
|
||||
============================================================
|
||||
safe-buffer@5.2.1
|
||||
(git://github.com/feross/safe-buffer.git)
|
||||
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) Feross Aboukhadijeh
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
|
||||
|
||||
============================================================
|
||||
cookie@0.7.2
|
||||
(No repository found)
|
||||
@@ -2156,33 +2129,6 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
|
||||
============================================================
|
||||
path-to-regexp@6.3.0
|
||||
(https://github.com/pillarjs/path-to-regexp.git)
|
||||
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2014 Blake Embrey (hello@blakeembrey.com)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
|
||||
|
||||
============================================================
|
||||
send@1.2.1
|
||||
(No repository found)
|
||||
@@ -2295,7 +2241,7 @@ THE SOFTWARE.
|
||||
|
||||
|
||||
============================================================
|
||||
hono@4.12.2
|
||||
hono@4.11.9
|
||||
(git+https://github.com/honojs/hono.git)
|
||||
|
||||
MIT License
|
||||
|
||||
@@ -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.20260304.28af4e127",
|
||||
"version": "0.34.0",
|
||||
"publisher": "google",
|
||||
"icon": "assets/icon.png",
|
||||
"repository": {
|
||||
|
||||
@@ -1689,6 +1689,13 @@
|
||||
"default": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
"extensionRegistryURI": {
|
||||
"title": "Extension Registry URI",
|
||||
"description": "The URI (web URL or local file path) of the extension registry.",
|
||||
"markdownDescription": "The URI (web URL or local file path) of the extension registry.\n\n- Category: `Experimental`\n- Requires restart: `yes`\n- Default: `https://geminicli.com/extensions.json`",
|
||||
"default": "https://geminicli.com/extensions.json",
|
||||
"type": "string"
|
||||
},
|
||||
"extensionReloading": {
|
||||
"title": "Extension Reloading",
|
||||
"description": "Enables extension loading/unloading within the CLI session.",
|
||||
|
||||
@@ -70,5 +70,36 @@ if (invalidPackages.length > 0) {
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log('Lockfile check passed.');
|
||||
}
|
||||
|
||||
// Check that gaxios v7+ is NOT resolved in any workspace node_modules.
|
||||
// gaxios v7.x has a bug where Array.toString() joins stream chunks with
|
||||
// commas, corrupting error response JSON at TCP chunk boundaries.
|
||||
// See: https://github.com/google-gemini/gemini-cli/pull/21884
|
||||
const gaxiosViolations = [];
|
||||
for (const [location, details] of Object.entries(packages)) {
|
||||
if (
|
||||
location.match(/(^|\/)node_modules\/gaxios$/) &&
|
||||
!location.includes('@google/genai/node_modules') &&
|
||||
details.version &&
|
||||
parseInt(details.version.split('.')[0], 10) >= 7
|
||||
) {
|
||||
gaxiosViolations.push(`${location} (v${details.version})`);
|
||||
}
|
||||
}
|
||||
|
||||
if (gaxiosViolations.length > 0) {
|
||||
console.error(
|
||||
'\nError: gaxios v7+ detected in workspace node_modules. This version has a stream corruption bug.',
|
||||
);
|
||||
console.error('See: https://github.com/google-gemini/gemini-cli/pull/21884');
|
||||
gaxiosViolations.forEach((v) => console.error(`- ${v}`));
|
||||
console.error(
|
||||
'\nDo NOT upgrade @google/genai or google-auth-library until the gaxios v7 bug is fixed upstream.',
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
if (!process.exitCode) {
|
||||
process.exitCode = 0;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user