mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-08-02 21:21:09 -07:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 04741d1d73 | |||
| dfb641c2e9 | |||
| bc999138b7 | |||
| f94dbbeb46 | |||
| ece2b0c3a0 | |||
| b143c40bbe | |||
| 2ffab0a554 |
@@ -175,6 +175,13 @@ export type SlashCommandActionReturn =
|
||||
| OpenCustomDialogActionReturn
|
||||
| LogoutActionReturn;
|
||||
|
||||
export enum CommandSource {
|
||||
CORE = 'core',
|
||||
EXTENSION = 'extension',
|
||||
USER = 'user',
|
||||
WORKSPACE = 'workspace',
|
||||
}
|
||||
|
||||
export enum CommandKind {
|
||||
BUILT_IN = 'built-in',
|
||||
USER_FILE = 'user-file',
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Command } from '../key/keyMatchers.js';
|
||||
import { formatCommand } from '../key/keybindingUtils.js';
|
||||
|
||||
interface ContextSummaryDisplayProps {
|
||||
activeExtensionName?: string;
|
||||
geminiMdFileCount: number;
|
||||
contextFileNames: string[];
|
||||
mcpServers?: Record<string, MCPServerConfig>;
|
||||
@@ -29,6 +30,7 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
|
||||
ideContext,
|
||||
skillCount,
|
||||
backgroundProcessCount = 0,
|
||||
activeExtensionName,
|
||||
}) => {
|
||||
const mcpServerCount = Object.keys(mcpServers || {}).length;
|
||||
const blockedMcpServerCount = blockedMcpServers?.length || 0;
|
||||
@@ -40,7 +42,8 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
|
||||
blockedMcpServerCount === 0 &&
|
||||
openFileCount === 0 &&
|
||||
skillCount === 0 &&
|
||||
backgroundProcessCount === 0
|
||||
backgroundProcessCount === 0 &&
|
||||
!activeExtensionName
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
@@ -103,12 +106,20 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
|
||||
}`;
|
||||
})();
|
||||
|
||||
const extensionText = (() => {
|
||||
if (!activeExtensionName) {
|
||||
return '';
|
||||
}
|
||||
return `Extension: ${activeExtensionName}`;
|
||||
})();
|
||||
|
||||
const summaryParts = [
|
||||
openFilesText,
|
||||
geminiMdText,
|
||||
mcpText,
|
||||
skillText,
|
||||
backgroundText,
|
||||
extensionText,
|
||||
].filter(Boolean);
|
||||
|
||||
return (
|
||||
|
||||
@@ -39,6 +39,7 @@ export const StatusDisplay: React.FC<StatusDisplayProps> = ({
|
||||
}
|
||||
skillCount={config.getSkillManager().getDisplayableSkills().length}
|
||||
backgroundProcessCount={uiState.backgroundTaskCount}
|
||||
activeExtensionName={config.activeExtensionName}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -670,6 +670,8 @@ describe('useSlashCommandProcessor', () => {
|
||||
expect(actionResult).toEqual({
|
||||
type: 'submit_prompt',
|
||||
content: [{ text: 'The actual prompt from the TOML file.' }],
|
||||
activeExtensionName: undefined,
|
||||
clearExtensionMRU: false,
|
||||
});
|
||||
|
||||
expect(mockAddItem).toHaveBeenCalledWith(
|
||||
@@ -704,6 +706,8 @@ describe('useSlashCommandProcessor', () => {
|
||||
expect(actionResult).toEqual({
|
||||
type: 'submit_prompt',
|
||||
content: [{ text: 'The actual prompt from the mcp command.' }],
|
||||
activeExtensionName: undefined,
|
||||
clearExtensionMRU: false,
|
||||
});
|
||||
|
||||
expect(mockAddItem).toHaveBeenCalledWith(
|
||||
|
||||
@@ -47,7 +47,12 @@ import type {
|
||||
} from '../types.js';
|
||||
import { MessageType } from '../types.js';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
import { type CommandContext, type SlashCommand } from '../commands/types.js';
|
||||
import {
|
||||
type CommandContext,
|
||||
type SlashCommand,
|
||||
CommandSource,
|
||||
CommandKind,
|
||||
} from '../commands/types.js';
|
||||
import { CommandService } from '../../services/CommandService.js';
|
||||
import { BuiltinCommandLoader } from '../../services/BuiltinCommandLoader.js';
|
||||
import { FileCommandLoader } from '../../services/FileCommandLoader.js';
|
||||
@@ -92,6 +97,43 @@ interface SlashCommandProcessorActions {
|
||||
/**
|
||||
* Hook to define and process slash commands (e.g., /help, /clear).
|
||||
*/
|
||||
|
||||
function getCommandSource(command: SlashCommand): CommandSource {
|
||||
if (
|
||||
command.extensionName ||
|
||||
command.kind === CommandKind.EXTENSION_FILE ||
|
||||
command.kind === CommandKind.MCP_PROMPT ||
|
||||
command.kind === CommandKind.SKILL
|
||||
)
|
||||
return CommandSource.EXTENSION;
|
||||
if (command.kind === CommandKind.WORKSPACE_FILE)
|
||||
return CommandSource.WORKSPACE;
|
||||
if (command.kind === CommandKind.USER_FILE) return CommandSource.USER;
|
||||
return CommandSource.CORE;
|
||||
}
|
||||
|
||||
function shouldClearExtensionMRU(command: SlashCommand | undefined): boolean {
|
||||
if (!command) return false;
|
||||
|
||||
// Explicitly reset commands
|
||||
if (command.name === 'clear' || command.name === 'extension reset')
|
||||
return true;
|
||||
|
||||
// Exempt informational commands
|
||||
const exemptions = [
|
||||
'help',
|
||||
'settings',
|
||||
'status',
|
||||
'history',
|
||||
'bug',
|
||||
'exit',
|
||||
'quit',
|
||||
];
|
||||
if (exemptions.includes(command.name)) return false;
|
||||
|
||||
return getCommandSource(command) === CommandSource.CORE;
|
||||
}
|
||||
|
||||
export const useSlashCommandProcessor = (
|
||||
config: Config | null,
|
||||
settings: LoadedSettings,
|
||||
@@ -448,6 +490,9 @@ export const useSlashCommandProcessor = (
|
||||
toolName: result.toolName,
|
||||
toolArgs: result.toolArgs,
|
||||
postSubmitPrompt: result.postSubmitPrompt,
|
||||
activeExtensionName: commandToExecute?.extensionName,
|
||||
clearExtensionMRU:
|
||||
shouldClearExtensionMRU(commandToExecute),
|
||||
};
|
||||
case 'message':
|
||||
addItem(
|
||||
@@ -460,7 +505,12 @@ export const useSlashCommandProcessor = (
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
return { type: 'handled' };
|
||||
return {
|
||||
type: 'handled',
|
||||
activeExtensionName: commandToExecute?.extensionName,
|
||||
clearExtensionMRU:
|
||||
shouldClearExtensionMRU(commandToExecute),
|
||||
};
|
||||
case 'logout':
|
||||
// Show logout confirmation dialog with Login/Exit options
|
||||
setCustomDialog(
|
||||
@@ -476,30 +526,70 @@ export const useSlashCommandProcessor = (
|
||||
},
|
||||
}),
|
||||
);
|
||||
return { type: 'handled' };
|
||||
return {
|
||||
type: 'handled',
|
||||
activeExtensionName: commandToExecute?.extensionName,
|
||||
clearExtensionMRU:
|
||||
shouldClearExtensionMRU(commandToExecute),
|
||||
};
|
||||
case 'dialog':
|
||||
switch (result.dialog) {
|
||||
case 'auth':
|
||||
actions.openAuthDialog();
|
||||
return { type: 'handled' };
|
||||
return {
|
||||
type: 'handled',
|
||||
activeExtensionName: commandToExecute?.extensionName,
|
||||
clearExtensionMRU:
|
||||
shouldClearExtensionMRU(commandToExecute),
|
||||
};
|
||||
case 'theme':
|
||||
actions.openThemeDialog();
|
||||
return { type: 'handled' };
|
||||
return {
|
||||
type: 'handled',
|
||||
activeExtensionName: commandToExecute?.extensionName,
|
||||
clearExtensionMRU:
|
||||
shouldClearExtensionMRU(commandToExecute),
|
||||
};
|
||||
case 'editor':
|
||||
actions.openEditorDialog();
|
||||
return { type: 'handled' };
|
||||
return {
|
||||
type: 'handled',
|
||||
activeExtensionName: commandToExecute?.extensionName,
|
||||
clearExtensionMRU:
|
||||
shouldClearExtensionMRU(commandToExecute),
|
||||
};
|
||||
case 'privacy':
|
||||
actions.openPrivacyNotice();
|
||||
return { type: 'handled' };
|
||||
return {
|
||||
type: 'handled',
|
||||
activeExtensionName: commandToExecute?.extensionName,
|
||||
clearExtensionMRU:
|
||||
shouldClearExtensionMRU(commandToExecute),
|
||||
};
|
||||
case 'sessionBrowser':
|
||||
actions.openSessionBrowser();
|
||||
return { type: 'handled' };
|
||||
return {
|
||||
type: 'handled',
|
||||
activeExtensionName: commandToExecute?.extensionName,
|
||||
clearExtensionMRU:
|
||||
shouldClearExtensionMRU(commandToExecute),
|
||||
};
|
||||
case 'settings':
|
||||
actions.openSettingsDialog();
|
||||
return { type: 'handled' };
|
||||
return {
|
||||
type: 'handled',
|
||||
activeExtensionName: commandToExecute?.extensionName,
|
||||
clearExtensionMRU:
|
||||
shouldClearExtensionMRU(commandToExecute),
|
||||
};
|
||||
case 'model':
|
||||
actions.openModelDialog();
|
||||
return { type: 'handled' };
|
||||
return {
|
||||
type: 'handled',
|
||||
activeExtensionName: commandToExecute?.extensionName,
|
||||
clearExtensionMRU:
|
||||
shouldClearExtensionMRU(commandToExecute),
|
||||
};
|
||||
case 'agentConfig': {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const props = result.props as Record<string, unknown>;
|
||||
@@ -522,16 +612,31 @@ export const useSlashCommandProcessor = (
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
props['definition'] as AgentDefinition,
|
||||
);
|
||||
return { type: 'handled' };
|
||||
return {
|
||||
type: 'handled',
|
||||
activeExtensionName: commandToExecute?.extensionName,
|
||||
clearExtensionMRU:
|
||||
shouldClearExtensionMRU(commandToExecute),
|
||||
};
|
||||
}
|
||||
case 'permissions':
|
||||
actions.openPermissionsDialog(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
result.props as { targetDirectory?: string },
|
||||
);
|
||||
return { type: 'handled' };
|
||||
return {
|
||||
type: 'handled',
|
||||
activeExtensionName: commandToExecute?.extensionName,
|
||||
clearExtensionMRU:
|
||||
shouldClearExtensionMRU(commandToExecute),
|
||||
};
|
||||
case 'help':
|
||||
return { type: 'handled' };
|
||||
return {
|
||||
type: 'handled',
|
||||
activeExtensionName: commandToExecute?.extensionName,
|
||||
clearExtensionMRU:
|
||||
shouldClearExtensionMRU(commandToExecute),
|
||||
};
|
||||
default: {
|
||||
const unhandled: never = result.dialog;
|
||||
throw new Error(
|
||||
@@ -545,16 +650,29 @@ export const useSlashCommandProcessor = (
|
||||
result.history.forEach((item, index) => {
|
||||
fullCommandContext.ui.addItem(item, index);
|
||||
});
|
||||
return { type: 'handled' };
|
||||
return {
|
||||
type: 'handled',
|
||||
activeExtensionName: commandToExecute?.extensionName,
|
||||
clearExtensionMRU:
|
||||
shouldClearExtensionMRU(commandToExecute),
|
||||
};
|
||||
}
|
||||
case 'quit':
|
||||
actions.quit(result.messages);
|
||||
return { type: 'handled' };
|
||||
return {
|
||||
type: 'handled',
|
||||
activeExtensionName: commandToExecute?.extensionName,
|
||||
clearExtensionMRU:
|
||||
shouldClearExtensionMRU(commandToExecute),
|
||||
};
|
||||
|
||||
case 'submit_prompt':
|
||||
return {
|
||||
type: 'submit_prompt',
|
||||
content: result.content,
|
||||
activeExtensionName: commandToExecute?.extensionName,
|
||||
clearExtensionMRU:
|
||||
shouldClearExtensionMRU(commandToExecute),
|
||||
};
|
||||
case 'confirm_shell_commands': {
|
||||
const callId = `expansion-${Date.now()}`;
|
||||
@@ -611,7 +729,12 @@ export const useSlashCommandProcessor = (
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
return { type: 'handled' };
|
||||
return {
|
||||
type: 'handled',
|
||||
activeExtensionName: commandToExecute?.extensionName,
|
||||
clearExtensionMRU:
|
||||
shouldClearExtensionMRU(commandToExecute),
|
||||
};
|
||||
}
|
||||
|
||||
if (outcome === ToolConfirmationOutcome.ProceedAlways) {
|
||||
@@ -649,7 +772,12 @@ export const useSlashCommandProcessor = (
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
return { type: 'handled' };
|
||||
return {
|
||||
type: 'handled',
|
||||
activeExtensionName: commandToExecute?.extensionName,
|
||||
clearExtensionMRU:
|
||||
shouldClearExtensionMRU(commandToExecute),
|
||||
};
|
||||
}
|
||||
|
||||
return await handleSlashCommand(
|
||||
@@ -660,7 +788,12 @@ export const useSlashCommandProcessor = (
|
||||
}
|
||||
case 'custom_dialog': {
|
||||
setCustomDialog(result.component);
|
||||
return { type: 'handled' };
|
||||
return {
|
||||
type: 'handled',
|
||||
activeExtensionName: commandToExecute?.extensionName,
|
||||
clearExtensionMRU:
|
||||
shouldClearExtensionMRU(commandToExecute),
|
||||
};
|
||||
}
|
||||
default: {
|
||||
const unhandled: never = result;
|
||||
@@ -671,7 +804,11 @@ export const useSlashCommandProcessor = (
|
||||
}
|
||||
}
|
||||
|
||||
return { type: 'handled' };
|
||||
return {
|
||||
type: 'handled',
|
||||
activeExtensionName: commandToExecute?.extensionName,
|
||||
clearExtensionMRU: shouldClearExtensionMRU(commandToExecute),
|
||||
};
|
||||
} else if (commandToExecute.subCommands) {
|
||||
const helpText = `Command '/${commandToExecute.name}' requires a subcommand. Available:\n${commandToExecute.subCommands
|
||||
.map((sc) => ` - ${sc.name}: ${sc.description || ''}`)
|
||||
@@ -681,11 +818,19 @@ export const useSlashCommandProcessor = (
|
||||
content: helpText,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
return { type: 'handled' };
|
||||
return {
|
||||
type: 'handled',
|
||||
activeExtensionName: commandToExecute?.extensionName,
|
||||
clearExtensionMRU: shouldClearExtensionMRU(commandToExecute),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { type: 'handled' };
|
||||
return {
|
||||
type: 'handled',
|
||||
activeExtensionName: commandToExecute?.extensionName,
|
||||
clearExtensionMRU: shouldClearExtensionMRU(commandToExecute),
|
||||
};
|
||||
} catch (e: unknown) {
|
||||
hasError = true;
|
||||
if (config) {
|
||||
@@ -704,7 +849,11 @@ export const useSlashCommandProcessor = (
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
return { type: 'handled' };
|
||||
return {
|
||||
type: 'handled',
|
||||
activeExtensionName: commandToExecute?.extensionName,
|
||||
clearExtensionMRU: shouldClearExtensionMRU(commandToExecute),
|
||||
};
|
||||
} finally {
|
||||
if (config && resolvedCommandPath[0] && !hasError) {
|
||||
const event = makeSlashCommandEvent({
|
||||
|
||||
@@ -100,6 +100,7 @@ const MockedGeminiClientClass = vi.hoisted(() =>
|
||||
recordMessageTokens: vi.fn(),
|
||||
recordToolCalls: vi.fn(),
|
||||
getConversationFile: vi.fn(),
|
||||
recordActiveExtensionName: vi.fn(),
|
||||
});
|
||||
this.getCurrentSequenceModel = vi
|
||||
.fn()
|
||||
@@ -310,6 +311,8 @@ describe('useGeminiStream', () => {
|
||||
debugMode: false,
|
||||
question: undefined,
|
||||
coreTools: [],
|
||||
activeExtensionName: undefined,
|
||||
setActiveExtensionName: vi.fn(),
|
||||
toolDiscoveryCommand: undefined,
|
||||
toolCallCommand: undefined,
|
||||
mcpServerCommand: undefined,
|
||||
|
||||
@@ -935,6 +935,23 @@ export const useGeminiStream = (
|
||||
: false;
|
||||
|
||||
if (slashCommandResult) {
|
||||
if (slashCommandResult.clearExtensionMRU) {
|
||||
config.setActiveExtensionName(undefined);
|
||||
config
|
||||
.getGeminiClient()
|
||||
?.getChatRecordingService()
|
||||
?.recordActiveExtensionName(undefined);
|
||||
} else if (slashCommandResult.activeExtensionName) {
|
||||
config.setActiveExtensionName(
|
||||
slashCommandResult.activeExtensionName,
|
||||
);
|
||||
config
|
||||
.getGeminiClient()
|
||||
?.getChatRecordingService()
|
||||
?.recordActiveExtensionName(
|
||||
slashCommandResult.activeExtensionName,
|
||||
);
|
||||
}
|
||||
switch (slashCommandResult.type) {
|
||||
case 'schedule_tool': {
|
||||
const { toolName, toolArgs, postSubmitPrompt } =
|
||||
@@ -1750,6 +1767,16 @@ export const useGeminiStream = (
|
||||
|
||||
const handleApprovalModeChange = useCallback(
|
||||
async (newApprovalMode: ApprovalMode) => {
|
||||
if (
|
||||
previousApprovalModeRef.current === ApprovalMode.PLAN &&
|
||||
newApprovalMode !== ApprovalMode.PLAN
|
||||
) {
|
||||
config.setActiveExtensionName(undefined);
|
||||
config
|
||||
.getGeminiClient()
|
||||
?.getChatRecordingService()
|
||||
?.recordActiveExtensionName(undefined);
|
||||
}
|
||||
if (
|
||||
previousApprovalModeRef.current === ApprovalMode.PLAN &&
|
||||
newApprovalMode !== ApprovalMode.PLAN &&
|
||||
|
||||
@@ -83,6 +83,13 @@ export function useSessionResume({
|
||||
workspaceContext.addDirectories(resumedData.conversation.directories);
|
||||
}
|
||||
|
||||
// Restore active extension context
|
||||
if (resumedData.conversation.activeExtensionName) {
|
||||
config.setActiveExtensionName(
|
||||
resumedData.conversation.activeExtensionName,
|
||||
);
|
||||
}
|
||||
|
||||
// Give the history to the Gemini client.
|
||||
await config.getGeminiClient()?.resumeChat(clientHistory, resumedData);
|
||||
} catch (error) {
|
||||
|
||||
@@ -498,6 +498,8 @@ export interface ConsoleMessageItem {
|
||||
export interface SubmitPromptResult {
|
||||
type: 'submit_prompt';
|
||||
content: PartListUnion;
|
||||
activeExtensionName?: string;
|
||||
clearExtensionMRU?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -509,9 +511,13 @@ export type SlashCommandProcessorResult =
|
||||
toolName: string;
|
||||
toolArgs: Record<string, unknown>;
|
||||
postSubmitPrompt?: PartListUnion;
|
||||
activeExtensionName?: string;
|
||||
clearExtensionMRU?: boolean;
|
||||
}
|
||||
| {
|
||||
type: 'handled'; // Indicates the command was processed and no further action is needed.
|
||||
activeExtensionName?: string;
|
||||
clearExtensionMRU?: boolean;
|
||||
}
|
||||
| SubmitPromptResult;
|
||||
|
||||
|
||||
@@ -77,6 +77,7 @@ import type { InjectionSource } from '../config/injectionService.js';
|
||||
import {
|
||||
createScopedWorkspaceContext,
|
||||
runWithScopedWorkspaceContext,
|
||||
runWithScopedActiveExtension,
|
||||
} from '../config/scoped-config.js';
|
||||
import { CompleteTaskTool } from '../tools/complete-task.js';
|
||||
import {
|
||||
@@ -523,21 +524,27 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
* @returns A promise that resolves to the agent's final output.
|
||||
*/
|
||||
async run(inputs: AgentInputs, signal: AbortSignal): Promise<OutputObject> {
|
||||
// If the agent definition declares additional workspace directories,
|
||||
// wrap execution in a scoped workspace context. All calls to
|
||||
// Config.getWorkspaceContext() within this scope will see the extended
|
||||
// directories, without mutating the shared Config.
|
||||
const dirs = this.definition.workspaceDirectories;
|
||||
if (dirs && dirs.length > 0) {
|
||||
const scopedCtx = createScopedWorkspaceContext(
|
||||
this.context.config.getWorkspaceContext(),
|
||||
dirs,
|
||||
);
|
||||
return runWithScopedWorkspaceContext(scopedCtx, () =>
|
||||
this.runInternal(inputs, signal),
|
||||
);
|
||||
}
|
||||
return this.runInternal(inputs, signal);
|
||||
// Isolate activeExtensionName for sub-agents to prevent leaking context switches
|
||||
return runWithScopedActiveExtension(
|
||||
this.context.config.activeExtensionName ?? null,
|
||||
() => {
|
||||
// If the agent definition declares additional workspace directories,
|
||||
// wrap execution in a scoped workspace context. All calls to
|
||||
// Config.getWorkspaceContext() within this scope will see the extended
|
||||
// directories, without mutating the shared Config.
|
||||
const dirs = this.definition.workspaceDirectories;
|
||||
if (dirs && dirs.length > 0) {
|
||||
const scopedCtx = createScopedWorkspaceContext(
|
||||
this.context.config.getWorkspaceContext(),
|
||||
dirs,
|
||||
);
|
||||
return runWithScopedWorkspaceContext(scopedCtx, () =>
|
||||
this.runInternal(inputs, signal),
|
||||
);
|
||||
}
|
||||
return this.runInternal(inputs, signal);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private async runInternal(
|
||||
|
||||
@@ -26,6 +26,9 @@ export interface AgentLoopContext {
|
||||
/** The unique ID for the parent session if this is a subagent. */
|
||||
readonly parentSessionId?: string;
|
||||
|
||||
/** The name of the active extension driving this context, if any. */
|
||||
readonly activeExtensionName?: string;
|
||||
|
||||
/** The registry of tools available to the agent in this context. */
|
||||
readonly toolRegistry: ToolRegistry;
|
||||
|
||||
|
||||
@@ -1490,6 +1490,56 @@ describe('Server Config (config.ts)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getExtensionSetting', () => {
|
||||
it('returns undefined if the extension does not exist', () => {
|
||||
const config = new Config(baseParams);
|
||||
vi.spyOn(config, 'getExtensions').mockReturnValue([]);
|
||||
expect(config.getExtensionSetting('foo', 'bar')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined if the extension has no resolvedSettings', () => {
|
||||
const config = new Config(baseParams);
|
||||
vi.spyOn(config, 'getExtensions').mockReturnValue([
|
||||
{
|
||||
name: 'my-ext',
|
||||
version: '1.0',
|
||||
isActive: true,
|
||||
path: '/ext',
|
||||
contextFiles: [],
|
||||
id: 'my-ext',
|
||||
},
|
||||
]);
|
||||
expect(
|
||||
config.getExtensionSetting('my-ext', 'some.setting'),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns the setting value if it exists', () => {
|
||||
const config = new Config(baseParams);
|
||||
vi.spyOn(config, 'getExtensions').mockReturnValue([
|
||||
{
|
||||
name: 'my-ext',
|
||||
version: '1.0',
|
||||
isActive: true,
|
||||
path: '/ext',
|
||||
contextFiles: [],
|
||||
id: 'my-ext',
|
||||
resolvedSettings: [
|
||||
{
|
||||
name: 'some.setting',
|
||||
value: 'custom-val',
|
||||
envVar: 'MY_EXT_SOME_SETTING',
|
||||
sensitive: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
expect(config.getExtensionSetting('my-ext', 'some.setting')).toBe(
|
||||
'custom-val',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTruncateToolOutputThreshold', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
@@ -133,7 +133,10 @@ import type { GenerateContentParameters } from '@google/genai';
|
||||
export type { MCPOAuthConfig, AnyToolInvocation, AnyDeclarativeTool };
|
||||
import type { AnyToolInvocation, AnyDeclarativeTool } from '../tools/tools.js';
|
||||
import { WorkspaceContext } from '../utils/workspaceContext.js';
|
||||
import { getWorkspaceContextOverride } from './scoped-config.js';
|
||||
import {
|
||||
getWorkspaceContextOverride,
|
||||
getActiveExtensionOverride,
|
||||
} from './scoped-config.js';
|
||||
import { Storage } from './storage.js';
|
||||
import type { ShellExecutionConfig } from '../services/shellExecutionService.js';
|
||||
import { FileExclusions } from '../utils/ignorePatterns.js';
|
||||
@@ -737,6 +740,26 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
private blockedEnvironmentVariables: string[];
|
||||
private readonly enableEnvironmentVariableRedaction: boolean;
|
||||
private _promptRegistry!: PromptRegistry;
|
||||
private _activeExtensionName?: string;
|
||||
|
||||
get activeExtensionName(): string | undefined {
|
||||
const override = getActiveExtensionOverride();
|
||||
if (override !== undefined) {
|
||||
return override.name === null ? undefined : override.name;
|
||||
}
|
||||
return (
|
||||
this._activeExtensionName || process.env['GEMINI_CLI_ACTIVE_EXTENSION']
|
||||
);
|
||||
}
|
||||
|
||||
setActiveExtensionName(name: string | undefined): void {
|
||||
const override = getActiveExtensionOverride();
|
||||
if (override !== undefined) {
|
||||
override.name = name ?? null;
|
||||
} else {
|
||||
this._activeExtensionName = name;
|
||||
}
|
||||
}
|
||||
private _resourceRegistry!: ResourceRegistry;
|
||||
private agentRegistry!: AgentRegistry;
|
||||
private readonly acknowledgedAgentsService: AcknowledgedAgentsService;
|
||||
@@ -2844,6 +2867,26 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
return this._extensionLoader.getExtensions();
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a setting value for a specific extension.
|
||||
*
|
||||
* @param extensionName - The name of the extension.
|
||||
* @param settingName - The name of the setting to retrieve.
|
||||
*/
|
||||
getExtensionSetting<T>(
|
||||
extensionName: string,
|
||||
settingName: string,
|
||||
): T | undefined {
|
||||
const ext = this.getExtensions().find((e) => e.name === extensionName);
|
||||
if (!ext || !ext.resolvedSettings) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const setting = ext.resolvedSettings.find((s) => s.name === settingName);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return setting?.value as T | undefined;
|
||||
}
|
||||
|
||||
getExtensionLoader(): ExtensionLoader {
|
||||
return this._extensionLoader;
|
||||
}
|
||||
@@ -3340,20 +3383,10 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
return this.shellExecutionConfig;
|
||||
}
|
||||
|
||||
setShellExecutionConfig(config: ShellExecutionConfig): void {
|
||||
setShellExecutionConfig(config: Partial<ShellExecutionConfig>): void {
|
||||
this.shellExecutionConfig = {
|
||||
...this.shellExecutionConfig,
|
||||
terminalWidth:
|
||||
config.terminalWidth ?? this.shellExecutionConfig.terminalWidth,
|
||||
terminalHeight:
|
||||
config.terminalHeight ?? this.shellExecutionConfig.terminalHeight,
|
||||
showColor: config.showColor ?? this.shellExecutionConfig.showColor,
|
||||
pager: config.pager ?? this.shellExecutionConfig.pager,
|
||||
sanitizationConfig:
|
||||
config.sanitizationConfig ??
|
||||
this.shellExecutionConfig.sanitizationConfig,
|
||||
sandboxManager:
|
||||
config.sandboxManager ?? this.shellExecutionConfig.sandboxManager,
|
||||
...config,
|
||||
};
|
||||
}
|
||||
getScreenReader(): boolean {
|
||||
|
||||
@@ -300,4 +300,36 @@ describe('ProjectRegistry', () => {
|
||||
'ProjectRegistry must be initialized before use',
|
||||
);
|
||||
});
|
||||
|
||||
it('retries on EBUSY during save', async () => {
|
||||
const registry = new ProjectRegistry(registryPath);
|
||||
await registry.initialize();
|
||||
|
||||
const renameSpy = vi.spyOn(fs.promises, 'rename');
|
||||
let ebusyCount = 0;
|
||||
|
||||
renameSpy.mockImplementation(async (oldPath, newPath) => {
|
||||
// Only throw for the specific temporary file generated by save()
|
||||
if (oldPath.toString().includes('.tmp') && ebusyCount < 2) {
|
||||
ebusyCount++;
|
||||
const err = new Error('Resource busy or locked');
|
||||
(err as { code?: string }).code = 'EBUSY';
|
||||
throw err;
|
||||
}
|
||||
return fs.promises
|
||||
.copyFile(oldPath, newPath)
|
||||
.then(() => fs.promises.unlink(oldPath));
|
||||
});
|
||||
|
||||
const projectPath = path.join(tempDir, 'ebusy-project');
|
||||
const shortId = await registry.getShortId(projectPath);
|
||||
expect(shortId).toBe('ebusy-project');
|
||||
expect(ebusyCount).toBe(2);
|
||||
|
||||
// Verify it actually saved properly after retries
|
||||
const data = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
|
||||
expect(data.projects[normalizePath(projectPath)]).toBe('ebusy-project');
|
||||
|
||||
renameSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import { lock } from 'proper-lockfile';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
import { isNodeError } from '../utils/errors.js';
|
||||
|
||||
export interface RegistryData {
|
||||
projects: Record<string, string>;
|
||||
@@ -83,17 +84,48 @@ export class ProjectRegistry {
|
||||
await fs.promises.mkdir(dir, { recursive: true });
|
||||
}
|
||||
|
||||
const tmpPath = this.registryPath + '.' + randomUUID() + '.tmp';
|
||||
try {
|
||||
const content = JSON.stringify(data, null, 2);
|
||||
// Use a randomized tmp path to avoid ENOENT crashes when save() is called concurrently
|
||||
const tmpPath = this.registryPath + '.' + randomUUID() + '.tmp';
|
||||
await fs.promises.writeFile(tmpPath, content, 'utf8');
|
||||
await fs.promises.rename(tmpPath, this.registryPath);
|
||||
|
||||
// Exponential backoff for OS-level file locks (EBUSY/EPERM) during rename
|
||||
const maxRetries = 5;
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
try {
|
||||
await fs.promises.rename(tmpPath, this.registryPath);
|
||||
break; // Success
|
||||
} catch (error: unknown) {
|
||||
const code = isNodeError(error) ? error.code : '';
|
||||
|
||||
if (
|
||||
(code === 'EBUSY' || code === 'EPERM') &&
|
||||
attempt < maxRetries - 1
|
||||
) {
|
||||
const delayMs = Math.pow(2, attempt) * 50;
|
||||
debugLogger.debug(
|
||||
`Rename failed with ${code}, retrying in ${delayMs}ms (attempt ${attempt + 1}/${maxRetries})...`,
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
debugLogger.error(
|
||||
`Failed to save project registry to ${this.registryPath}:`,
|
||||
error,
|
||||
);
|
||||
} finally {
|
||||
// Clean up the temporary file if it was left behind
|
||||
try {
|
||||
if (fs.existsSync(tmpPath)) {
|
||||
await fs.promises.unlink(tmpPath);
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors during cleanup
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
createScopedWorkspaceContext,
|
||||
runWithScopedWorkspaceContext,
|
||||
getWorkspaceContextOverride,
|
||||
runWithScopedActiveExtension,
|
||||
getActiveExtensionOverride,
|
||||
} from './scoped-config.js';
|
||||
import { Config } from './config.js';
|
||||
|
||||
@@ -204,3 +206,65 @@ describe('runWithScopedWorkspaceContext', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('runWithScopedActiveExtension', () => {
|
||||
let config: Config;
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'scoped-run-'));
|
||||
config = new Config({
|
||||
targetDir: tempDir,
|
||||
sessionId: 'test-session',
|
||||
debugMode: false,
|
||||
cwd: tempDir,
|
||||
model: 'test-model',
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should override Config.activeExtensionName within scope', () => {
|
||||
config.setActiveExtensionName('global-ext');
|
||||
|
||||
runWithScopedActiveExtension('scoped-ext', () => {
|
||||
expect(config.activeExtensionName).toBe('scoped-ext');
|
||||
});
|
||||
|
||||
expect(config.activeExtensionName).toBe('global-ext');
|
||||
});
|
||||
|
||||
it('should handle null to mask the global extension', () => {
|
||||
config.setActiveExtensionName('global-ext');
|
||||
|
||||
runWithScopedActiveExtension(null, () => {
|
||||
expect(config.activeExtensionName).toBeUndefined();
|
||||
});
|
||||
|
||||
expect(config.activeExtensionName).toBe('global-ext');
|
||||
});
|
||||
|
||||
it('should allow mutating the scoped extension using Config.setActiveExtensionName', () => {
|
||||
config.setActiveExtensionName('global-ext');
|
||||
|
||||
runWithScopedActiveExtension('scoped-ext', () => {
|
||||
config.setActiveExtensionName('mutated-scoped-ext');
|
||||
expect(config.activeExtensionName).toBe('mutated-scoped-ext');
|
||||
});
|
||||
|
||||
// The global state should remain untouched
|
||||
expect(config.activeExtensionName).toBe('global-ext');
|
||||
});
|
||||
|
||||
it('should return undefined from getActiveExtensionOverride outside scope', () => {
|
||||
expect(getActiveExtensionOverride()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return the object from getActiveExtensionOverride inside scope', () => {
|
||||
runWithScopedActiveExtension('scoped-ext', () => {
|
||||
expect(getActiveExtensionOverride()).toEqual({ name: 'scoped-ext' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,6 +19,9 @@ import { WorkspaceContext } from '../utils/workspaceContext.js';
|
||||
* This follows the same pattern as `toolCallContext` and `promptIdContext`.
|
||||
*/
|
||||
const workspaceContextOverride = new AsyncLocalStorage<WorkspaceContext>();
|
||||
const activeExtensionOverride = new AsyncLocalStorage<{
|
||||
name: string | null;
|
||||
}>();
|
||||
|
||||
/**
|
||||
* Returns the current workspace context override, if any.
|
||||
@@ -28,6 +31,16 @@ export function getWorkspaceContextOverride(): WorkspaceContext | undefined {
|
||||
return workspaceContextOverride.getStore();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current active extension name override, if any.
|
||||
* Called by `Config.activeExtensionName` getter/setter to check for isolated scoped execution.
|
||||
*/
|
||||
export function getActiveExtensionOverride():
|
||||
| { name: string | null }
|
||||
| undefined {
|
||||
return activeExtensionOverride.getStore();
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a function with a scoped workspace context override.
|
||||
* Any calls to `Config.getWorkspaceContext()` within `fn` will return
|
||||
@@ -44,6 +57,22 @@ export function runWithScopedWorkspaceContext<T>(
|
||||
return workspaceContextOverride.run(scopedContext, fn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a function with a scoped active extension context override.
|
||||
* Any calls to `Config.activeExtensionName` within `fn` will return
|
||||
* the scoped context instead of the inherited default.
|
||||
*
|
||||
* @param scopedExtension The active extension name to use within the scope.
|
||||
* @param fn The function to run.
|
||||
* @returns The result of the function.
|
||||
*/
|
||||
export function runWithScopedActiveExtension<T>(
|
||||
scopedExtension: string | null,
|
||||
fn: () => T,
|
||||
): T {
|
||||
return activeExtensionOverride.run({ name: scopedExtension }, fn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a {@link WorkspaceContext} that extends a parent's directories
|
||||
* with additional ones.
|
||||
|
||||
@@ -291,6 +291,40 @@ describe('Storage – additional helpers', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveWorkspaceRelativePath', () => {
|
||||
it('resolves a relative path correctly', () => {
|
||||
expect(storage.resolveWorkspaceRelativePath('foo/bar')).toBe(
|
||||
path.join(projectRoot, 'foo/bar'),
|
||||
);
|
||||
});
|
||||
|
||||
it('throws if homedir path escapes workspace', () => {
|
||||
// In this test, projectRoot is /tmp/project, and homedir is likely outside.
|
||||
// We expect this to throw an error about escaping the project root.
|
||||
expect(() => storage.resolveWorkspaceRelativePath('~/foo')).toThrow(
|
||||
/outside the project root/,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws if path escapes workspace', () => {
|
||||
expect(() => storage.resolveWorkspaceRelativePath('../outside')).toThrow(
|
||||
/outside the project root/,
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves an absolute path within workspace', () => {
|
||||
expect(
|
||||
storage.resolveWorkspaceRelativePath(path.join(projectRoot, 'inner')),
|
||||
).toBe(path.join(projectRoot, 'inner'));
|
||||
});
|
||||
|
||||
it('throws for an absolute path outside workspace', () => {
|
||||
expect(() => storage.resolveWorkspaceRelativePath('/tmp/foo')).toThrow(
|
||||
/outside the project root/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPlansDir', () => {
|
||||
interface TestCase {
|
||||
name: string;
|
||||
@@ -310,7 +344,7 @@ describe('Storage – additional helpers', () => {
|
||||
name: 'custom absolute path outside throws',
|
||||
customDir: path.resolve('/absolute/path/to/plans'),
|
||||
expected: '',
|
||||
expectedError: `Custom plans directory '${path.resolve('/absolute/path/to/plans')}' resolves to '${path.resolve('/absolute/path/to/plans')}', which is outside the project root '${resolveToRealPath(projectRoot)}'.`,
|
||||
expectedError: `Path '${path.resolve('/absolute/path/to/plans')}' resolves to '${path.resolve('/absolute/path/to/plans')}', which is outside the project root '${resolveToRealPath(projectRoot)}'.`,
|
||||
},
|
||||
{
|
||||
name: 'absolute path that happens to be inside project root',
|
||||
@@ -336,7 +370,7 @@ describe('Storage – additional helpers', () => {
|
||||
name: 'escaping relative path throws',
|
||||
customDir: '../escaped-plans',
|
||||
expected: '',
|
||||
expectedError: `Custom plans directory '../escaped-plans' resolves to '${resolveToRealPath(path.resolve(projectRoot, '../escaped-plans'))}', which is outside the project root '${resolveToRealPath(projectRoot)}'.`,
|
||||
expectedError: `Path '../escaped-plans' resolves to '${resolveToRealPath(path.resolve(projectRoot, '../escaped-plans'))}', which is outside the project root '${resolveToRealPath(projectRoot)}'.`,
|
||||
},
|
||||
{
|
||||
name: 'hidden directory starting with ..',
|
||||
@@ -356,7 +390,7 @@ describe('Storage – additional helpers', () => {
|
||||
return () => vi.mocked(fs.realpathSync).mockRestore();
|
||||
},
|
||||
expected: '',
|
||||
expectedError: `Custom plans directory 'symlink-to-outside' resolves to '${path.resolve('/outside/project/root')}', which is outside the project root '${resolveToRealPath(projectRoot)}'.`,
|
||||
expectedError: `Path 'symlink-to-outside' resolves to '${path.resolve('/outside/project/root')}', which is outside the project root '${resolveToRealPath(projectRoot)}'.`,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -320,22 +320,54 @@ export class Storage {
|
||||
return path.join(this.getProjectTempDir(), 'tracker');
|
||||
}
|
||||
|
||||
getPlansDir(): string {
|
||||
if (this.customPlansDir) {
|
||||
const resolvedPath = path.resolve(
|
||||
this.getProjectRoot(),
|
||||
this.customPlansDir,
|
||||
);
|
||||
const realProjectRoot = resolveToRealPath(this.getProjectRoot());
|
||||
const realResolvedPath = resolveToRealPath(resolvedPath);
|
||||
|
||||
if (!isSubpath(realProjectRoot, realResolvedPath)) {
|
||||
throw new Error(
|
||||
`Custom plans directory '${this.customPlansDir}' resolves to '${realResolvedPath}', which is outside the project root '${realProjectRoot}'.`,
|
||||
);
|
||||
/**
|
||||
* Resolves a path securely relative to the project root.
|
||||
* Throws if the path attempts to escape the workspace (e.g. via ../).
|
||||
*/
|
||||
resolveWorkspaceRelativePath(customPath: string): string {
|
||||
const isWindows = os.platform() === 'win32';
|
||||
// Normalize tilde to homedir
|
||||
let expandedPath = customPath;
|
||||
if (
|
||||
expandedPath.startsWith('~/') ||
|
||||
(isWindows && expandedPath.startsWith('~\\'))
|
||||
) {
|
||||
const home = homedir();
|
||||
if (home) {
|
||||
expandedPath = path.join(home, expandedPath.slice(2));
|
||||
}
|
||||
} else if (expandedPath === '~') {
|
||||
expandedPath = homedir() || expandedPath;
|
||||
}
|
||||
|
||||
return resolvedPath;
|
||||
const resolvedPath = path.resolve(this.getProjectRoot(), expandedPath);
|
||||
const realProjectRoot = resolveToRealPath(this.getProjectRoot());
|
||||
|
||||
// We cannot use resolveToRealPath on resolvedPath directly if it doesn't exist yet
|
||||
// To prevent traversal attacks via symlinks that don't exist yet, we check the un-real resolved path
|
||||
// against the real project root, assuming the resolved path doesn't contain unresolved symlinks escaping the root.
|
||||
// However, if it exists, we resolve it.
|
||||
let realResolvedPath = resolvedPath;
|
||||
try {
|
||||
realResolvedPath = resolveToRealPath(resolvedPath);
|
||||
} catch {
|
||||
// Path doesn't exist, use the absolute normalized path
|
||||
realResolvedPath = normalizePath(resolvedPath);
|
||||
}
|
||||
|
||||
if (!isSubpath(realProjectRoot, realResolvedPath)) {
|
||||
throw new Error(
|
||||
`Path '${customPath}' resolves to '${realResolvedPath}', which is outside the project root '${realProjectRoot}'.`,
|
||||
);
|
||||
}
|
||||
|
||||
return resolvedPath;
|
||||
}
|
||||
|
||||
getPlansDir(customDir?: string): string {
|
||||
const dirToResolve = customDir ?? this.customPlansDir;
|
||||
if (dirToResolve) {
|
||||
return this.resolveWorkspaceRelativePath(dirToResolve);
|
||||
}
|
||||
return this.getProjectTempPlansDir();
|
||||
}
|
||||
|
||||
@@ -192,12 +192,28 @@ export class PromptProvider {
|
||||
),
|
||||
planningWorkflow: this.withSection(
|
||||
'planningWorkflow',
|
||||
() => ({
|
||||
interactive: interactiveMode,
|
||||
planModeToolsList,
|
||||
plansDir: context.config.storage.getPlansDir(),
|
||||
approvedPlanPath: context.config.getApprovedPlanPath(),
|
||||
}),
|
||||
() => {
|
||||
let plansDir = '';
|
||||
const activeExt = context.config.activeExtensionName;
|
||||
let customDir: string | undefined;
|
||||
if (activeExt) {
|
||||
customDir = context.config.getExtensionSetting<string>(
|
||||
activeExt,
|
||||
'plan.directory',
|
||||
);
|
||||
}
|
||||
try {
|
||||
plansDir = context.config.storage.getPlansDir(customDir);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return {
|
||||
interactive: interactiveMode,
|
||||
planModeToolsList,
|
||||
plansDir,
|
||||
approvedPlanPath: context.config.getApprovedPlanPath(),
|
||||
};
|
||||
},
|
||||
isPlanMode,
|
||||
),
|
||||
operationalGuidelines: this.withSection(
|
||||
|
||||
@@ -603,6 +603,18 @@ export class ChatRecordingService {
|
||||
}
|
||||
}
|
||||
|
||||
recordActiveExtensionName(activeExtensionName: string | undefined): void {
|
||||
if (!this.conversationFile) return;
|
||||
try {
|
||||
this.updateMetadata({ activeExtensionName });
|
||||
} catch (error) {
|
||||
debugLogger.error(
|
||||
'Error saving active extension to chat history.',
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
getConversation(): ConversationRecord | null {
|
||||
if (!this.conversationFile) return null;
|
||||
return this.cachedConversation;
|
||||
|
||||
@@ -87,6 +87,8 @@ export interface ConversationRecord {
|
||||
directories?: string[];
|
||||
/** The kind of conversation (main agent or subagent) */
|
||||
kind?: 'main' | 'subagent';
|
||||
/** The last active extension context for the session */
|
||||
activeExtensionName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -466,10 +466,22 @@ class EditToolInvocation
|
||||
);
|
||||
if (this.config.isPlanMode()) {
|
||||
const safeFilename = path.basename(this.params.file_path);
|
||||
this.resolvedPath = path.join(
|
||||
this.config.storage.getPlansDir(),
|
||||
safeFilename,
|
||||
);
|
||||
let customDir: string | undefined;
|
||||
const activeExt = this.config.activeExtensionName;
|
||||
if (activeExt) {
|
||||
customDir = this.config.getExtensionSetting<string>(
|
||||
activeExt,
|
||||
'plan.directory',
|
||||
);
|
||||
}
|
||||
try {
|
||||
this.resolvedPath = path.join(
|
||||
this.config.storage.getPlansDir(customDir),
|
||||
safeFilename,
|
||||
);
|
||||
} catch {
|
||||
this.resolvedPath = ''; // Handled safely downstream
|
||||
}
|
||||
} else if (!path.isAbsolute(this.params.file_path)) {
|
||||
const result = correctPath(this.params.file_path, this.config);
|
||||
if (result.success) {
|
||||
|
||||
@@ -39,6 +39,8 @@ describe('EnterPlanModeTool', () => {
|
||||
|
||||
mockConfig = {
|
||||
setApprovalMode: vi.fn(),
|
||||
getExtensions: vi.fn().mockReturnValue([]),
|
||||
getExtensionSetting: vi.fn(),
|
||||
storage: {
|
||||
getPlansDir: vi.fn().mockReturnValue('/mock/plans/dir'),
|
||||
} as unknown as Config['storage'],
|
||||
@@ -132,15 +134,71 @@ describe('EnterPlanModeTool', () => {
|
||||
expect(result.returnDisplay).toBe('Switching to Plan mode');
|
||||
});
|
||||
|
||||
it('should create plans directory if it does not exist', async () => {
|
||||
const invocation = tool.build({});
|
||||
it('should create custom plan directories for active extensions', async () => {
|
||||
vi.mocked(mockConfig.getExtensions!).mockReturnValue([
|
||||
{
|
||||
name: 'ext-a',
|
||||
isActive: true,
|
||||
} as import('../config/config.js').GeminiCLIExtension,
|
||||
{
|
||||
name: 'ext-b',
|
||||
isActive: false,
|
||||
} as import('../config/config.js').GeminiCLIExtension,
|
||||
]);
|
||||
vi.mocked(mockConfig.getExtensionSetting!).mockImplementation(
|
||||
(name, setting) => {
|
||||
if (name === 'ext-a' && setting === 'plan.directory')
|
||||
return '.ext-a-plans';
|
||||
return undefined;
|
||||
},
|
||||
);
|
||||
vi.mocked(mockConfig.storage!.getPlansDir).mockImplementation(
|
||||
(customDir?: string) => {
|
||||
if (customDir === '.ext-a-plans') return '/mock/plans/ext-a-plans';
|
||||
return '/mock/plans/dir';
|
||||
},
|
||||
);
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
|
||||
const invocation = tool.build({});
|
||||
await invocation.execute({ abortSignal: new AbortController().signal });
|
||||
|
||||
expect(fs.mkdirSync).toHaveBeenCalledWith('/mock/plans/dir', {
|
||||
recursive: true,
|
||||
});
|
||||
expect(fs.mkdirSync).toHaveBeenCalledWith('/mock/plans/ext-a-plans', {
|
||||
recursive: true,
|
||||
});
|
||||
expect(fs.mkdirSync).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should ignore validation failures for extension-specific plan directories', async () => {
|
||||
vi.mocked(mockConfig.getExtensions!).mockReturnValue([
|
||||
{
|
||||
name: 'ext-a',
|
||||
isActive: true,
|
||||
} as import('../config/config.js').GeminiCLIExtension,
|
||||
]);
|
||||
vi.mocked(mockConfig.getExtensionSetting!).mockReturnValue(
|
||||
'../outside-workspace',
|
||||
);
|
||||
vi.mocked(mockConfig.storage!.getPlansDir).mockImplementation(
|
||||
(customDir?: string) => {
|
||||
if (customDir === '../outside-workspace')
|
||||
throw new Error('Path traversal detected');
|
||||
return '/mock/plans/dir';
|
||||
},
|
||||
);
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
|
||||
const invocation = tool.build({});
|
||||
await invocation.execute({ abortSignal: new AbortController().signal });
|
||||
|
||||
// Should only create the default one
|
||||
expect(fs.mkdirSync).toHaveBeenCalledWith('/mock/plans/dir', {
|
||||
recursive: true,
|
||||
});
|
||||
expect(fs.mkdirSync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should include optional reason in output display but not in llmContent', async () => {
|
||||
|
||||
@@ -125,16 +125,46 @@ export class EnterPlanModeInvocation extends BaseToolInvocation<
|
||||
|
||||
this.config.setApprovalMode(ApprovalMode.PLAN);
|
||||
|
||||
// Ensure plans directory exists so that the agent can write the plan file.
|
||||
// Ensure plans directories exist so that the agent can write plan files.
|
||||
// In sandboxed environments, the plans directory must exist on the host
|
||||
// before it can be bound/allowed in the sandbox.
|
||||
const plansDir = this.config.storage.getPlansDir();
|
||||
if (!fs.existsSync(plansDir)) {
|
||||
try {
|
||||
fs.mkdirSync(plansDir, { recursive: true });
|
||||
} catch (e) {
|
||||
// Log error but don't fail; write_file will try again later
|
||||
debugLogger.error(`Failed to create plans directory: ${plansDir}`, e);
|
||||
const dirsToCreate = new Set<string>();
|
||||
|
||||
// Always ensure the default plans directory exists
|
||||
try {
|
||||
dirsToCreate.add(this.config.storage.getPlansDir(undefined));
|
||||
} catch {
|
||||
// Ignore if default somehow throws (unlikely)
|
||||
}
|
||||
|
||||
// Ensure extension-specific plan directories exist
|
||||
for (const ext of this.config.getExtensions()) {
|
||||
if (!ext.isActive) continue;
|
||||
|
||||
const customDir = this.config.getExtensionSetting<string>(
|
||||
ext.name,
|
||||
'plan.directory',
|
||||
);
|
||||
if (customDir) {
|
||||
try {
|
||||
dirsToCreate.add(this.config.storage.getPlansDir(customDir));
|
||||
} catch (e) {
|
||||
debugLogger.warn(
|
||||
`Invalid custom plan directory '${customDir}' for extension '${ext.name}':`,
|
||||
e,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const dir of dirsToCreate) {
|
||||
if (!fs.existsSync(dir)) {
|
||||
try {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
} catch (e) {
|
||||
// Log error but don't fail; write_file will try again later
|
||||
debugLogger.error(`Failed to create plans directory: ${dir}`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,17 @@ export class ExitPlanModeTool extends BaseDeclarativeTool<
|
||||
);
|
||||
}
|
||||
|
||||
private getResolvedPlansDir(): string {
|
||||
let customDir: string | undefined;
|
||||
const activeExt = this.config.activeExtensionName;
|
||||
if (activeExt) {
|
||||
customDir = this.config.getExtensionSetting<string>(
|
||||
activeExt,
|
||||
'plan.directory',
|
||||
);
|
||||
}
|
||||
return this.config.storage.getPlansDir(customDir);
|
||||
}
|
||||
protected override validateToolParamValues(
|
||||
params: ExitPlanModeParams,
|
||||
): string | null {
|
||||
@@ -61,11 +72,14 @@ export class ExitPlanModeTool extends BaseDeclarativeTool<
|
||||
}
|
||||
|
||||
const safeFilename = path.basename(params.plan_filename);
|
||||
const plansDir = resolveToRealPath(this.config.storage.getPlansDir());
|
||||
const resolvedPath = path.join(
|
||||
this.config.storage.getPlansDir(),
|
||||
safeFilename,
|
||||
);
|
||||
let plansDir: string;
|
||||
let resolvedPath: string;
|
||||
try {
|
||||
plansDir = resolveToRealPath(this.getResolvedPlansDir());
|
||||
resolvedPath = path.join(this.getResolvedPlansDir(), safeFilename);
|
||||
} catch {
|
||||
return 'Failed to read plan directory: Path traversal attempt detected.';
|
||||
}
|
||||
|
||||
const realPath = resolveToRealPath(resolvedPath);
|
||||
|
||||
@@ -114,6 +128,18 @@ export class ExitPlanModeInvocation extends BaseToolInvocation<
|
||||
super(params, messageBus, toolName, toolDisplayName);
|
||||
}
|
||||
|
||||
private getResolvedPlansDir(): string {
|
||||
let customDir: string | undefined;
|
||||
const activeExt = this.config.activeExtensionName;
|
||||
if (activeExt) {
|
||||
customDir = this.config.getExtensionSetting<string>(
|
||||
activeExt,
|
||||
'plan.directory',
|
||||
);
|
||||
}
|
||||
return this.config.storage.getPlansDir(customDir);
|
||||
}
|
||||
|
||||
override async shouldConfirmExecute(
|
||||
abortSignal: AbortSignal,
|
||||
): Promise<ToolExitPlanModeConfirmationDetails | false> {
|
||||
@@ -121,7 +147,7 @@ export class ExitPlanModeInvocation extends BaseToolInvocation<
|
||||
|
||||
const pathError = await validatePlanPath(
|
||||
this.params.plan_filename,
|
||||
this.config.storage.getPlansDir(),
|
||||
this.getResolvedPlansDir(),
|
||||
);
|
||||
if (pathError) {
|
||||
this.planValidationError = pathError;
|
||||
@@ -171,7 +197,7 @@ export class ExitPlanModeInvocation extends BaseToolInvocation<
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return `Requesting plan approval for: ${path.join(this.config.storage.getPlansDir(), this.params.plan_filename)}`;
|
||||
return `Requesting plan approval for: ${path.join(this.getResolvedPlansDir(), this.params.plan_filename)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -180,7 +206,7 @@ export class ExitPlanModeInvocation extends BaseToolInvocation<
|
||||
*/
|
||||
private getResolvedPlanPath(): string {
|
||||
const safeFilename = path.basename(this.params.plan_filename);
|
||||
return path.join(this.config.storage.getPlansDir(), safeFilename);
|
||||
return path.join(this.getResolvedPlansDir(), safeFilename);
|
||||
}
|
||||
|
||||
async execute({ abortSignal: _signal }: ExecuteOptions): Promise<ToolResult> {
|
||||
|
||||
@@ -634,7 +634,23 @@ export class ToolRegistry {
|
||||
*/
|
||||
getFunctionDeclarations(modelId?: string): FunctionDeclaration[] {
|
||||
const isPlanMode = this.config.getApprovalMode() === ApprovalMode.PLAN;
|
||||
const plansDir = this.config.storage.getPlansDir();
|
||||
|
||||
let plansDir: string | undefined;
|
||||
if (isPlanMode) {
|
||||
let customDir: string | undefined;
|
||||
const activeExt = this.config.activeExtensionName;
|
||||
if (activeExt) {
|
||||
customDir = this.config.getExtensionSetting<string>(
|
||||
activeExt,
|
||||
'plan.directory',
|
||||
);
|
||||
}
|
||||
try {
|
||||
plansDir = this.config.storage.getPlansDir(customDir);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
const declarations: FunctionDeclaration[] = [];
|
||||
const seenNames = new Set<string>();
|
||||
|
||||
@@ -38,6 +38,7 @@ export interface ExecuteOptions {
|
||||
updateOutput?: (output: ToolLiveOutput) => void;
|
||||
shellExecutionConfig?: ShellExecutionConfig;
|
||||
setExecutionIdCallback?: (executionId: number) => void;
|
||||
activeExtensionName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -169,10 +169,22 @@ class WriteFileToolInvocation extends BaseToolInvocation<
|
||||
|
||||
if (this.config.isPlanMode()) {
|
||||
const safeFilename = path.basename(this.params.file_path);
|
||||
this.resolvedPath = path.join(
|
||||
this.config.storage.getPlansDir(),
|
||||
safeFilename,
|
||||
);
|
||||
let customDir: string | undefined;
|
||||
const activeExt = this.config.activeExtensionName;
|
||||
if (activeExt) {
|
||||
customDir = this.config.getExtensionSetting<string>(
|
||||
activeExt,
|
||||
'plan.directory',
|
||||
);
|
||||
}
|
||||
try {
|
||||
this.resolvedPath = path.join(
|
||||
this.config.storage.getPlansDir(customDir),
|
||||
safeFilename,
|
||||
);
|
||||
} catch {
|
||||
this.resolvedPath = ''; // handled safely downstream
|
||||
}
|
||||
} else {
|
||||
this.resolvedPath = path.resolve(
|
||||
this.config.getTargetDir(),
|
||||
|
||||
Reference in New Issue
Block a user