mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-08-02 21:21:09 -07:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d06d875d5c | |||
| 316ed83b79 | |||
| 8199879513 | |||
| adf7b3b717 | |||
| 9637fb3990 | |||
| 06fcdc231c | |||
| d29da15427 | |||
| ab3075feb9 | |||
| 5588000e93 | |||
| 68fef8745e | |||
| e432f7c009 |
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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'),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -115,7 +115,6 @@ async function testMCPConnection(
|
||||
}
|
||||
},
|
||||
isTrustedFolder: () => isTrusted,
|
||||
getChannels: () => [],
|
||||
};
|
||||
|
||||
let transport;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
"
|
||||
`;
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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('</channel');
|
||||
expect(xml).toContain('user="evil<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);
|
||||
|
||||
@@ -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, '</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[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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');
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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>,
|
||||
|
||||
Reference in New Issue
Block a user