Compare commits

...

7 Commits

29 changed files with 820 additions and 104 deletions
+7
View File
@@ -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) {
+6
View File
@@ -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;
+22 -15
View File
@@ -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;
+50
View File
@@ -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();
+46 -13
View File
@@ -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();
});
});
+35 -3
View File
@@ -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' });
});
});
});
+29
View File
@@ -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.
+37 -3
View File
@@ -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)}'.`,
},
];
+46 -14
View File
@@ -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();
}
+22 -6
View File
@@ -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;
}
/**
+16 -4
View File
@@ -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 () => {
+38 -8
View File
@@ -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);
}
}
}
+34 -8
View File
@@ -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> {
+17 -1
View File
@@ -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>();
+1
View File
@@ -38,6 +38,7 @@ export interface ExecuteOptions {
updateOutput?: (output: ToolLiveOutput) => void;
shellExecutionConfig?: ShellExecutionConfig;
setExecutionIdCallback?: (executionId: number) => void;
activeExtensionName?: string;
}
/**
+16 -4
View File
@@ -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(),