Compare commits

..

11 Commits

Author SHA1 Message Date
Christian Gunderman d06d875d5c Queue up final response and show at the end. 2026-04-07 17:28:58 -07:00
Christian Gunderman 316ed83b79 Reapply "fix(ui): improve narration suppression and reduce flicker (#24635)"
This reverts commit 8199879513.
2026-04-07 15:16:38 -07:00
Christian Gunderman 8199879513 Revert "fix(ui): improve narration suppression and reduce flicker (#24635)"
This reverts commit 7872d6d7fe.
2026-04-07 15:12:23 -07:00
David Pierce adf7b3b717 Improve sandbox error matching and caching (#24550) 2026-04-07 21:08:18 +00:00
Jack Wotherspoon 9637fb3990 fix(core): remove tmux alternate buffer warning (#24852) 2026-04-07 21:01:14 +00:00
Sri Pasumarthi 06fcdc231c feat(acp): add /help command (#24839) 2026-04-07 20:01:44 +00:00
Sehoon Shon d29da15427 fix(cli): prevent multiple banner increments on remount (#24843) 2026-04-07 19:44:09 +00:00
Enjoy Kumawat ab3075feb9 fix: use directory junctions on Windows for skill linking (#24823) 2026-04-07 19:28:43 +00:00
Abhi 5588000e93 chore: fix formatting for behavioral eval skill reference file (#24846) 2026-04-07 19:26:53 +00:00
krishdef7 68fef8745e fix(core): propagate BeforeModel hook model override end-to-end (#24784)
Signed-off-by: krishdef7 <gargkrish06@gmail.com>
Co-authored-by: Sandy Tao <sandytao520@icloud.com>
2026-04-07 17:49:26 +00:00
Michael Bleigh e432f7c009 feat(hooks): display hook system messages in UI (#24616) 2026-04-07 17:42:39 +00:00
52 changed files with 814 additions and 759 deletions
@@ -33,16 +33,35 @@ evaluation.
- **Warning**: Do not lose test fidelity by making prompts too direct/easy.
- **Primary Fix Trigger**: Adjust tool descriptions, system prompts
(`snippets.ts`), or **modules that contribute to the prompt template**.
- Fixes should generally try to improve the prompt `@packages/core/src/prompts/snippets.ts` first.
- **Instructional Generality**: Changes to the system prompt should aim to be as general as possible while still accomplishing the goal. Specificity should be added only as needed.
- **Principle**: Instead of creating "forbidden lists" for specific syntax (e.g., "Don't use `Object.create()`"), formulate a broader engineering principle that covers the underlying issue (e.g., "Prioritize explicit composition over hidden prototype manipulation"). This improves steerability across a wider range of similar scenarios.
- *Low Specificity*: "Follow ecosystem best practices"
- *Medium Specificity*: "Utilize OOP and functional best practices, as applicable"
- *High Specificity*: Provide ecosystem-specific hints as examples of a broader principle rather than direct instructions. e.g., "NEVER use hacks like bypassing the type system or employing 'hidden' logic (e.g.: reflection, prototype manipulation). Instead, use explicit and idiomatic language features (e.g.: type guards, explicit class instantiation, or object spread) that maintain structural integrity."
- **Prompt Simplification**: Once the test is passing, use `ask_user` to determine if prompt simplification is desired.
- **Criteria**: Simplification should be attempted only if there are related clauses that can be de-duplicated or reparented under a single heading.
- **Verification**: As part of simplification, you MUST identify and run any behavioral eval tests that might be affected by the changes to ensure no regressions are introduced.
- Test fixes should not "cheat" by changing a test's `GEMINI.md` file or by updating the test's prompt to instruct it to not repro the bug.
- Fixes should generally try to improve the prompt
`@packages/core/src/prompts/snippets.ts` first.
- **Instructional Generality**: Changes to the system prompt should aim to
be as general as possible while still accomplishing the goal. Specificity
should be added only as needed.
- **Principle**: Instead of creating "forbidden lists" for specific syntax
(e.g., "Don't use `Object.create()`"), formulate a broader engineering
principle that covers the underlying issue (e.g., "Prioritize explicit
composition over hidden prototype manipulation"). This improves
steerability across a wider range of similar scenarios.
- _Low Specificity_: "Follow ecosystem best practices"
- _Medium Specificity_: "Utilize OOP and functional best practices, as
applicable"
- _High Specificity_: Provide ecosystem-specific hints as examples of a
broader principle rather than direct instructions. e.g., "NEVER use
hacks like bypassing the type system or employing 'hidden' logic (e.g.:
reflection, prototype manipulation). Instead, use explicit and idiomatic
language features (e.g.: type guards, explicit class instantiation, or
object spread) that maintain structural integrity."
- **Prompt Simplification**: Once the test is passing, use `ask_user` to
determine if prompt simplification is desired.
- **Criteria**: Simplification should be attempted only if there are
related clauses that can be de-duplicated or reparented under a single
heading.
- **Verification**: As part of simplification, you MUST identify and run
any behavioral eval tests that might be affected by the changes to
ensure no regressions are introduced.
- Test fixes should not "cheat" by changing a test's `GEMINI.md` file or by
updating the test's prompt to instruct it to not repro the bug.
- **Warning**: Prompts have multiple configurations; ensure your fix targets
the correct config for the model in question.
4. **Architecture Options**: If prompt or instruction tuning triggers no
-5
View File
@@ -2350,11 +2350,6 @@ for that specific session.
with screen readers.
- **`--version`**:
- Displays the version of the CLI.
- **`--channels <channel1,channel2,...>`**:
- A comma-separated list of MCP server names to enable as message channels.
- When specified, the CLI will listen for asynchronous messages from these
servers and inject them into the conversation.
- Example: `gemini --channels telegram,slack`
- **`--yolo`**:
- Enables YOLO mode, which automatically approves all tool calls.
@@ -29,5 +29,8 @@ describe('CommandHandler', () => {
const about = parse('/about');
expect(about.commandToExecute?.name).toBe('about');
const help = parse('/help');
expect(help.commandToExecute?.name).toBe('help');
});
});
+2
View File
@@ -11,6 +11,7 @@ import { ExtensionsCommand } from './commands/extensions.js';
import { InitCommand } from './commands/init.js';
import { RestoreCommand } from './commands/restore.js';
import { AboutCommand } from './commands/about.js';
import { HelpCommand } from './commands/help.js';
export class CommandHandler {
private registry: CommandRegistry;
@@ -26,6 +27,7 @@ export class CommandHandler {
registry.register(new InitCommand());
registry.register(new RestoreCommand());
registry.register(new AboutCommand());
registry.register(new HelpCommand(registry));
return registry;
}
@@ -0,0 +1,53 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { HelpCommand } from './help.js';
import { CommandRegistry } from './commandRegistry.js';
import type { Command, CommandContext } from './types.js';
describe('HelpCommand', () => {
it('returns formatted help text with sorted commands', async () => {
const registry = new CommandRegistry();
const cmdB: Command = {
name: 'bravo',
description: 'Bravo command',
execute: async () => ({ name: 'bravo', data: '' }),
};
const cmdA: Command = {
name: 'alpha',
description: 'Alpha command',
execute: async () => ({ name: 'alpha', data: '' }),
};
registry.register(cmdB);
registry.register(cmdA);
const helpCommand = new HelpCommand(registry);
const context = {} as CommandContext;
const response = await helpCommand.execute(context, []);
expect(response.name).toBe('help');
const data = response.data as string;
expect(data).toContain('Gemini CLI Help:');
expect(data).toContain('### Basics');
expect(data).toContain('### Commands');
const lines = data.split('\n');
const alphaIndex = lines.findIndex((l) => l.includes('/alpha'));
const bravoIndex = lines.findIndex((l) => l.includes('/bravo'));
expect(alphaIndex).toBeLessThan(bravoIndex);
expect(alphaIndex).not.toBe(-1);
expect(bravoIndex).not.toBe(-1);
});
});
+50
View File
@@ -0,0 +1,50 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type {
Command,
CommandContext,
CommandExecutionResponse,
} from './types.js';
import type { CommandRegistry } from './commandRegistry.js';
export class HelpCommand implements Command {
readonly name = 'help';
readonly description = 'Show available commands';
constructor(private registry: CommandRegistry) {}
async execute(
_context: CommandContext,
_args: string[] = [],
): Promise<CommandExecutionResponse> {
const commands = this.registry
.getAllCommands()
.sort((a, b) => a.name.localeCompare(b.name));
const lines: string[] = [];
lines.push('Gemini CLI Help:');
lines.push('');
lines.push('### Basics');
lines.push(
'- **Add context**: Use `@` to specify files for context (e.g., `@src/myFile.ts`) to target specific files or folders.',
);
lines.push('');
lines.push('### Commands');
for (const cmd of commands) {
if (cmd.description) {
lines.push(`- **/${cmd.name}** - ${cmd.description}`);
}
}
return {
name: this.name,
data: lines.join('\n'),
};
}
}
-1
View File
@@ -115,7 +115,6 @@ async function testMCPConnection(
}
},
isTrustedFolder: () => isTrusted,
getChannels: () => [],
};
let transport;
-8
View File
@@ -106,7 +106,6 @@ export interface CliArgs {
rawOutput: boolean | undefined;
acceptRawOutputRisk: boolean | undefined;
isCommand: boolean | undefined;
channels: string[] | undefined;
}
/**
@@ -444,12 +443,6 @@ export async function parseArguments(
.option('accept-raw-output-risk', {
type: 'boolean',
description: 'Suppress the security warning when using --raw-output.',
})
.option('channels', {
type: 'string',
array: true,
description: 'Enable channel message delivery from named MCP servers',
coerce: coerceCommaSeparated,
}),
)
.version(await getVersion()) // This will enable the --version flag based on package.json
@@ -1055,7 +1048,6 @@ export async function loadCliConfig(
};
},
enableConseca: settings.security?.enableConseca,
channels: argv.channels,
});
}
-2
View File
@@ -516,7 +516,6 @@ describe('gemini.tsx main function kitty protocol', () => {
rawOutput: undefined,
acceptRawOutputRisk: undefined,
isCommand: undefined,
channels: undefined,
});
await act(async () => {
@@ -575,7 +574,6 @@ describe('gemini.tsx main function kitty protocol', () => {
rawOutput: undefined,
acceptRawOutputRisk: undefined,
isCommand: undefined,
channels: undefined,
});
await act(async () => {
@@ -22,7 +22,6 @@ import { aboutCommand } from '../ui/commands/aboutCommand.js';
import { agentsCommand } from '../ui/commands/agentsCommand.js';
import { authCommand } from '../ui/commands/authCommand.js';
import { bugCommand } from '../ui/commands/bugCommand.js';
import { channelsCommand } from '../ui/commands/channelsCommand.js';
import { chatCommand, debugCommand } from '../ui/commands/chatCommand.js';
import { clearCommand } from '../ui/commands/clearCommand.js';
import { commandsCommand } from '../ui/commands/commandsCommand.js';
@@ -122,7 +121,6 @@ export class BuiltinCommandLoader implements ICommandLoader {
...(this.config?.isAgentsEnabled() ? [agentsCommand] : []),
authCommand,
bugCommand,
channelsCommand,
{
...chatCommand,
subCommands: chatResumeSubCommands,
+17 -23
View File
@@ -36,10 +36,11 @@ import {
type ConfirmationRequest,
type PermissionConfirmationRequest,
type QuotaStats,
MessageType,
StreamingState,
type HistoryItemInfo,
} from './types.js';
import { checkPermissions } from './hooks/atCommandProcessor.js';
import { MessageType, StreamingState } from './types.js';
import { theme } from './semantic-colors.js';
import { ToolActionsProvider } from './contexts/ToolActionsContext.js';
import { MouseProvider } from './contexts/MouseContext.js';
import { ScrollProvider } from './contexts/ScrollProvider.js';
@@ -52,6 +53,7 @@ import {
type UserTierId,
type GeminiUserTier,
type UserFeedbackPayload,
type HookSystemMessagePayload,
type AgentDefinition,
type ApprovalMode,
IdeClient,
@@ -70,7 +72,6 @@ import {
refreshServerHierarchicalMemory,
flattenMemory,
type MemoryChangedPayload,
type ChannelMessagePayload,
writeToStdout,
disableMouseEvents,
enterAlternateScreen,
@@ -1275,20 +1276,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
isMcpReady,
});
// Listen for external channel messages from MCP servers declaring
// experimental['gemini/channel'] and inject them into the message queue.
const channelsEnabled = config.getChannels().length > 0;
useEffect(() => {
if (!channelsEnabled) return;
const handler = (payload: ChannelMessagePayload) => {
addMessage(payload.content);
};
coreEvents.on(CoreEvent.ChannelMessage, handler);
return () => {
coreEvents.off(CoreEvent.ChannelMessage, handler);
};
}, [channelsEnabled, addMessage]);
cancelHandlerRef.current = useCallback(
(shouldRestorePrompt: boolean = true) => {
if (isToolAwaitingConfirmation(pendingHistoryItems)) {
@@ -2110,16 +2097,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
);
}
const isChannel = payload.style === 'channel';
historyManager.addItem(
{
type,
text: payload.message,
...(isChannel && {
icon: '» ',
color: theme.text.secondary,
marginTop: 0,
}),
},
Date.now(),
);
@@ -2133,7 +2114,19 @@ Logging in with Google... Restarting Gemini CLI to continue.
}
};
const handleHookSystemMessage = (payload: HookSystemMessagePayload) => {
historyManager.addItem(
{
type: MessageType.INFO,
text: payload.message,
source: payload.hookName,
} as HistoryItemInfo,
Date.now(),
);
};
coreEvents.on(CoreEvent.UserFeedback, handleUserFeedback);
coreEvents.on(CoreEvent.HookSystemMessage, handleHookSystemMessage);
// Flush any messages that happened during startup before this component
// mounted.
@@ -2141,6 +2134,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
return () => {
coreEvents.off(CoreEvent.UserFeedback, handleUserFeedback);
coreEvents.off(CoreEvent.HookSystemMessage, handleHookSystemMessage);
};
}, [historyManager]);
@@ -1,39 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { SlashCommand, CommandContext } from './types.js';
import { CommandKind } from './types.js';
import {
MessageType,
type ChannelInfo,
type HistoryItemChannelsList,
} from '../types.js';
import { activeChannels } from '@google/gemini-cli-core';
export const channelsCommand: SlashCommand = {
name: 'channels',
description: 'List active message channels from MCP servers',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: async (context: CommandContext) => {
const channels: ChannelInfo[] = Array.from(activeChannels.entries()).map(
([name, capability]) => ({
name,
displayName: capability.displayName,
supportsReply: capability.supportsReply,
}),
);
const channelsListItem: HistoryItemChannelsList = {
type: MessageType.CHANNELS_LIST,
channels,
};
context.ui.addItem(channelsListItem);
return;
},
};
@@ -10,15 +10,20 @@ import {
} from '../../test-utils/render.js';
import type { LoadedSettings } from '../../config/settings.js';
import { AppHeader } from './AppHeader.js';
import { describe, it, expect, vi } from 'vitest';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { makeFakeConfig } from '@google/gemini-cli-core';
import crypto from 'node:crypto';
import { _clearSessionBannersForTest } from '../hooks/useBanner.js';
vi.mock('../utils/terminalSetup.js', () => ({
getTerminalProgram: () => null,
}));
describe('<AppHeader />', () => {
beforeEach(() => {
_clearSessionBannersForTest();
});
it('should render the banner with default text', async () => {
const uiState = {
history: [],
@@ -31,7 +31,6 @@ import { getMCPServerStatus } from '@google/gemini-cli-core';
import { ToolsList } from './views/ToolsList.js';
import { SkillsList } from './views/SkillsList.js';
import { AgentsStatus } from './views/AgentsStatus.js';
import { ChannelsList } from './views/ChannelsList.js';
import { McpStatus } from './views/McpStatus.js';
import { ChatList } from './views/ChatList.js';
import { ModelMessage } from './messages/ModelMessage.js';
@@ -135,9 +134,9 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
<InfoMessage
text={itemForDisplay.text}
secondaryText={itemForDisplay.secondaryText}
source={itemForDisplay.source}
icon={itemForDisplay.icon}
color={itemForDisplay.color}
marginTop={itemForDisplay.marginTop}
marginBottom={itemForDisplay.marginBottom}
/>
)}
@@ -240,9 +239,6 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
terminalWidth={terminalWidth}
/>
)}
{itemForDisplay.type === 'channels_list' && (
<ChannelsList channels={itemForDisplay.channels} />
)}
{itemForDisplay.type === 'mcp_status' && (
<McpStatus {...itemForDisplay} serverStatus={getMCPServerStatus} />
)}
@@ -168,13 +168,6 @@ exports[`InputPrompt > mouse interaction > should toggle paste expansion on doub
"
`;
exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 4`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> [Pasted Text: 10 lines]
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
"
`;
exports[`InputPrompt > multiline rendering > should correctly render multiline input including blank lines 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
│ > hello │
@@ -12,18 +12,18 @@ import { RenderInline } from '../../utils/InlineMarkdownRenderer.js';
interface InfoMessageProps {
text: string;
secondaryText?: string;
source?: string;
icon?: string;
color?: string;
marginTop?: number;
marginBottom?: number;
}
export const InfoMessage: React.FC<InfoMessageProps> = ({
text,
secondaryText,
source,
icon,
color,
marginTop,
marginBottom,
}) => {
color ??= theme.status.warning;
@@ -31,11 +31,7 @@ export const InfoMessage: React.FC<InfoMessageProps> = ({
const prefixWidth = prefix.length;
return (
<Box
flexDirection="row"
marginTop={marginTop ?? 1}
marginBottom={marginBottom ?? 0}
>
<Box flexDirection="row" marginTop={1} marginBottom={marginBottom ?? 0}>
<Box width={prefixWidth}>
<Text color={color}>{prefix}</Text>
</Box>
@@ -46,6 +42,9 @@ export const InfoMessage: React.FC<InfoMessageProps> = ({
{index === text.split('\n').length - 1 && secondaryText && (
<Text color={theme.text.secondary}> {secondaryText}</Text>
)}
{index === text.split('\n').length - 1 && source && (
<Text color={theme.text.secondary}> [{source}]</Text>
)}
</Text>
))}
</Box>
@@ -1,40 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { ChannelsList } from './ChannelsList.js';
import { type ChannelInfo } from '../../types.js';
import { renderWithProviders } from '../../../test-utils/render.js';
const mockChannels: ChannelInfo[] = [
{
name: 'telegram',
displayName: 'Telegram',
supportsReply: true,
},
{
name: 'minimal-channel',
supportsReply: false,
},
];
describe('<ChannelsList />', () => {
it('renders correctly with active channels', async () => {
const { lastFrame, waitUntilReady } = await renderWithProviders(
<ChannelsList channels={mockChannels} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('renders correctly with no active channels', async () => {
const { lastFrame, waitUntilReady } = await renderWithProviders(
<ChannelsList channels={[]} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
});
@@ -1,64 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { theme } from '../../semantic-colors.js';
import { RenderInline } from '../../utils/InlineMarkdownRenderer.js';
import type { ChannelInfo } from '../../types.js';
interface ChannelsListProps {
channels: readonly ChannelInfo[];
}
export const ChannelsList: React.FC<ChannelsListProps> = ({ channels }) => {
if (channels.length === 0) {
return (
<Box flexDirection="column" marginBottom={1}>
<Text color={theme.text.primary}>No active channels.</Text>
<Text color={theme.text.secondary}>
<RenderInline
text="Use `--channels <name>` to listen for channel messages from MCP servers."
defaultColor={theme.text.secondary}
/>
</Text>
</Box>
);
}
return (
<Box flexDirection="column" marginBottom={1}>
<Text bold color={theme.text.primary}>
Active channels:
</Text>
<Box height={1} />
<Box flexDirection="column">
{channels.map((channel) => (
<Box key={channel.name} flexDirection="row">
<Text color={theme.text.primary}>{' '}- </Text>
<Box flexDirection="column">
<Text bold color={theme.text.accent}>
{channel.displayName || channel.name} ({channel.name})
</Text>
<Text color={theme.text.secondary}>
Direction:{' '}
<Text
color={
channel.supportsReply
? theme.status.success
: theme.text.secondary
}
>
{channel.supportsReply ? 'two-way' : 'one-way'}
</Text>
</Text>
</Box>
</Box>
))}
</Box>
</Box>
);
};
@@ -1,17 +0,0 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<ChannelsList /> > renders correctly with active channels 1`] = `
"Active channels:
- Telegram (telegram)
Direction: two-way
- minimal-channel (minimal-channel)
Direction: one-way
"
`;
exports[`<ChannelsList /> > renders correctly with no active channels 1`] = `
"No active channels.
Use --channels <name> to listen for channel messages from MCP servers.
"
`;
+10 -4
View File
@@ -13,7 +13,7 @@ import {
type MockedFunction,
} from 'vitest';
import { renderHook } from '../../test-utils/render.js';
import { useBanner } from './useBanner.js';
import { useBanner, _clearSessionBannersForTest } from './useBanner.js';
import { persistentState } from '../../utils/persistentState.js';
import crypto from 'node:crypto';
@@ -56,6 +56,7 @@ describe('useBanner', () => {
beforeEach(() => {
vi.resetAllMocks();
_clearSessionBannersForTest();
// Default persistentState behavior: return empty object (no counts)
mockedPersistentStateGet.mockReturnValue({});
@@ -101,13 +102,18 @@ describe('useBanner', () => {
);
});
it('should NOT increment count if warning text is shown instead', async () => {
it('should increment count if warning text is shown instead', async () => {
const data = { defaultText: 'Standard', warningText: 'Warning' };
await renderHook(() => useBanner(data));
// Since warning text takes precedence, default banner logic (and increment) is skipped
expect(mockedPersistentStateSet).not.toHaveBeenCalled();
// Warning text now also gets counted
expect(mockedPersistentStateSet).toHaveBeenCalledWith(
'defaultBannerShownCount',
{
[crypto.createHash('sha256').update(data.warningText).digest('hex')]: 1,
},
);
});
it('should handle newline replacements', async () => {
+20 -11
View File
@@ -4,12 +4,21 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useEffect, useRef } from 'react';
import { useState, useEffect } from 'react';
import { persistentState } from '../../utils/persistentState.js';
import crypto from 'node:crypto';
const DEFAULT_MAX_BANNER_SHOWN_COUNT = 5;
// Track banners incremented during this session to prevent multiple increments
// on React unmounts/remounts
const sessionIncrementedBanners = new Set<string>();
// For testing purposes
export function _clearSessionBannersForTest() {
sessionIncrementedBanners.clear();
}
interface BannerData {
defaultText: string;
warningText: string;
@@ -22,25 +31,25 @@ export function useBanner(bannerData: BannerData) {
() => persistentState.get('defaultBannerShownCount') || {},
);
const activeText = warningText ? warningText : defaultText;
const hashedText = crypto
.createHash('sha256')
.update(defaultText)
.update(activeText)
.digest('hex');
const currentBannerCount = bannerCounts[hashedText] || 0;
const showDefaultBanner =
warningText === '' && currentBannerCount < DEFAULT_MAX_BANNER_SHOWN_COUNT;
const showBanner =
activeText !== '' && currentBannerCount < DEFAULT_MAX_BANNER_SHOWN_COUNT;
const rawBannerText = showDefaultBanner ? defaultText : warningText;
const rawBannerText = showBanner ? activeText : '';
const bannerText = rawBannerText.replace(/\\n/g, '\n');
const lastIncrementedKey = useRef<string | null>(null);
useEffect(() => {
if (showDefaultBanner && defaultText) {
if (lastIncrementedKey.current !== defaultText) {
lastIncrementedKey.current = defaultText;
if (showBanner && activeText) {
if (!sessionIncrementedBanners.has(activeText)) {
sessionIncrementedBanners.add(activeText);
const allCounts = persistentState.get('defaultBannerShownCount') || {};
const current = allCounts[hashedText] || 0;
@@ -51,7 +60,7 @@ export function useBanner(bannerData: BannerData) {
});
}
}
}, [showDefaultBanner, defaultText, hashedText]);
}, [showBanner, activeText, hashedText]);
return {
bannerText,
@@ -66,6 +66,7 @@ import { MessageType, StreamingState } from '../types.js';
import type { LoadedSettings } from '../../config/settings.js';
import { findLastSafeSplitPoint } from '../utils/markdownUtilities.js';
import { theme } from '../semantic-colors.js';
import { createMockSettings } from '../../test-utils/mockConfig.js';
// --- MOCKS ---
const mockSendMessageStream = vi
@@ -4240,4 +4241,100 @@ describe('useGeminiStream', () => {
});
expect(spanMetadata.input).toBe('telemetry test query');
});
describe('topicUpdateNarration blocking', () => {
it('should block text updates while streaming and show them at the end if no tools are called', async () => {
const settings = createMockSettings({
merged: {
experimental: { topicUpdateNarration: true },
ui: { compactToolOutput: true },
},
});
mockSendMessageStream.mockReturnValue(
(async function* () {
yield { type: ServerGeminiEventType.Content, value: 'Hello ' };
yield { type: ServerGeminiEventType.Content, value: 'world!' };
})(),
);
const { result } = await renderTestHook([], undefined, settings);
await act(async () => {
await result.current.submitQuery('Hi');
});
// During streaming, addItem should NOT have been called with 'gemini' type.
// However, it IS called at the end of the turn because there are no tool calls.
expect(mockAddItem).toHaveBeenCalledWith(
expect.objectContaining({ type: 'gemini', text: 'Hello world!' }),
expect.any(Number),
);
});
it('should discard text updates if tool calls are present in the turn', async () => {
const settings = createMockSettings({
merged: {
experimental: { topicUpdateNarration: true },
ui: { compactToolOutput: true },
},
});
mockSendMessageStream.mockReturnValue(
(async function* () {
yield {
type: ServerGeminiEventType.Content,
value: 'I will call a tool.',
};
yield {
type: ServerGeminiEventType.ToolCallRequest,
value: { callId: '1', name: 'some_tool', args: {} },
};
})(),
);
const { result } = await renderTestHook([], undefined, settings);
await act(async () => {
await result.current.submitQuery('Hi');
});
// addItem should NOT have been called with 'gemini' type because there was a tool call.
expect(mockAddItem).not.toHaveBeenCalledWith(
expect.objectContaining({ type: 'gemini' }),
expect.any(Number),
);
});
it('should block thinking history items when narration is enabled', async () => {
const settings = createMockSettings({
merged: {
experimental: { topicUpdateNarration: true },
ui: { inlineThinkingMode: 'full' },
},
});
mockSendMessageStream.mockReturnValue(
(async function* () {
yield {
type: ServerGeminiEventType.Thought,
value: { thought: 'I am thinking...' },
};
yield { type: ServerGeminiEventType.Content, value: 'Final answer' };
})(),
);
const { result } = await renderTestHook([], undefined, settings);
await act(async () => {
await result.current.submitQuery('Hi');
});
// addItem should NOT have been called with 'thinking' type.
expect(mockAddItem).not.toHaveBeenCalledWith(
expect.objectContaining({ type: 'thinking' }),
expect.any(Number),
);
});
});
});
+39 -3
View File
@@ -238,6 +238,8 @@ export const useGeminiStream = (
null,
);
const isLowErrorVerbosity = settings.merged.ui?.errorVerbosity !== 'full';
const topicUpdateNarrationEnabled =
settings.merged.experimental?.topicUpdateNarration === true;
const suppressedToolErrorCountRef = useRef(0);
const suppressedToolErrorNoteShownRef = useRef(false);
const lowVerbosityFailureNoteShownRef = useRef(false);
@@ -1082,9 +1084,24 @@ export const useGeminiStream = (
if (pendingHistoryItemRef.current) {
addItem(pendingHistoryItemRef.current, userMessageTimestamp);
}
// When narration is enabled, we block all text updates during the turn.
// The text is accumulated and only shown at the end of the turn if
// no tool calls were made.
if (topicUpdateNarrationEnabled) {
setPendingHistoryItem(null);
return newGeminiMessageBuffer;
}
setPendingHistoryItem({ type: 'gemini', text: '' });
newGeminiMessageBuffer = eventValue;
}
// When narration is enabled, skip updating the UI with incremental text.
if (topicUpdateNarrationEnabled) {
return newGeminiMessageBuffer;
}
// Split large messages for better rendering performance. Ideally,
// we should maximize the amount of output sent to <Static />.
const splitPoint = findLastSafeSplitPoint(newGeminiMessageBuffer);
@@ -1123,21 +1140,31 @@ export const useGeminiStream = (
}
return newGeminiMessageBuffer;
},
[addItem, pendingHistoryItemRef, setPendingHistoryItem],
[
addItem,
pendingHistoryItemRef,
setPendingHistoryItem,
topicUpdateNarrationEnabled,
],
);
const handleThoughtEvent = useCallback(
(eventValue: ThoughtSummary, _userMessageTimestamp: number) => {
setThought(eventValue);
if (getInlineThinkingMode(settings) === 'full') {
// Block thinking history items when narration is enabled to avoid
// UI flickering and provide a cleaner experience.
if (
!topicUpdateNarrationEnabled &&
getInlineThinkingMode(settings) === 'full'
) {
addItem({
type: 'thinking',
thought: eventValue,
} as HistoryItemThinking);
}
},
[addItem, settings, setThought],
[addItem, settings, setThought, topicUpdateNarrationEnabled],
);
const handleUserCancelledEvent = useCallback(
@@ -1545,6 +1572,14 @@ export const useGeminiStream = (
setPendingHistoryItem(null);
}
await scheduleToolCalls(toolCallRequests, signal);
} else if (
topicUpdateNarrationEnabled &&
geminiMessageBuffer.length > 0
) {
// When narration is enabled, we only show the final text response
// if no tools were called in the current turn. This hides intermediate
// narration during multi-turn orchestration.
setPendingHistoryItem({ type: 'gemini', text: geminiMessageBuffer });
}
return StreamProcessingStatus.Completed;
},
@@ -1567,6 +1602,7 @@ export const useGeminiStream = (
pendingHistoryItemRef,
setPendingHistoryItem,
setThought,
topicUpdateNarrationEnabled,
],
);
const submitQuery = useCallback(
+1 -14
View File
@@ -174,9 +174,9 @@ export type HistoryItemInfo = HistoryItemBase & {
type: 'info';
text: string;
secondaryText?: string;
source?: string;
icon?: string;
color?: string;
marginTop?: number;
marginBottom?: number;
};
@@ -322,17 +322,6 @@ export type AgentDefinitionJson = Pick<
'name' | 'displayName' | 'description' | 'kind'
>;
export interface ChannelInfo {
name: string;
displayName?: string;
supportsReply: boolean;
}
export type HistoryItemChannelsList = HistoryItemBase & {
type: 'channels_list';
channels: ChannelInfo[];
};
export type HistoryItemAgentsList = HistoryItemBase & {
type: 'agents_list';
agents: AgentDefinitionJson[];
@@ -412,7 +401,6 @@ export type HistoryItemWithoutId =
| HistoryItemToolsList
| HistoryItemSkillsList
| HistoryItemAgentsList
| HistoryItemChannelsList
| HistoryItemMcpStatus
| HistoryItemChatList
| HistoryItemThinking
@@ -439,7 +427,6 @@ export enum MessageType {
TOOLS_LIST = 'tools_list',
SKILLS_LIST = 'skills_list',
AGENTS_LIST = 'agents_list',
CHANNELS_LIST = 'channels_list',
MCP_STATUS = 'mcp_status',
CHAT_LIST = 'chat_list',
HINT = 'hint',
+65 -81
View File
@@ -26,66 +26,49 @@ describe('skillUtils', () => {
vi.unstubAllEnvs();
});
const itif = (condition: boolean) => (condition ? it : it.skip);
describe('linkSkill', () => {
// TODO: issue 19388 - Enable linkSkill tests on Windows
itif(process.platform !== 'win32')(
'should successfully link from a local directory',
async () => {
// Create a mock skill directory
const mockSkillSourceDir = path.join(tempDir, 'mock-skill-source');
const skillSubDir = path.join(mockSkillSourceDir, 'test-skill');
await fs.mkdir(skillSubDir, { recursive: true });
await fs.writeFile(
path.join(skillSubDir, 'SKILL.md'),
'---\nname: test-skill\ndescription: test\n---\nbody',
);
it('should successfully link from a local directory', async () => {
// Create a mock skill directory
const mockSkillSourceDir = path.join(tempDir, 'mock-skill-source');
const skillSubDir = path.join(mockSkillSourceDir, 'test-skill');
await fs.mkdir(skillSubDir, { recursive: true });
await fs.writeFile(
path.join(skillSubDir, 'SKILL.md'),
'---\nname: test-skill\ndescription: test\n---\nbody',
);
const skills = await linkSkill(
mockSkillSourceDir,
'workspace',
() => {},
);
expect(skills.length).toBe(1);
expect(skills[0].name).toBe('test-skill');
const skills = await linkSkill(mockSkillSourceDir, 'workspace', () => {});
expect(skills.length).toBe(1);
expect(skills[0].name).toBe('test-skill');
const linkedPath = path.join(tempDir, '.gemini/skills', 'test-skill');
const stats = await fs.lstat(linkedPath);
expect(stats.isSymbolicLink()).toBe(true);
const linkedPath = path.join(tempDir, '.gemini/skills', 'test-skill');
const stats = await fs.lstat(linkedPath);
expect(stats.isSymbolicLink()).toBe(true);
const linkTarget = await fs.readlink(linkedPath);
expect(path.resolve(linkTarget)).toBe(path.resolve(skillSubDir));
},
);
const linkTarget = await fs.readlink(linkedPath);
expect(path.resolve(linkTarget)).toBe(path.resolve(skillSubDir));
});
itif(process.platform !== 'win32')(
'should overwrite existing skill at destination',
async () => {
const mockSkillSourceDir = path.join(tempDir, 'mock-skill-source');
const skillSubDir = path.join(mockSkillSourceDir, 'test-skill');
await fs.mkdir(skillSubDir, { recursive: true });
await fs.writeFile(
path.join(skillSubDir, 'SKILL.md'),
'---\nname: test-skill\ndescription: test\n---\nbody',
);
it('should overwrite existing skill at destination', async () => {
const mockSkillSourceDir = path.join(tempDir, 'mock-skill-source');
const skillSubDir = path.join(mockSkillSourceDir, 'test-skill');
await fs.mkdir(skillSubDir, { recursive: true });
await fs.writeFile(
path.join(skillSubDir, 'SKILL.md'),
'---\nname: test-skill\ndescription: test\n---\nbody',
);
const targetDir = path.join(tempDir, '.gemini/skills');
await fs.mkdir(targetDir, { recursive: true });
const existingPath = path.join(targetDir, 'test-skill');
await fs.mkdir(existingPath);
const targetDir = path.join(tempDir, '.gemini/skills');
await fs.mkdir(targetDir, { recursive: true });
const existingPath = path.join(targetDir, 'test-skill');
await fs.mkdir(existingPath);
const skills = await linkSkill(
mockSkillSourceDir,
'workspace',
() => {},
);
expect(skills.length).toBe(1);
const skills = await linkSkill(mockSkillSourceDir, 'workspace', () => {});
expect(skills.length).toBe(1);
const stats = await fs.lstat(existingPath);
expect(stats.isSymbolicLink()).toBe(true);
},
);
const stats = await fs.lstat(existingPath);
expect(stats.isSymbolicLink()).toBe(true);
});
it('should abort linking if consent is rejected', async () => {
const mockSkillSourceDir = path.join(tempDir, 'mock-skill-source');
@@ -237,39 +220,40 @@ describe('skillUtils', () => {
expect(result).toBeNull();
});
itif(process.platform !== 'win32')(
'should successfully uninstall a skill even if its name was updated after linking',
async () => {
// 1. Create source skill
const sourceDir = path.join(tempDir, 'source-skill');
await fs.mkdir(sourceDir, { recursive: true });
const skillMdPath = path.join(sourceDir, 'SKILL.md');
await fs.writeFile(
skillMdPath,
'---\nname: original-name\ndescription: test\n---\nbody',
);
it('should successfully uninstall a skill even if its name was updated after linking', async () => {
// 1. Create source skill
const sourceDir = path.join(tempDir, 'source-skill');
await fs.mkdir(sourceDir, { recursive: true });
const skillMdPath = path.join(sourceDir, 'SKILL.md');
await fs.writeFile(
skillMdPath,
'---\nname: original-name\ndescription: test\n---\nbody',
);
// 2. Link it
const skillsDir = path.join(tempDir, '.gemini/skills');
await fs.mkdir(skillsDir, { recursive: true });
const destPath = path.join(skillsDir, 'original-name');
await fs.symlink(sourceDir, destPath, 'dir');
// 2. Link it
const skillsDir = path.join(tempDir, '.gemini/skills');
await fs.mkdir(skillsDir, { recursive: true });
const destPath = path.join(skillsDir, 'original-name');
await fs.symlink(
sourceDir,
destPath,
process.platform === 'win32' ? 'junction' : 'dir',
);
// 3. Update name in source
await fs.writeFile(
skillMdPath,
'---\nname: updated-name\ndescription: test\n---\nbody',
);
// 3. Update name in source
await fs.writeFile(
skillMdPath,
'---\nname: updated-name\ndescription: test\n---\nbody',
);
// 4. Uninstall by NEW name (this is the bug fix)
const result = await uninstallSkill('updated-name', 'user');
expect(result).not.toBeNull();
expect(result?.location).toBe(destPath);
// 4. Uninstall by NEW name (this is the bug fix)
const result = await uninstallSkill('updated-name', 'user');
expect(result).not.toBeNull();
expect(result?.location).toBe(destPath);
const exists = await fs.lstat(destPath).catch(() => null);
expect(exists).toBeNull();
},
);
const exists = await fs.lstat(destPath).catch(() => null);
expect(exists).toBeNull();
});
it('should successfully uninstall a skill by directory name if metadata is missing (fallback)', async () => {
const skillsDir = path.join(tempDir, '.gemini/skills');
+7 -1
View File
@@ -248,7 +248,13 @@ export async function linkSkill(
await fs.rm(destPath, { recursive: true, force: true });
}
await fs.symlink(skillSourceDir, destPath, 'dir');
// Use 'junction' on Windows to avoid EPERM errors — junctions don't
// require elevated privileges or Developer Mode (fixes #24816)
await fs.symlink(
skillSourceDir,
destPath,
process.platform === 'win32' ? 'junction' : 'dir',
);
linkedSkills.push({ name: skillName, location: destPath });
}
-52
View File
@@ -1,52 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Payload for the 'channel-message' event, emitted when an MCP server
* declaring the `gemini/channel` experimental capability sends a
* `notifications/gemini/channel` notification.
*
* XML formatting and escaping happens at the trust boundary in mcp-client.ts,
* so `content` is a pre-formatted, escaped `<channel>` XML string ready for
* injection into the conversation.
*/
export interface ChannelMessagePayload {
/** Name of the MCP server acting as the channel. */
channelName: string;
/** Pre-formatted, escaped `<channel>` XML string. */
content: string;
}
/**
* Describes the channel capability advertised by an MCP server via
* `capabilities.experimental['gemini/channel']`.
*/
export interface ChannelCapability {
/** Whether this channel exposes MCP tools for replying (two-way). */
supportsReply: boolean;
/** Human-readable name for the channel (defaults to MCP server name). */
displayName?: string;
}
/**
* Simple registry tracking which MCP servers have declared channel capability.
* Populated by McpClient.registerNotificationHandlers().
*/
export const activeChannels = new Map<string, ChannelCapability>();
/**
* Returns the names of all MCP servers currently registered as channels.
*/
export function getActiveChannelNames(): string[] {
return Array.from(activeChannels.keys());
}
/**
* Removes a channel entry when its MCP server disconnects.
*/
export function removeChannel(name: string): void {
activeChannels.delete(name);
}
+1 -30
View File
@@ -69,7 +69,6 @@ import {
type TelemetryTarget,
} from '../telemetry/index.js';
import { coreEvents, CoreEvent } from '../utils/events.js';
import { activeChannels } from '../channels/types.js';
import { tokenLimit } from '../core/tokenLimits.js';
import {
DEFAULT_GEMINI_EMBEDDING_MODEL,
@@ -726,7 +725,6 @@ export interface ConfigParameters {
billing?: {
overageStrategy?: OverageStrategy;
};
channels?: string[];
}
export class Config implements McpContext, AgentLoopContext {
@@ -910,8 +908,8 @@ export class Config implements McpContext, AgentLoopContext {
private readonly acceptRawOutputRisk: boolean;
private readonly dynamicModelConfiguration: boolean;
private pendingIncludeDirectories: string[];
private readonly enableHooks: boolean;
private readonly enableHooksUI: boolean;
private readonly enableHooks: boolean;
private hooks: { [K in HookEventName]?: HookDefinition[] } | undefined;
private projectHooks:
@@ -940,8 +938,6 @@ export class Config implements McpContext, AgentLoopContext {
private readonly skillsSupport: boolean;
private disabledSkills: string[];
private readonly adminSkillsEnabled: boolean;
private readonly channels: string[];
private readonly experimentalJitContext: boolean;
private readonly experimentalMemoryManager: boolean;
private readonly memoryBoundaryMarkers: readonly string[];
@@ -1277,7 +1273,6 @@ export class Config implements McpContext, AgentLoopContext {
this.fileExclusions = new FileExclusions(this);
this.eventEmitter = params.eventEmitter;
this.enableConseca = params.enableConseca ?? false;
this.channels = params.channels ?? [];
// Initialize Safety Infrastructure
const contextBuilder = new ContextBuilder(this);
@@ -1461,26 +1456,6 @@ export class Config implements McpContext, AgentLoopContext {
debugLogger.error('Error initializing MCP clients:', result.reason);
}
}
// Report channel status after all MCP servers have initialized.
if (this.channels.length > 0) {
const active = this.channels.filter((name) => activeChannels.has(name));
if (active.length > 0) {
coreEvents.emitFeedback(
'info',
`Channels listening for messages: ${active.join(', ')}\nOnly use channels you trust — messages are injected into the conversation.`,
undefined,
{ style: 'channel' },
);
}
for (const name of this.channels) {
if (!activeChannels.has(name)) {
coreEvents.emitFeedback(
'warning',
`Channel "${name}" was requested but the MCP server did not declare channel capability.`,
);
}
}
}
});
if (!this.interactive || this.acpMode) {
@@ -2263,10 +2238,6 @@ export class Config implements McpContext, AgentLoopContext {
return this.mcpEnabled;
}
getChannels(): string[] {
return this.channels;
}
getMcpEnablementCallbacks(): McpEnablementCallbacks | undefined {
return this.mcpEnablementCallbacks;
}
+15
View File
@@ -597,6 +597,21 @@ export class GeminiChat {
);
}
if (beforeModelResult.modifiedModel) {
modelToUse = resolveModel(
beforeModelResult.modifiedModel,
useGemini3_1,
useGemini3_1FlashLite,
false,
hasAccessToPreview,
this.context.config,
);
lastModelToUse = modelToUse;
// Re-evaluate contentsToUse based on the new model's feature support
contentsToUse = supportsModernFeatures(modelToUse)
? [...contentsForPreviewModel]
: [...requestContents];
}
if (beforeModelResult.modifiedConfig) {
Object.assign(config, beforeModelResult.modifiedConfig);
}
@@ -458,6 +458,15 @@ export class HookEventHandler {
);
logHookCall(this.context.config, hookCallEvent);
// Emit structured system message event for UI display
if (result.output?.systemMessage && result.outputFormat === 'json') {
coreEvents.emitHookSystemMessage({
hookName,
eventName,
message: result.output.systemMessage,
});
}
}
// Log individual errors
+15 -2
View File
@@ -204,7 +204,11 @@ describe('HookRunner', () => {
};
it('should execute command hook successfully', async () => {
const mockOutput = { decision: 'allow', reason: 'All good' };
const mockOutput = {
decision: 'allow',
reason: 'All good',
format: 'json',
};
// Mock successful execution
mockSpawn.mockStdoutOn.mockImplementation(
@@ -623,6 +627,7 @@ describe('HookRunner', () => {
hookSpecificOutput: {
additionalContext: 'Context from hook 1',
},
format: 'json',
};
let hookCallCount = 0;
@@ -803,6 +808,7 @@ describe('HookRunner', () => {
expect(result.success).toBe(true);
expect(result.exitCode).toBe(0);
// Should convert plain text to structured output
expect(result.outputFormat).toBe('text');
expect(result.output).toEqual({
decision: 'allow',
systemMessage: invalidJson,
@@ -835,6 +841,7 @@ describe('HookRunner', () => {
);
expect(result.success).toBe(true);
expect(result.outputFormat).toBe('text');
expect(result.output).toEqual({
decision: 'allow',
systemMessage: malformedJson,
@@ -868,6 +875,7 @@ describe('HookRunner', () => {
expect(result.success).toBe(false);
expect(result.exitCode).toBe(1);
expect(result.outputFormat).toBe('text');
expect(result.output).toEqual({
decision: 'allow',
systemMessage: `Warning: ${invalidJson}`,
@@ -901,6 +909,7 @@ describe('HookRunner', () => {
expect(result.success).toBe(false);
expect(result.exitCode).toBe(2);
expect(result.outputFormat).toBe('text');
expect(result.output).toEqual({
decision: 'deny',
reason: invalidJson,
@@ -936,7 +945,11 @@ describe('HookRunner', () => {
});
it('should handle double-encoded JSON string', async () => {
const mockOutput = { decision: 'allow', reason: 'All good' };
const mockOutput = {
decision: 'allow',
reason: 'All good',
format: 'json',
};
const doubleEncodedJson = JSON.stringify(JSON.stringify(mockOutput));
mockSpawn.mockStdoutOn.mockImplementation(
+5 -1
View File
@@ -447,6 +447,7 @@ export class HookRunner {
// Parse output
let output: HookOutput | undefined;
let outputFormat: 'json' | 'text' | undefined;
const textToParse = stdout.trim() || stderr.trim();
if (textToParse) {
@@ -460,6 +461,7 @@ export class HookRunner {
if (parsed && typeof parsed === 'object') {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
output = parsed as HookOutput;
outputFormat = 'json';
}
} catch {
// Not JSON, convert plain text to structured output
@@ -467,6 +469,7 @@ export class HookRunner {
textToParse,
exitCode || EXIT_CODE_SUCCESS,
);
outputFormat = 'text';
}
}
@@ -475,6 +478,7 @@ export class HookRunner {
eventName,
success: exitCode === EXIT_CODE_SUCCESS,
output,
outputFormat,
stdout,
stderr,
exitCode: exitCode || EXIT_CODE_SUCCESS,
@@ -523,7 +527,7 @@ export class HookRunner {
exitCode: number,
): HookOutput {
if (exitCode === EXIT_CODE_SUCCESS) {
// Success - treat as system message or additional context
// Success
return {
decision: 'allow',
systemMessage: text,
+3
View File
@@ -48,6 +48,8 @@ export interface BeforeModelHookResult {
reason?: string;
/** Synthetic response to return instead of calling the model (if blocked) */
syntheticResponse?: GenerateContentResponse;
/** Modified model override (if not blocked) */
modifiedModel?: string;
/** Modified config (if not blocked) */
modifiedConfig?: GenerateContentConfig;
/** Modified contents (if not blocked) */
@@ -292,6 +294,7 @@ export class HookSystem {
beforeModelOutput.applyLLMRequestModifications(llmRequest);
return {
blocked: false,
modifiedModel: modifiedRequest?.model,
modifiedConfig: modifiedRequest?.config,
modifiedContents: modifiedRequest?.contents,
};
+2
View File
@@ -734,6 +734,8 @@ export interface HookExecutionResult {
exitCode?: number;
duration: number;
error?: Error;
/** The format of the output provided by the hook */
outputFormat?: 'json' | 'text';
}
/**
-1
View File
@@ -124,7 +124,6 @@ export * from './utils/checkpointUtils.js';
export * from './utils/secure-browser-launcher.js';
export * from './utils/apiConversionUtils.js';
export * from './utils/channel.js';
export * from './channels/types.js';
export * from './utils/constants.js';
export * from './utils/sessionUtils.js';
export * from './utils/cache.js';
@@ -27,11 +27,16 @@ import {
verifySandboxOverrides,
getCommandName,
} from '../utils/commandUtils.js';
import { assertValidPathString } from '../../utils/paths.js';
import {
isKnownSafeCommand,
isDangerousCommand,
} from '../utils/commandSafety.js';
import { parsePosixSandboxDenials } from '../utils/sandboxDenialUtils.js';
import {
parsePosixSandboxDenials,
createSandboxDenialCache,
type SandboxDenialCache,
} from '../utils/sandboxDenialUtils.js';
import { handleReadWriteCommands } from '../utils/sandboxReadWriteUtils.js';
import { buildBwrapArgs } from './bwrapArgsBuilder.js';
@@ -108,6 +113,7 @@ function getSeccompBpfPath(): string {
* Ensures a file or directory exists.
*/
function touch(filePath: string, isDirectory: boolean) {
assertValidPathString(filePath);
try {
// If it exists (even as a broken symlink), do nothing
if (fs.lstatSync(filePath)) return;
@@ -129,6 +135,7 @@ function touch(filePath: string, isDirectory: boolean) {
export class LinuxSandboxManager implements SandboxManager {
private static maskFilePath: string | undefined;
private readonly denialCache: SandboxDenialCache = createSandboxDenialCache();
constructor(private readonly options: GlobalSandboxOptions) {}
@@ -141,7 +148,7 @@ export class LinuxSandboxManager implements SandboxManager {
}
parseDenials(result: ShellExecutionResult): ParsedSandboxDenial | undefined {
return parsePosixSandboxDenials(result);
return parsePosixSandboxDenials(result, this.denialCache);
}
getWorkspace(): string {
@@ -32,10 +32,16 @@ import {
getCommandName as getFullCommandName,
isStrictlyApproved,
} from '../utils/commandUtils.js';
import { parsePosixSandboxDenials } from '../utils/sandboxDenialUtils.js';
import {
parsePosixSandboxDenials,
createSandboxDenialCache,
type SandboxDenialCache,
} from '../utils/sandboxDenialUtils.js';
import { handleReadWriteCommands } from '../utils/sandboxReadWriteUtils.js';
export class MacOsSandboxManager implements SandboxManager {
private readonly denialCache: SandboxDenialCache = createSandboxDenialCache();
constructor(private readonly options: GlobalSandboxOptions) {}
isKnownSafeCommand(args: string[]): boolean {
@@ -52,7 +58,7 @@ export class MacOsSandboxManager implements SandboxManager {
}
parseDenials(result: ShellExecutionResult): ParsedSandboxDenial | undefined {
return parsePosixSandboxDenials(result);
return parsePosixSandboxDenials(result, this.denialCache);
}
getWorkspace(): string {
@@ -0,0 +1,52 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { tryRealpath } from './fsUtils.js';
describe('fsUtils', () => {
let tempDir: string;
let realTempDir: string;
beforeAll(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'fs-utils-test-'));
realTempDir = fs.realpathSync(tempDir);
});
afterAll(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
describe('tryRealpath', () => {
it('should throw error for paths with null bytes', () => {
expect(() => tryRealpath(path.join(tempDir, 'foo\0bar'))).toThrow(
'Invalid path',
);
});
it('should resolve existing paths', () => {
const resolved = tryRealpath(tempDir);
expect(resolved).toBe(realTempDir);
});
it('should handle non-existent paths by resolving parent', () => {
const nonExistentPath = path.join(tempDir, 'non-existent-file-12345');
const expected = path.join(realTempDir, 'non-existent-file-12345');
const resolved = tryRealpath(nonExistentPath);
expect(resolved).toBe(expected);
});
it('should handle nested non-existent paths', () => {
const nonExistentPath = path.join(tempDir, 'dir1', 'dir2', 'file');
const expected = path.join(realTempDir, 'dir1', 'dir2', 'file');
const resolved = tryRealpath(nonExistentPath);
expect(resolved).toBe(expected);
});
});
});
@@ -6,12 +6,14 @@
import fs from 'node:fs';
import path from 'node:path';
import { assertValidPathString } from '../../utils/paths.js';
export function isErrnoException(e: unknown): e is NodeJS.ErrnoException {
return e instanceof Error && 'code' in e;
}
export function tryRealpath(p: string): string {
assertValidPathString(p);
try {
return fs.realpathSync(p);
} catch (e) {
@@ -5,7 +5,10 @@
*/
import { describe, it, expect } from 'vitest';
import { parsePosixSandboxDenials } from './sandboxDenialUtils.js';
import {
parsePosixSandboxDenials,
createSandboxDenialCache,
} from './sandboxDenialUtils.js';
import type { ShellExecutionResult } from '../../services/shellExecutionService.js';
describe('parsePosixSandboxDenials', () => {
@@ -116,4 +119,109 @@ EACCES: permission denied, mkdir '/Users/galzahavi/.pnpm-store/v3'
expect(parsed).toBeDefined();
expect(parsed?.filePaths).toContain('/Users/galzahavi/.pnpm-store/v3');
});
it('should detect Python PermissionError and extract path accurately', () => {
const output = `Caught exception: [Errno 13] Permission denied: '/etc/test_sandbox_denial'
Traceback (most recent call last):
File "/usr/local/google/home/davidapierce/gemini-cli/repro_sandbox.py", line 9, in <module>
raise e
File "/usr/local/google/home/davidapierce/gemini-cli/repro_sandbox.py", line 5, in <module>
with open('/etc/test_sandbox_denial', 'w') as f:
~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
PermissionError: [Errno 13] Permission denied: '/etc/test_sandbox_denial'`;
const parsed = parsePosixSandboxDenials({
output,
exitCode: 1,
error: null,
} as unknown as ShellExecutionResult);
expect(parsed?.filePaths).toEqual(['/etc/test_sandbox_denial']);
});
it('should detect new keywords like "access denied" and "forbidden"', () => {
const parsed1 = parsePosixSandboxDenials({
output: 'Access denied to /var/log/syslog',
exitCode: 1,
error: null,
} as unknown as ShellExecutionResult);
expect(parsed1?.filePaths).toContain('/var/log/syslog');
const parsed2 = parsePosixSandboxDenials({
output: 'Forbidden: access to /root/secret is not allowed',
exitCode: 1,
error: null,
} as unknown as ShellExecutionResult);
expect(parsed2?.filePaths).toContain('/root/secret');
});
it('should detect read-only file system error', () => {
const parsed = parsePosixSandboxDenials({
output: 'rm: cannot remove /mnt/usb/test: Read-only file system',
exitCode: 1,
error: null,
} as unknown as ShellExecutionResult);
expect(parsed?.filePaths).toContain('/mnt/usb/test');
});
it('should reject paths with directory traversal', () => {
const output = 'ls: /etc/shadow/../../etc/passwd: Operation not permitted';
const parsed = parsePosixSandboxDenials({
output,
} as unknown as ShellExecutionResult);
expect(parsed?.filePaths || []).not.toContain(
'/etc/shadow/../../etc/passwd',
);
});
it('should reject home-relative paths with directory traversal', () => {
const output = "Operation not permitted, open '~/../../etc/shadow'";
const parsed = parsePosixSandboxDenials({
output,
} as unknown as ShellExecutionResult);
expect(parsed?.filePaths || []).not.toContain('~/../../etc/shadow');
});
it('should reject paths with null bytes', () => {
const output = "Operation not permitted, open '/etc/passwd\0/foo'";
const parsed = parsePosixSandboxDenials({
output,
} as unknown as ShellExecutionResult);
expect(parsed?.filePaths || []).not.toContain('/etc/passwd\0/foo');
});
it('should reject paths with internal tildes', () => {
const output = "Operation not permitted, open '/home/user/~/config'";
const parsed = parsePosixSandboxDenials({
output,
} as unknown as ShellExecutionResult);
expect(parsed?.filePaths || []).not.toContain('/home/user/~/config');
});
it('should suppress redundant denials if cache is provided', () => {
const cache = createSandboxDenialCache();
const result = {
output: 'ls: /root: Operation not permitted',
} as unknown as ShellExecutionResult;
// First call: should process
const parsed1 = parsePosixSandboxDenials(result, cache);
expect(parsed1).toBeDefined();
// Second call: should be suppressed
const parsed2 = parsePosixSandboxDenials(result, cache);
expect(parsed2).toBeUndefined();
});
it('should not suppress denials if no cache is provided', () => {
const result = {
output: 'ls: /root: Operation not permitted',
} as unknown as ShellExecutionResult;
const parsed1 = parsePosixSandboxDenials(result);
expect(parsed1).toBeDefined();
const parsed2 = parsePosixSandboxDenials(result);
expect(parsed2).toBeDefined();
});
});
@@ -4,8 +4,58 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { LRUCache } from 'mnemonist';
import { type ParsedSandboxDenial } from '../../services/sandboxManager.js';
import type { ShellExecutionResult } from '../../services/shellExecutionService.js';
import { isValidPathString } from '../../utils/paths.js';
/**
* Type for the sandbox denial error cache.
* Stores normalized error output to prevent redundant processing.
*/
export type SandboxDenialCache = LRUCache<string, boolean>;
/**
* Creates a new sandbox denial cache with a standard LRU policy.
*/
export function createSandboxDenialCache(maxSize = 10): SandboxDenialCache {
return new LRUCache<string, boolean>(maxSize);
}
/**
* Sanitizes extracted paths to prevent path traversal vulnerabilities.
* Filters out paths containing '..' or null bytes.
*/
export function sanitizeExtractedPath(p: string): string | undefined {
if (!isValidPathString(p)) return undefined;
// Reject paths with directory traversal components
const parts = p.split(/[/\\]/);
if (parts.includes('..')) {
return undefined;
}
// Reject paths with internal tildes (tilde should only be at the beginning)
if (p.indexOf('~') > 0) {
return undefined;
}
// Basic normalization without resolving symlinks or accessing the file system
let normalized = p;
// Collapse multiple slashes
normalized = normalized.replace(/\/+/g, '/');
// Remove single dot segments
normalized = normalized.replace(/\/\.\//g, '/');
// Remove trailing slashes (unless it's exactly '/')
if (normalized.length > 1 && normalized.endsWith('/')) {
normalized = normalized.slice(0, -1);
}
return normalized;
}
/**
* Common POSIX-style sandbox denial detection.
@@ -13,10 +63,18 @@ import type { ShellExecutionResult } from '../../services/shellExecutionService.
*/
export function parsePosixSandboxDenials(
result: ShellExecutionResult,
cache?: SandboxDenialCache,
): ParsedSandboxDenial | undefined {
const output = result.output || '';
const errorOutput = result.error?.message;
const combined = (output + ' ' + (errorOutput || '')).toLowerCase();
const fullText = output + '\n' + (errorOutput || '');
const combined = fullText.toLowerCase();
// Cache by the first 200 characters of the error to handle variable data (timestamps, PIDs)
const cacheKey = combined.trim().slice(0, 200);
if (cacheKey && cache?.has(cacheKey)) {
return undefined;
}
const isFileDenial = [
'operation not permitted',
@@ -27,6 +85,12 @@ export function parsePosixSandboxDenials(
'should be read/write',
'sandbox_apply',
'sandbox: ',
'access denied',
'read-only file system',
'permissionerror',
'fs.permissiondenied',
'forbidden',
'system.unauthorizedaccessexception',
].some((keyword) => combined.includes(keyword));
const isNetworkDenial = [
@@ -46,6 +110,8 @@ export function parsePosixSandboxDenials(
'err_pnpm_fetch',
'err_pnpm_no_matching_version',
"syscall: 'listen'",
'socketexception',
'networkaccessdenied',
].some((keyword) => combined.includes(keyword));
if (!isFileDenial && !isNetworkDenial) {
@@ -57,27 +123,28 @@ export function parsePosixSandboxDenials(
// Extract denied paths (POSIX absolute paths or home-relative paths starting with ~)
const regexes = [
// format: /path: operation not permitted
/(?:^|\s)['"]?((?:\/|~)[\w.\-/:~]+)['"]?:\s*[Oo]peration not permitted/gi,
/(?:^|\s)['"]?((?:\/|~)(?:[\w.\-/:~]*[\w.\-/~])?)['"]?[\s:,'"[\]]*operation not permitted/gi,
// format: operation not permitted, open '/path'
/[Oo]peration not permitted,\s*open\s*['"]?((?:\/|~)[\w.\-/:~]+)['"]?/gi,
/operation not permitted[\s:,'"[\]]*open[\s:,'"[\]]*['"]?((?:\/|~)(?:[\w.\-/:~]*[\w.\-/~])?)['"]?/gi,
// format: permission denied, open '/path'
/[Pp]ermission denied,\s*open\s*['"]?((?:\/|~)[\w.\-/:~]+)['"]?/gi,
/permission denied[\s:,'"[\]]*open[\s:,'"[\]]*['"]?((?:\/|~)(?:[\w.\-/:~]*[\w.\-/~])?)['"]?/gi,
// format: npm error path /path or npm ERR! path /path
/npm\s+(?:error|ERR!)\s+path\s+((?:\/|~)[\w.\-/:~]+)/gi,
// format: EACCES: permission denied, mkdir '/path'
/EACCES:\s*permission denied,\s*\w+\s*['"]?((?:\/|~)[\w.\-/:~]+)['"]?/gi,
/npm[\s!]*[A-Za-z]*err[A-Za-z!]*[\s!]+path[\s!]*((?:\/|~)(?:[\w.\-/:~]*[\w.\-/~])?)/gi,
// format: eacces: permission denied, mkdir '/path'
/eacces[\s:,'"[\]]*permission denied[\s:,'"[\]]*\w+[\s:,'"[\]]*['"]?((?:\/|~)[\w.\-/:~]*[\w.\-/~])?/gi,
// format: PermissionError: [Errno 13] Permission denied: '/path'
/permissionerror[\s:,'"[\]]*(?:[^'"]*)['"]((?:\/|~)[\w.\-/:~]*[\w.\-/~])?['"]/gi,
// format: FileNotFoundError: [Errno 2] No such file or directory: '/path' (sometimes returned in sandbox denials if directory is hidden)
/filenotfounderror[\s:,'"[\]]*(?:[^'"]*)['"]((?:\/|~)[\w.\-/:~]*[\w.\-/~])?['"]/gi,
// format: Error: EACCES: permission denied, open '/path'
/error[\s:,'"[\]]*eacces[\s:,'"[\]]*permission denied[\s:,'"[\]]*(?:[^'"]*)['"]((?:\/|~)[\w.\-/:~]*[\w.\-/~])?['"]/gi,
];
for (const regex of regexes) {
let match;
while ((match = regex.exec(output)) !== null) {
filePaths.add(match[1]);
}
if (errorOutput) {
regex.lastIndex = 0; // Reset for next use
while ((match = regex.exec(errorOutput)) !== null) {
filePaths.add(match[1]);
}
while ((match = regex.exec(fullText)) !== null) {
const sanitized = sanitizeExtractedPath(match[1]);
if (sanitized) filePaths.add(sanitized);
}
}
@@ -86,22 +153,16 @@ export function parsePosixSandboxDenials(
const fallbackRegex =
/(?:^|[\s"'[\]])(\/[a-zA-Z0-9_.-]+(?:\/[a-zA-Z0-9_.-]+)+)(?:$|[\s"'[\]:])/gi;
let m;
while ((m = fallbackRegex.exec(output)) !== null) {
const p = m[1];
if (p && !p.startsWith('/bin/') && !p.startsWith('/usr/bin/')) {
filePaths.add(p);
}
}
if (errorOutput) {
while ((m = fallbackRegex.exec(errorOutput)) !== null) {
const p = m[1];
if (p && !p.startsWith('/bin/') && !p.startsWith('/usr/bin/')) {
filePaths.add(p);
}
}
while ((m = fallbackRegex.exec(fullText)) !== null) {
const sanitized = sanitizeExtractedPath(m[1]);
if (sanitized) filePaths.add(sanitized);
}
}
if (cacheKey && cache) {
cache.set(cacheKey, true);
}
return {
network: isNetworkDenial || undefined,
filePaths: filePaths.size > 0 ? Array.from(filePaths) : undefined,
@@ -8,6 +8,7 @@ import {
type SandboxPermissions,
type SandboxRequest,
} from '../../services/sandboxManager.js';
import { isValidPathString } from '../../utils/paths.js';
/**
* Validates if the requested paths are within the allowed workspace or allowed paths.
@@ -18,6 +19,9 @@ function validatePaths(
allowedPaths: string[],
): boolean {
for (const p of paths) {
if (!isValidPathString(p)) {
return false; // Reject malicious paths
}
const resolvedPath = path.resolve(p);
const resolvedWorkspace = path.resolve(workspace);
const isInsideWorkspace =
@@ -35,7 +35,15 @@ import {
} from './commandSafety.js';
import { verifySandboxOverrides } from '../utils/commandUtils.js';
import { parseWindowsSandboxDenials } from './windowsSandboxDenialUtils.js';
import { isSubpath, resolveToRealPath } from '../../utils/paths.js';
import {
isSubpath,
resolveToRealPath,
assertValidPathString,
} from '../../utils/paths.js';
import {
type SandboxDenialCache,
createSandboxDenialCache,
} from '../utils/sandboxDenialUtils.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -54,6 +62,7 @@ export class WindowsSandboxManager implements SandboxManager {
private initialized = false;
private readonly allowedCache = new Set<string>();
private readonly deniedCache = new Set<string>();
private readonly denialCache: SandboxDenialCache = createSandboxDenialCache();
constructor(private readonly options: GlobalSandboxOptions) {
this.helperPath = path.resolve(__dirname, WindowsSandboxManager.HELPER_EXE);
@@ -73,7 +82,7 @@ export class WindowsSandboxManager implements SandboxManager {
}
parseDenials(result: ShellExecutionResult): ParsedSandboxDenial | undefined {
return parseWindowsSandboxDenials(result);
return parseWindowsSandboxDenials(result, this.denialCache);
}
getWorkspace(): string {
@@ -88,6 +97,7 @@ export class WindowsSandboxManager implements SandboxManager {
* Ensures a file or directory exists.
*/
private touch(filePath: string, isDirectory: boolean): void {
assertValidPathString(filePath);
try {
// If it exists (even as a broken symlink), do nothing
if (fs.lstatSync(filePath)) return;
@@ -6,6 +6,10 @@
import { type ParsedSandboxDenial } from '../../services/sandboxManager.js';
import type { ShellExecutionResult } from '../../services/shellExecutionService.js';
import {
type SandboxDenialCache,
sanitizeExtractedPath,
} from '../utils/sandboxDenialUtils.js';
/**
* Windows-specific sandbox denial detection.
@@ -13,10 +17,18 @@ import type { ShellExecutionResult } from '../../services/shellExecutionService.
*/
export function parseWindowsSandboxDenials(
result: ShellExecutionResult,
cache?: SandboxDenialCache,
): ParsedSandboxDenial | undefined {
const output = result.output || '';
const errorOutput = result.error?.message;
const combined = (output + ' ' + (errorOutput || '')).toLowerCase();
const fullText = output + '\n' + (errorOutput || '');
const combined = fullText.toLowerCase();
// Cache by the first 200 characters of the error to handle variable data (timestamps, PIDs)
const cacheKey = combined.trim().slice(0, 200);
if (cacheKey && cache?.has(cacheKey)) {
return undefined;
}
const isFileDenial = [
'access is denied',
@@ -46,30 +58,24 @@ export function parseWindowsSandboxDenials(
// 1. Quoted paths: 'C:\Foo Bar' or "C:\Foo Bar"
const quotedRegex = /['"]((?:\\\\(?:\?|\.)\\)?[a-zA-Z]:[\\/][^'"]+)['"]/g;
for (const match of output.matchAll(quotedRegex)) {
filePaths.add(match[1]);
}
if (errorOutput) {
for (const match of errorOutput.matchAll(quotedRegex)) {
filePaths.add(match[1]);
}
for (const match of fullText.matchAll(quotedRegex)) {
const sanitized = sanitizeExtractedPath(match[1]);
if (sanitized) filePaths.add(sanitized);
}
// 2. Unquoted paths or paths in PowerShell error format: PermissionDenied: (C:\path:String)
const generalRegex =
/(?:^|[\s(])((?:\\\\(?:\?|\.)\\)?[a-zA-Z]:[\\/][^"'\s()<>|?*]+)/g;
for (const match of output.matchAll(generalRegex)) {
for (const match of fullText.matchAll(generalRegex)) {
// Clean up trailing colon which might be part of the error message rather than the path
let p = match[1];
if (p.endsWith(':')) p = p.slice(0, -1);
filePaths.add(p);
const sanitized = sanitizeExtractedPath(p);
if (sanitized) filePaths.add(sanitized);
}
if (errorOutput) {
for (const match of errorOutput.matchAll(generalRegex)) {
let p = match[1];
if (p.endsWith(':')) p = p.slice(0, -1);
filePaths.add(p);
}
if (cacheKey && cache) {
cache.set(cacheKey, true);
}
return {
-137
View File
@@ -43,7 +43,6 @@ import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { coreEvents } from '../utils/events.js';
import { activeChannels } from '../channels/types.js';
import type { EnvironmentSanitizationConfig } from '../services/environmentSanitization.js';
interface TestableTransport {
@@ -64,7 +63,6 @@ const MOCK_CONTEXT_DEFAULT = {
emitMcpDiagnostic: vi.fn(),
setUserInteractedWithMcp: vi.fn(),
isTrustedFolder: vi.fn().mockReturnValue(true),
getChannels: vi.fn().mockReturnValue([]),
};
let MOCK_CONTEXT: McpContext = MOCK_CONTEXT_DEFAULT;
@@ -82,8 +80,6 @@ vi.mock('../utils/events.js', () => ({
coreEvents: {
emitFeedback: vi.fn(),
emitConsoleLog: vi.fn(),
emitChannelMessage: vi.fn(),
emitMcpProgress: vi.fn(),
},
}));
@@ -92,13 +88,11 @@ describe('mcp-client', () => {
let testWorkspace: string;
beforeEach(() => {
activeChannels.clear();
MOCK_CONTEXT = {
sanitizationConfig: EMPTY_CONFIG,
emitMcpDiagnostic: vi.fn(),
setUserInteractedWithMcp: vi.fn(),
isTrustedFolder: vi.fn().mockReturnValue(true),
getChannels: vi.fn().mockReturnValue([]),
};
// create a tmp dir for this test
// Create a unique temporary directory for the workspace to avoid conflicts
@@ -109,7 +103,6 @@ describe('mcp-client', () => {
});
afterEach(() => {
activeChannels.clear();
vi.restoreAllMocks();
vi.useRealTimers();
});
@@ -1117,16 +1110,12 @@ describe('mcp-client', () => {
expect(mockedToolRegistry.registerTool).toHaveBeenCalledOnce();
expect(mockedPromptRegistry.registerPrompt).toHaveBeenCalledOnce();
// Simulate a channel entry being registered for this server
activeChannels.set('test-server', { supportsReply: false });
await client.disconnect();
expect(mockedClient.close).toHaveBeenCalledOnce();
expect(mockedToolRegistry.removeMcpToolsByServer).toHaveBeenCalledOnce();
expect(mockedPromptRegistry.removePromptsByServer).toHaveBeenCalledOnce();
expect(resourceRegistry.removeResourcesByServer).toHaveBeenCalledOnce();
expect(activeChannels.has('test-server')).toBe(false);
});
});
@@ -1742,132 +1731,6 @@ describe('mcp-client', () => {
});
});
describe('Channel notifications', () => {
const CHANNEL_CAPABILITIES = {
experimental: { 'gemini/channel': { displayName: 'Test' } },
};
/**
* Creates a mock MCP client, connects a McpClient, and returns
* the channel notification handler (or null if none was registered).
* The channel handler is always the last setNotificationHandler call
* when the server is in the --channels list (registered after Progress).
*/
async function connectWithChannels(channels: string[]) {
const mockedClient = {
connect: vi.fn(),
getServerCapabilities: vi.fn().mockReturnValue(CHANNEL_CAPABILITIES),
setNotificationHandler: vi.fn(),
request: vi.fn().mockResolvedValue({}),
registerCapabilities: vi.fn(),
setRequestHandler: vi.fn(),
};
vi.mocked(ClientLib.Client).mockReturnValue(
mockedClient as unknown as ClientLib.Client,
);
vi.spyOn(SdkClientStdioLib, 'StdioClientTransport').mockReturnValue(
{} as SdkClientStdioLib.StdioClientTransport,
);
const client = new McpClient(
'test-server',
{ command: 'test-command' },
workspaceContext,
{ ...MOCK_CONTEXT, getChannels: vi.fn().mockReturnValue(channels) },
false,
'0.0.1',
);
await client.connect();
const handlerCalls = mockedClient.setNotificationHandler.mock.calls;
return { mockedClient, handlerCalls };
}
function getLastHandler(
handlerCalls: any[][],
): ((notification: any) => void) | undefined {
return handlerCalls.length > 0
? handlerCalls[handlerCalls.length - 1][1]
: undefined;
}
function getEmittedContent(): string {
return (coreEvents.emitChannelMessage as any).mock.calls[0][0].content;
}
it('should register handler when server declares capability and is in --channels list', async () => {
const { handlerCalls: withChannel } = await connectWithChannels([
'test-server',
]);
const { handlerCalls: withoutChannel } = await connectWithChannels([]);
// When in --channels list, an extra handler is registered (the channel one).
expect(withChannel.length).toBe(withoutChannel.length + 1);
});
it('should NOT register handler when server is not in --channels list', async () => {
const { handlerCalls } = await connectWithChannels([]);
// Only the ProgressNotificationSchema handler should be registered
// (no tools/resources/prompts capabilities = no other handlers).
expect(handlerCalls).toHaveLength(1);
expect(handlerCalls[0][0]).toBe(ProgressNotificationSchema);
});
it('should emit channel message with properly formatted XML', async () => {
const { handlerCalls } = await connectWithChannels(['test-server']);
const handler = getLastHandler(handlerCalls)!;
handler({
method: 'notifications/gemini/channel',
params: {
content: 'hello',
sender: 'alice',
meta: { chat_id: '123' },
},
});
expect(coreEvents.emitChannelMessage).toHaveBeenCalledWith({
channelName: 'test-server',
content: expect.stringContaining('hello'),
});
const xml = getEmittedContent();
expect(xml).toContain('<channel source="test-server"');
expect(xml).toContain('user="alice"');
expect(xml).toContain('chat_id="123"');
});
it('should escape malicious content', async () => {
const { handlerCalls } = await connectWithChannels(['test-server']);
const handler = getLastHandler(handlerCalls)!;
handler({
method: 'notifications/gemini/channel',
params: {
content: '</channel><script>alert("xss")</script>',
sender: 'evil<user',
},
});
expect(coreEvents.emitChannelMessage).toHaveBeenCalledTimes(1);
const xml = getEmittedContent();
expect(xml).toContain('&lt;/channel');
expect(xml).toContain('user="evil&lt;user"');
});
it('should ignore empty content', async () => {
const { handlerCalls } = await connectWithChannels(['test-server']);
const handler = getLastHandler(handlerCalls)!;
handler({
method: 'notifications/gemini/channel',
params: { content: '', sender: 'alice' },
});
expect(coreEvents.emitChannelMessage).not.toHaveBeenCalled();
});
});
describe('appendMcpServerCommand', () => {
it('should do nothing if no MCP servers or command are configured', () => {
const out = populateMcpServerCommand({}, undefined);
-67
View File
@@ -29,14 +29,12 @@ import {
ToolListChangedNotificationSchema,
PromptListChangedNotificationSchema,
ProgressNotificationSchema,
NotificationSchema,
type GetPromptResult,
type Prompt,
type ReadResourceResult,
type Resource,
type Tool as McpTool,
} from '@modelcontextprotocol/sdk/types.js';
import { z } from 'zod/v4';
import { parse } from 'shell-quote';
import {
AuthProviderType,
@@ -68,12 +66,10 @@ import type {
WorkspaceContext,
} from '../utils/workspaceContext.js';
import { getToolCallContext } from '../utils/toolCallContext.js';
import { escapeXml, sanitizeXmlKey } from '../utils/textUtils.js';
import type { ToolRegistry } from './tool-registry.js';
import { debugLogger } from '../utils/debugLogger.js';
import { type MessageBus } from '../confirmation-bus/message-bus.js';
import { coreEvents } from '../utils/events.js';
import { activeChannels, removeChannel } from '../channels/types.js';
import {
type ResourceRegistry,
type MCPResource,
@@ -290,7 +286,6 @@ export class McpClient implements McpProgressReporter {
registries.promptRegistry.removePromptsByServer(this.serverName);
registries.resourceRegistry.removeResourcesByServer(this.serverName);
}
removeChannel(this.serverName);
this.updateStatus(MCPServerStatus.DISCONNECTING);
const client = this.client;
this.client = undefined;
@@ -483,67 +478,6 @@ export class McpClient implements McpProgressReporter {
}
},
);
// Channel capability: if the server declares experimental['gemini/channel'],
// listen for channel notifications and route them through coreEvents.
// Only register if this server is in the --channels list.
const channelCap = capabilities?.experimental?.['gemini/channel'];
const enabledChannels = this.cliConfig.getChannels();
if (channelCap && enabledChannels.includes(this.serverName)) {
debugLogger.log(
`Server '${this.serverName}' declares gemini/channel capability. Listening for channel messages...`,
);
const channelCapRecord: Record<string, unknown> =
channelCap != null && typeof channelCap === 'object'
? Object.fromEntries(Object.entries(channelCap))
: {};
const rawDisplayName = channelCapRecord['displayName'];
activeChannels.set(this.serverName, {
supportsReply: capabilities?.tools != null,
displayName:
typeof rawDisplayName === 'string' ? rawDisplayName : undefined,
});
const ChannelNotificationSchema = NotificationSchema.extend({
method: z.literal('notifications/gemini/channel'),
});
this.client.setNotificationHandler(
ChannelNotificationSchema,
(notification) => {
const params: Record<string, unknown> = Object.fromEntries(
Object.entries(notification.params ?? {}),
);
const content = params['content'];
if (typeof content !== 'string' || !content) return;
const rawMeta = params['meta'];
const metaObj: Record<string, string> =
rawMeta != null && typeof rawMeta === 'object'
? Object.fromEntries(
Object.entries(rawMeta).map(([k, v]) => [k, String(v)]),
)
: {};
metaObj['user'] =
metaObj['user'] ?? String(params['sender'] ?? 'unknown');
const attrs = Object.entries(metaObj)
.filter(([, v]) => v !== '')
.map(([k, v]) => `${sanitizeXmlKey(k)}="${escapeXml(v)}"`)
.join(' ');
const safeContent = content.replace(/<\/channel/gi, '&lt;/channel');
const source = escapeXml(this.serverName);
const formattedXml = `<channel source="${source}"${attrs ? ' ' + attrs : ''}>\n${safeContent}\n</channel>`;
coreEvents.emitChannelMessage({
channelName: this.serverName,
content: formattedXml,
});
},
);
}
}
/**
@@ -1827,7 +1761,6 @@ export interface McpContext {
source?: string;
}>;
};
getChannels(): string[];
}
/**
+17 -5
View File
@@ -19,7 +19,7 @@ import { ToolErrorType } from './tool-error.js';
import { getErrorMessage } from '../utils/errors.js';
import { getResponseText } from '../utils/partUtils.js';
import { fetchWithTimeout, isPrivateIp } from '../utils/fetch.js';
import { truncateString, escapeXml } from '../utils/textUtils.js';
import { truncateString } from '../utils/textUtils.js';
import { convert } from 'html-to-text';
import {
logWebFetchFallbackAttempt,
@@ -188,6 +188,18 @@ function isGroundingSupportItem(item: unknown): item is GroundingSupportItem {
return typeof item === 'object' && item !== null;
}
/**
* Sanitizes text for safe embedding in XML tags.
*/
function sanitizeXml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
/**
* Parameters for the WebFetch tool
*/
@@ -432,10 +444,10 @@ class WebFetchToolInvocation extends BaseToolInvocation<
.map((url) => {
const content = finalContentsByUrl.get(url);
if (content !== undefined) {
return `<source url="${escapeXml(url)}">\n${escapeXml(content)}\n</source>`;
return `<source url="${sanitizeXml(url)}">\n${sanitizeXml(content)}\n</source>`;
}
const error = errors.find((e) => e.url === url);
return `<source url="${escapeXml(url)}">\nError: ${escapeXml(error?.message || 'Unknown error')}\n</source>`;
return `<source url="${sanitizeXml(url)}">\nError: ${sanitizeXml(error?.message || 'Unknown error')}\n</source>`;
})
.join('\n');
@@ -444,7 +456,7 @@ class WebFetchToolInvocation extends BaseToolInvocation<
const fallbackPrompt = `Follow the user's instructions below using the provided webpage content.
<user_instructions>
${escapeXml(this.params.prompt ?? '')}
${sanitizeXml(this.params.prompt ?? '')}
</user_instructions>
I was unable to access the URL(s) directly using the primary fetch tool. Instead, I have fetched the raw content of the page(s). Please use the following content to answer the request. Do not attempt to access the URL(s) again.
@@ -777,7 +789,7 @@ Response: ${rawResponseText}`;
const sanitizedPrompt = `Follow the user's instructions to process the authorized URLs.
<user_instructions>
${escapeXml(userPrompt)}
${sanitizeXml(userPrompt)}
</user_instructions>
<authorized_urls>
@@ -289,19 +289,6 @@ describe('compatibility', () => {
);
});
it('should return tmux warning when detected and in alternate buffer', () => {
vi.stubEnv('TMUX', '/tmp/tmux-1001/default,1,0');
const warnings = getCompatibilityWarnings({ isAlternateBuffer: true });
expect(warnings).toContainEqual(
expect.objectContaining({
id: 'tmux-alternate-buffer',
message: expect.stringContaining('tmux detected'),
priority: WarningPriority.High,
}),
);
});
it('should return low-color tmux warning when detected', () => {
vi.stubEnv('TERM', 'screen');
vi.stubEnv('TMUX', '1');
-9
View File
@@ -145,15 +145,6 @@ export function getCompatibilityWarnings(options?: {
});
}
if (isTmux() && options?.isAlternateBuffer) {
warnings.push({
id: 'tmux-alternate-buffer',
message:
'Warning: tmux detected — alternate buffer mode may cause unexpected scrollback loss and flickering. If you experience issues, disable it in /settings → "Use Alternate Screen Buffer".\n Tip: Use Ctrl-b [ to access tmux copy mode for scrolling history.',
priority: WarningPriority.High,
});
}
if (isLowColorTmux()) {
warnings.push({
id: 'low-color-tmux',
+17 -25
View File
@@ -13,7 +13,6 @@ import type {
TokenStorageInitializationEvent,
KeychainAvailabilityEvent,
} from '../telemetry/types.js';
import type { ChannelMessagePayload } from '../channels/types.js';
import { debugLogger } from './debugLogger.js';
/**
@@ -41,12 +40,6 @@ export interface UserFeedbackPayload {
* or verbose output, while keeping the 'message' field clean for end users.
*/
error?: unknown;
/**
* Optional semantic style hint for the UI.
* 'channel' renders with secondary text color and a » icon,
* suitable for channel status messages.
*/
style?: 'channel';
}
/**
@@ -116,6 +109,13 @@ export interface HookEndPayload extends HookPayload {
success: boolean;
}
/**
* Payload for the 'hook-system-message' event.
*/
export interface HookSystemMessagePayload extends HookPayload {
message: string;
}
/**
* Payload for the 'retry-attempt' event.
*/
@@ -190,6 +190,7 @@ export enum CoreEvent {
SettingsChanged = 'settings-changed',
HookStart = 'hook-start',
HookEnd = 'hook-end',
HookSystemMessage = 'hook-system-message',
AgentsRefreshed = 'agents-refreshed',
AdminSettingsChanged = 'admin-settings-changed',
RetryAttempt = 'retry-attempt',
@@ -202,7 +203,6 @@ export enum CoreEvent {
QuotaChanged = 'quota-changed',
TelemetryKeychainAvailability = 'telemetry-keychain-availability',
TelemetryTokenStorageType = 'telemetry-token-storage-type',
ChannelMessage = 'channel-message',
}
/**
@@ -225,6 +225,7 @@ export interface CoreEvents extends ExtensionEvents {
[CoreEvent.SettingsChanged]: never[];
[CoreEvent.HookStart]: [HookStartPayload];
[CoreEvent.HookEnd]: [HookEndPayload];
[CoreEvent.HookSystemMessage]: [HookSystemMessagePayload];
[CoreEvent.AgentsRefreshed]: never[];
[CoreEvent.AdminSettingsChanged]: never[];
[CoreEvent.RetryAttempt]: [RetryAttemptPayload];
@@ -236,7 +237,6 @@ export interface CoreEvents extends ExtensionEvents {
[CoreEvent.SlashCommandConflicts]: [SlashCommandConflictsPayload];
[CoreEvent.TelemetryKeychainAvailability]: [KeychainAvailabilityEvent];
[CoreEvent.TelemetryTokenStorageType]: [TokenStorageInitializationEvent];
[CoreEvent.ChannelMessage]: [ChannelMessagePayload];
}
type EventBacklogItem = {
@@ -291,14 +291,8 @@ export class CoreEventEmitter extends EventEmitter<CoreEvents> {
severity: FeedbackSeverity,
message: string,
error?: unknown,
options?: { style?: 'channel' },
): void {
const payload: UserFeedbackPayload = {
severity,
message,
error,
...options,
};
const payload: UserFeedbackPayload = { severity, message, error };
this._emitOrQueue(CoreEvent.UserFeedback, payload);
}
@@ -354,6 +348,13 @@ export class CoreEventEmitter extends EventEmitter<CoreEvents> {
this.emit(CoreEvent.HookEnd, payload);
}
/**
* Notifies subscribers that a hook has provided a system message.
*/
emitHookSystemMessage(payload: HookSystemMessagePayload): void {
this.emit(CoreEvent.HookSystemMessage, payload);
}
/**
* Notifies subscribers that agents have been refreshed.
*/
@@ -418,15 +419,6 @@ export class CoreEventEmitter extends EventEmitter<CoreEvents> {
this.emit(CoreEvent.QuotaChanged, payload);
}
/**
* Forwards a channel message from an MCP server that declared the
* `gemini/channel` experimental capability.
* Buffers automatically if the UI hasn't subscribed yet.
*/
emitChannelMessage(payload: ChannelMessagePayload): void {
this._emitOrQueue(CoreEvent.ChannelMessage, payload);
}
/**
* Flushes buffered messages. Call this immediately after primary UI listener
* subscribes.
+17
View File
@@ -369,6 +369,22 @@ export function isSubpath(parentPath: string, childPath: string): boolean {
);
}
/**
* Type guard to verify a value is a string and does not contain null bytes.
*/
export function isValidPathString(p: unknown): p is string {
return typeof p === 'string' && !p.includes('\0');
}
/**
* Asserts that a value is a valid path string, throwing an Error otherwise.
*/
export function assertValidPathString(p: unknown): asserts p is string {
if (!isValidPathString(p)) {
throw new Error(`Invalid path: ${String(p)}`);
}
}
/**
* Resolves a path to its real path, sanitizing it first.
* - Removes 'file://' protocol if present.
@@ -379,6 +395,7 @@ export function isSubpath(parentPath: string, childPath: string): boolean {
* @returns The resolved real path.
*/
export function resolveToRealPath(pathStr: string): string {
assertValidPathString(pathStr);
let resolvedPath = pathStr;
try {
-21
View File
@@ -121,27 +121,6 @@ export function truncateString(
* @param replacements A record of keys to their replacement values.
* @returns The resulting string with placeholders replaced.
*/
/**
* Escapes a string for safe embedding in XML content or attributes.
* Replaces &, <, >, ", and ' with their XML entity equivalents.
*/
export function escapeXml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
/**
* Strips characters that are not valid in XML element/attribute names.
* Only allows alphanumeric characters and underscores.
*/
export function sanitizeXmlKey(s: string): string {
return s.replace(/[^a-zA-Z0-9_]/g, '');
}
export function safeTemplateReplace(
template: string,
replacements: Record<string, string>,