mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-23 00:01:24 -07:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9ff03ddd20 | |||
| 50d8880e9c | |||
| 495931be79 | |||
| df8f1a8f03 | |||
| 7c0cfd53df | |||
| 432df7982c | |||
| 35fd166bce | |||
| e42b762553 | |||
| 76c28142eb | |||
| 7ab4c3d61f | |||
| ded474c2d0 | |||
| 3256b16039 | |||
| 5e89760856 | |||
| 6559fdbc31 | |||
| a5c2bf81f4 | |||
| b5d92caf89 | |||
| 81c74e1483 | |||
| b2f7c157ce | |||
| 0a8195fb3a | |||
| 058b5e31b4 | |||
| 402a96a519 | |||
| bdf90e9985 |
@@ -3796,7 +3796,8 @@ describe('loadCliConfig mcpEnabled', () => {
|
||||
]);
|
||||
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
expect(config.storage.getPlansDir()).toContain('ext-plans-dir');
|
||||
config.setActiveExtensionContext('ext-plan');
|
||||
expect(config.getPlansDir()).toContain('ext-plans-dir');
|
||||
});
|
||||
|
||||
it('should NOT use plan directory from active extension when user has specified one', async () => {
|
||||
|
||||
@@ -610,9 +610,12 @@ export async function loadCliConfig(
|
||||
});
|
||||
await extensionManager.loadExtensions();
|
||||
|
||||
const extensionPlanSettings = extensionManager
|
||||
.getExtensions()
|
||||
.find((ext) => ext.isActive && ext.plan?.directory)?.plan;
|
||||
const extensionPlanDirs: Record<string, string> = {};
|
||||
for (const ext of extensionManager.getExtensions()) {
|
||||
if (ext.isActive && ext.plan?.directory) {
|
||||
extensionPlanDirs[ext.name] = ext.plan.directory;
|
||||
}
|
||||
}
|
||||
|
||||
const experimentalJitContext = settings.experimental.jitContext;
|
||||
|
||||
@@ -982,9 +985,8 @@ export async function loadCliConfig(
|
||||
plan: settings.general?.plan?.enabled ?? true,
|
||||
tracker: settings.experimental?.taskTracker,
|
||||
directWebFetch: settings.experimental?.directWebFetch,
|
||||
planSettings: settings.general?.plan?.directory
|
||||
? settings.general.plan
|
||||
: (extensionPlanSettings ?? settings.general?.plan),
|
||||
planSettings: settings.general?.plan,
|
||||
extensionPlanDirs,
|
||||
enableEventDrivenScheduler: true,
|
||||
skillsSupport: settings.skills?.enabled ?? true,
|
||||
disabledSkills: settings.skills?.disabled,
|
||||
|
||||
@@ -23,6 +23,8 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
|
||||
isInteractive: vi.fn(() => false),
|
||||
isInitialized: vi.fn(() => true),
|
||||
setTerminalBackground: vi.fn(),
|
||||
setActiveExtensionContext: vi.fn(),
|
||||
hasExtensionPlanDir: vi.fn(() => true),
|
||||
storage: {
|
||||
getProjectTempDir: vi.fn().mockReturnValue('/tmp/gemini-test'),
|
||||
initialize: vi.fn().mockResolvedValue(undefined),
|
||||
|
||||
@@ -3195,9 +3195,7 @@ describe('AppContainer State Management', () => {
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Overflow Hint Handling', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
@@ -1347,10 +1347,11 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
isToolExecuting(pendingHistoryItems);
|
||||
|
||||
if (isSlash && isAgentRunning) {
|
||||
const { commandToExecute } = parseSlashCommand(
|
||||
const parsedCommand = parseSlashCommand(
|
||||
submittedValue,
|
||||
slashCommands ?? [],
|
||||
);
|
||||
const commandToExecute = parsedCommand.commandToExecute;
|
||||
if (commandToExecute?.isSafeConcurrent) {
|
||||
void handleSlashCommand(submittedValue);
|
||||
addInput(submittedValue);
|
||||
|
||||
@@ -45,6 +45,7 @@ describe('clearCommand', () => {
|
||||
fireSessionEndEvent: vi.fn().mockResolvedValue(undefined),
|
||||
fireSessionStartEvent: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
setActiveExtensionContext: vi.fn(),
|
||||
injectionService: {
|
||||
clear: mockHintClear,
|
||||
},
|
||||
|
||||
@@ -30,8 +30,9 @@ export const clearCommand: SlashCommand = {
|
||||
await hookSystem.fireSessionEndEvent(SessionEndReason.Clear);
|
||||
}
|
||||
|
||||
// Reset user steering hints
|
||||
// Reset user steering hints and extension context
|
||||
config?.injectionService.clear();
|
||||
config?.setActiveExtensionContext(undefined);
|
||||
|
||||
// Start a new conversation recording with a new session ID
|
||||
// We MUST do this before calling resetChat() so the new ChatRecordingService
|
||||
|
||||
@@ -56,8 +56,9 @@ describe('planCommand', () => {
|
||||
config: {
|
||||
isPlanEnabled: vi.fn(),
|
||||
setApprovalMode: vi.fn(),
|
||||
getApprovedPlanPath: vi.fn(),
|
||||
getApprovalMode: vi.fn(),
|
||||
getApprovedPlanPath: vi.fn(),
|
||||
getPlansDir: vi.fn(),
|
||||
getFileSystemService: vi.fn(),
|
||||
storage: {
|
||||
getPlansDir: vi.fn().mockReturnValue('/mock/plans/dir'),
|
||||
|
||||
@@ -82,7 +82,7 @@ export const planCommand: SlashCommand = {
|
||||
try {
|
||||
const content = await processSingleFileContent(
|
||||
approvedPlanPath,
|
||||
config.storage.getPlansDir(),
|
||||
config.getPlansDir(),
|
||||
config.getFileSystemService(),
|
||||
);
|
||||
const fileName = path.basename(approvedPlanPath);
|
||||
|
||||
@@ -993,4 +993,103 @@ describe('useSlashCommandProcessor', () => {
|
||||
expect(result.current.slashCommands).toEqual([newCommand]),
|
||||
);
|
||||
});
|
||||
describe('Active Extension Context Switching', () => {
|
||||
it('sets active extension context when a command with a plan dir is executed', async () => {
|
||||
const extensionCommand = createTestCommand({
|
||||
name: 'conductor:setup',
|
||||
extensionName: 'conductor',
|
||||
action: vi.fn(),
|
||||
});
|
||||
|
||||
const spyHasPlanDir = vi
|
||||
.spyOn(mockConfig, 'hasExtensionPlanDir')
|
||||
.mockReturnValue(true);
|
||||
const spySetContext = vi.spyOn(mockConfig, 'setActiveExtensionContext');
|
||||
|
||||
const hook = await setupProcessorHook({
|
||||
builtinCommands: [extensionCommand],
|
||||
});
|
||||
|
||||
await waitFor(() => expect(hook.current.slashCommands!.length).toBe(1));
|
||||
|
||||
await act(async () => {
|
||||
await hook.current.handleSlashCommand('/conductor:setup');
|
||||
});
|
||||
|
||||
expect(spyHasPlanDir).toHaveBeenCalledWith('conductor');
|
||||
expect(spySetContext).toHaveBeenCalledWith('conductor');
|
||||
});
|
||||
|
||||
it('clears active extension context when a command WITHOUT a plan dir is executed', async () => {
|
||||
const extensionCommand = createTestCommand({
|
||||
name: 'other:cmd',
|
||||
extensionName: 'other',
|
||||
action: vi.fn(),
|
||||
});
|
||||
|
||||
const spyHasPlanDir = vi
|
||||
.spyOn(mockConfig, 'hasExtensionPlanDir')
|
||||
.mockReturnValue(false);
|
||||
const spySetContext = vi.spyOn(mockConfig, 'setActiveExtensionContext');
|
||||
|
||||
const hook = await setupProcessorHook({
|
||||
builtinCommands: [extensionCommand],
|
||||
});
|
||||
|
||||
await waitFor(() => expect(hook.current.slashCommands!.length).toBe(1));
|
||||
|
||||
await act(async () => {
|
||||
await hook.current.handleSlashCommand('/other:cmd');
|
||||
});
|
||||
|
||||
expect(spyHasPlanDir).toHaveBeenCalledWith('other');
|
||||
expect(spySetContext).toHaveBeenCalledWith(undefined);
|
||||
});
|
||||
|
||||
it('handles a sequence of context switches between extensions and default plan mode', async () => {
|
||||
const extA = createTestCommand({
|
||||
name: 'extA',
|
||||
extensionName: 'extA',
|
||||
action: vi.fn(),
|
||||
});
|
||||
const extB = createTestCommand({
|
||||
name: 'extB',
|
||||
extensionName: 'extB',
|
||||
action: vi.fn(),
|
||||
});
|
||||
const planCmd = createTestCommand({
|
||||
name: 'plan',
|
||||
action: vi.fn(),
|
||||
});
|
||||
|
||||
const spySetContext = vi.spyOn(mockConfig, 'setActiveExtensionContext');
|
||||
vi.spyOn(mockConfig, 'hasExtensionPlanDir').mockReturnValue(true);
|
||||
|
||||
const hook = await setupProcessorHook({
|
||||
builtinCommands: [extA, extB, planCmd],
|
||||
});
|
||||
|
||||
await waitFor(() => expect(hook.current.slashCommands!.length).toBe(3));
|
||||
|
||||
// 1. Run Ext A
|
||||
await act(async () => {
|
||||
await hook.current.handleSlashCommand('/extA');
|
||||
});
|
||||
expect(spySetContext).toHaveBeenLastCalledWith('extA');
|
||||
|
||||
// 2. Run Ext B
|
||||
await act(async () => {
|
||||
await hook.current.handleSlashCommand('/extB');
|
||||
});
|
||||
expect(spySetContext).toHaveBeenLastCalledWith('extB');
|
||||
|
||||
// 3. Run /help (Concurrent global command)
|
||||
spySetContext.mockClear();
|
||||
await act(async () => {
|
||||
await hook.current.handleSlashCommand('/help');
|
||||
});
|
||||
// A concurrent command should NOT modify the active extension context
|
||||
expect(spySetContext).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -368,8 +368,17 @@ export const useSlashCommandProcessor = (
|
||||
commandToExecute,
|
||||
args,
|
||||
canonicalPath: resolvedCommandPath,
|
||||
extensionContext,
|
||||
} = parseSlashCommand(trimmed, commands);
|
||||
|
||||
if (config && commandToExecute && !commandToExecute.isSafeConcurrent) {
|
||||
config.setActiveExtensionContext(
|
||||
extensionContext && config.hasExtensionPlanDir(extensionContext)
|
||||
? extensionContext
|
||||
: undefined,
|
||||
);
|
||||
}
|
||||
|
||||
// If the input doesn't match any known command, check if MCP servers
|
||||
// are still loading (the command might come from an MCP server).
|
||||
// Otherwise, treat it as regular text input (e.g. file paths like
|
||||
|
||||
@@ -10,6 +10,7 @@ export type ParsedSlashCommand = {
|
||||
commandToExecute: SlashCommand | undefined;
|
||||
args: string;
|
||||
canonicalPath: string[];
|
||||
extensionContext?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -69,6 +70,8 @@ export const parseSlashCommand = (
|
||||
|
||||
const args = parts.slice(pathIndex).join(' ');
|
||||
|
||||
const extensionContext = commandToExecute?.extensionName;
|
||||
|
||||
// Backtrack if the matched (sub)command doesn't take arguments but some were provided,
|
||||
// AND the parent command is capable of handling them.
|
||||
if (
|
||||
@@ -82,8 +85,9 @@ export const parseSlashCommand = (
|
||||
commandToExecute: parentCommand,
|
||||
args: parts.slice(pathIndex - 1).join(' '),
|
||||
canonicalPath: canonicalPath.slice(0, -1),
|
||||
extensionContext: parentCommand.extensionName,
|
||||
};
|
||||
}
|
||||
|
||||
return { commandToExecute, args, canonicalPath };
|
||||
return { commandToExecute, args, canonicalPath, extensionContext };
|
||||
};
|
||||
|
||||
@@ -3275,6 +3275,9 @@ describe('Plans Directory Initialization', () => {
|
||||
debugMode: false,
|
||||
model: 'test-model',
|
||||
cwd: '/tmp/test',
|
||||
planSettings: {
|
||||
directory: 'plans',
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -3283,11 +3286,12 @@ describe('Plans Directory Initialization', () => {
|
||||
|
||||
afterEach(() => {
|
||||
vi.mocked(fs.promises.mkdir).mockRestore();
|
||||
vi.mocked(fs.promises.access).mockRestore?.();
|
||||
vi.mocked(fs.mkdirSync).mockRestore?.();
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true); // Reset to default mock behavior
|
||||
});
|
||||
|
||||
it('should add plans directory to workspace context if it exists', async () => {
|
||||
vi.spyOn(fs.promises, 'access').mockResolvedValue(undefined);
|
||||
it('should not eagerly create plans directory during initialization', async () => {
|
||||
vi.spyOn(fs, 'mkdirSync').mockReturnValue(undefined);
|
||||
const config = new Config({
|
||||
...baseParams,
|
||||
plan: true,
|
||||
@@ -3295,18 +3299,84 @@ describe('Plans Directory Initialization', () => {
|
||||
|
||||
await config.initialize();
|
||||
|
||||
const plansDir = config.storage.getPlansDir();
|
||||
// Should NOT create the directory eagerly
|
||||
expect(fs.promises.mkdir).not.toHaveBeenCalled();
|
||||
// Should check if it exists
|
||||
expect(fs.promises.access).toHaveBeenCalledWith(plansDir);
|
||||
expect(fs.mkdirSync).not.toHaveBeenCalled();
|
||||
|
||||
// Using storage directly to avoid triggering creation
|
||||
const plansDir = config.storage.getPlansDir();
|
||||
const context = config.getWorkspaceContext();
|
||||
expect(context.getDirectories()).not.toContain(plansDir);
|
||||
});
|
||||
|
||||
it('should create plans directory and add it to workspace context when getPlansDir is called', async () => {
|
||||
vi.spyOn(fs, 'mkdirSync').mockReturnValue(undefined);
|
||||
const config = new Config({
|
||||
...baseParams,
|
||||
plan: true,
|
||||
});
|
||||
|
||||
await config.initialize();
|
||||
const plansDir = config.getPlansDir();
|
||||
|
||||
expect(fs.mkdirSync).toHaveBeenCalledWith(plansDir, {
|
||||
recursive: true,
|
||||
});
|
||||
|
||||
const context = config.getWorkspaceContext();
|
||||
expect(context.getDirectories()).toContain(plansDir);
|
||||
});
|
||||
|
||||
it('should NOT add plans directory to workspace context if it does not exist', async () => {
|
||||
vi.spyOn(fs.promises, 'access').mockRejectedValue({ code: 'ENOENT' });
|
||||
it('should gracefully handle existing directories by relying on mkdirSync recursive: true', async () => {
|
||||
vi.spyOn(fs, 'mkdirSync').mockReturnValue(undefined);
|
||||
const config = new Config({
|
||||
...baseParams,
|
||||
plan: true,
|
||||
});
|
||||
|
||||
await config.initialize();
|
||||
const plansDir = config.getPlansDir();
|
||||
|
||||
// mkdirSync should be called unconditionally
|
||||
expect(fs.mkdirSync).toHaveBeenCalledWith(plansDir, { recursive: true });
|
||||
|
||||
// It MUST still register the directory
|
||||
const context = config.getWorkspaceContext();
|
||||
expect(context.getDirectories()).toContain(plansDir);
|
||||
});
|
||||
|
||||
it('should log a warning if the plan directory path is blocked by an existing file (EEXIST)', async () => {
|
||||
const writeSpy = vi
|
||||
.spyOn(process.stderr, 'write')
|
||||
.mockImplementation(() => true);
|
||||
vi.spyOn(fs, 'mkdirSync').mockImplementation(() => {
|
||||
const err = new Error('File exists') as NodeJS.ErrnoException;
|
||||
err.code = 'EEXIST';
|
||||
throw err;
|
||||
});
|
||||
const config = new Config({
|
||||
...baseParams,
|
||||
plan: true,
|
||||
});
|
||||
|
||||
await config.initialize();
|
||||
config.getPlansDir();
|
||||
|
||||
expect(writeSpy).toHaveBeenCalledWith(
|
||||
expect.stringMatching(
|
||||
/Failed to initialize active plan directory.*File exists/,
|
||||
),
|
||||
);
|
||||
writeSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should throw a security violation and verify mkdirSync runs before realpathSync (TOCTOU mitigation)', async () => {
|
||||
const callOrder: string[] = [];
|
||||
|
||||
vi.spyOn(fs, 'mkdirSync').mockImplementation(() => {
|
||||
callOrder.push('mkdirSync');
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const config = new Config({
|
||||
...baseParams,
|
||||
plan: true,
|
||||
@@ -3314,15 +3384,90 @@ describe('Plans Directory Initialization', () => {
|
||||
|
||||
await config.initialize();
|
||||
|
||||
const plansDir = config.storage.getPlansDir();
|
||||
expect(fs.promises.mkdir).not.toHaveBeenCalled();
|
||||
expect(fs.promises.access).toHaveBeenCalledWith(plansDir);
|
||||
// Bypass Storage check so we can specifically test Config's check
|
||||
vi.spyOn(config.storage, 'getPlansDir').mockReturnValue('/tmp/test/plans');
|
||||
|
||||
const context = config.getWorkspaceContext();
|
||||
expect(context.getDirectories()).not.toContain(plansDir);
|
||||
const realpathSpy = vi
|
||||
.spyOn(fs, 'realpathSync')
|
||||
.mockImplementation((p: fs.PathLike) => {
|
||||
const pStr = p.toString();
|
||||
// Ignore the calls from storage/initialization
|
||||
if (pStr.includes('plans')) {
|
||||
callOrder.push('realpathSync');
|
||||
return '/outside/the/project/root/plans';
|
||||
}
|
||||
return pStr;
|
||||
});
|
||||
|
||||
try {
|
||||
expect(() => config.getPlansDir()).toThrow(
|
||||
/Security violation: Resolved plan directory.*is outside both the project root.*and the global configuration directory/,
|
||||
);
|
||||
expect(callOrder).toEqual(['mkdirSync', 'realpathSync']);
|
||||
} finally {
|
||||
realpathSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('should log a warning if mkdirSync fails during getPlansDir (e.g. EACCES)', async () => {
|
||||
const writeSpy = vi
|
||||
.spyOn(process.stderr, 'write')
|
||||
.mockImplementation(() => true);
|
||||
vi.spyOn(fs, 'mkdirSync').mockImplementation(() => {
|
||||
const err = new Error('Permission denied') as NodeJS.ErrnoException;
|
||||
err.code = 'EACCES';
|
||||
throw err;
|
||||
});
|
||||
const config = new Config({
|
||||
...baseParams,
|
||||
plan: true,
|
||||
});
|
||||
|
||||
await config.initialize();
|
||||
config.getPlansDir();
|
||||
|
||||
expect(writeSpy).toHaveBeenCalledWith(
|
||||
expect.stringMatching(
|
||||
/Failed to initialize active plan directory.*Permission denied/,
|
||||
),
|
||||
);
|
||||
writeSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should deduplicate and cache when multiple extensions (or default) use the same directory', async () => {
|
||||
vi.spyOn(fs, 'mkdirSync').mockReturnValue(undefined);
|
||||
const config = new Config({
|
||||
...baseParams,
|
||||
plan: true,
|
||||
});
|
||||
|
||||
await config.initialize();
|
||||
|
||||
// 1. Call for Default Plan Dir
|
||||
const defaultDir = config.getPlansDir();
|
||||
expect(fs.mkdirSync).toHaveBeenCalledTimes(1);
|
||||
|
||||
// 2. Mock an extension that happens to use the SAME directory string
|
||||
vi.spyOn(
|
||||
config as unknown as {
|
||||
getActiveExtensionPlanDir: () => string | undefined;
|
||||
},
|
||||
'getActiveExtensionPlanDir',
|
||||
).mockReturnValue(
|
||||
'plans', // This will resolve to the same path as the default in our mock setup
|
||||
);
|
||||
|
||||
const extDir = config.getPlansDir();
|
||||
|
||||
// It should be the same path
|
||||
expect(extDir).toBe(defaultDir);
|
||||
|
||||
// It should NOT have called mkdirSync a second time
|
||||
expect(fs.mkdirSync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should NOT create plans directory or add it to workspace context when plan is disabled', async () => {
|
||||
vi.spyOn(fs, 'mkdirSync').mockReturnValue(undefined);
|
||||
const config = new Config({
|
||||
...baseParams,
|
||||
plan: false,
|
||||
@@ -3330,10 +3475,12 @@ describe('Plans Directory Initialization', () => {
|
||||
|
||||
await config.initialize();
|
||||
|
||||
const plansDir = config.storage.getPlansDir();
|
||||
expect(fs.promises.mkdir).not.toHaveBeenCalledWith(plansDir, {
|
||||
recursive: true,
|
||||
});
|
||||
// Even if getPlansDir is called manually, it should NOT create the directory
|
||||
const plansDir = config.getPlansDir();
|
||||
expect(fs.mkdirSync).not.toHaveBeenCalled();
|
||||
expect(config.getWorkspaceContext().getDirectories()).not.toContain(
|
||||
plansDir,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { LRUCache } from 'mnemonist';
|
||||
import { SecurityError } from '../core/errors.js';
|
||||
import { isSubpath, resolveToRealPath } from '../utils/paths.js';
|
||||
import { SandboxPolicyManager } from '../policy/sandboxPolicyManager.js';
|
||||
import { inspect } from 'node:util';
|
||||
import process from 'node:process';
|
||||
@@ -168,7 +171,6 @@ import { SkillManager, type SkillDefinition } from '../skills/skillManager.js';
|
||||
import { startupProfiler } from '../telemetry/startupProfiler.js';
|
||||
import type { AgentDefinition } from '../agents/types.js';
|
||||
import { fetchAdminControls } from '../code_assist/admin/admin_controls.js';
|
||||
import { isSubpath, resolveToRealPath } from '../utils/paths.js';
|
||||
import { InjectionService } from './injectionService.js';
|
||||
import { ExecutionLifecycleService } from '../services/executionLifecycleService.js';
|
||||
import { WORKSPACE_POLICY_TIER } from '../policy/config.js';
|
||||
@@ -710,6 +712,7 @@ export interface ConfigParameters {
|
||||
plan?: boolean;
|
||||
tracker?: boolean;
|
||||
planSettings?: PlanSettings;
|
||||
extensionPlanDirs?: Record<string, string>;
|
||||
worktreeSettings?: WorktreeSettings;
|
||||
modelSteering?: boolean;
|
||||
onModelChange?: (model: string) => void;
|
||||
@@ -773,6 +776,16 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
private readonly extensionsEnabled: boolean;
|
||||
private mcpServers: Record<string, MCPServerConfig> | undefined;
|
||||
private readonly mcpEnablementCallbacks?: McpEnablementCallbacks;
|
||||
/**
|
||||
* The persistent context used to route tools (e.g. plans, tasks) to the correct
|
||||
* extension directory.
|
||||
* This is a persistent session state (similar to `approvalMode`). It remains active
|
||||
* across multiple LLM turns until explicitly changed by another command.
|
||||
*/
|
||||
private activeExtensionContext?: string;
|
||||
private initializedPlanDirs = new Set<string>();
|
||||
private globalGeminiDirCache = new LRUCache<string, string | undefined>(1);
|
||||
private readonly extensionPlanDirs: Record<string, string>;
|
||||
private userMemory: string | HierarchicalMemory;
|
||||
private geminiMdFileCount: number;
|
||||
private geminiMdFilePaths: string[];
|
||||
@@ -1036,6 +1049,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
this.mcpServerCommand = params.mcpServerCommand;
|
||||
this.mcpServers = params.mcpServers;
|
||||
this.mcpEnablementCallbacks = params.mcpEnablementCallbacks;
|
||||
this.extensionPlanDirs = params.extensionPlanDirs ?? {};
|
||||
this.mcpEnabled = params.mcpEnabled ?? true;
|
||||
this.extensionsEnabled = params.extensionsEnabled ?? true;
|
||||
this.allowedMcpServers = params.allowedMcpServers ?? [];
|
||||
@@ -1405,20 +1419,6 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
this.workspaceContext.addDirectory(dir);
|
||||
}
|
||||
|
||||
// Add plans directory to workspace context for plan file storage
|
||||
if (this.planEnabled) {
|
||||
const plansDir = this.storage.getPlansDir();
|
||||
try {
|
||||
await fs.promises.access(plansDir);
|
||||
this.workspaceContext.addDirectory(plansDir);
|
||||
} catch {
|
||||
// Directory does not exist yet, so we don't add it to the workspace context.
|
||||
// It will be created when the first plan is written. Since custom plan
|
||||
// directories must be within the project root, they are automatically
|
||||
// covered by the project-wide file discovery once created.
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize centralized FileDiscoveryService
|
||||
const discoverToolsHandle = startupProfiler.start('discover_tools');
|
||||
this.getFileService();
|
||||
@@ -2238,6 +2238,121 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
return this.mcpEnabled;
|
||||
}
|
||||
|
||||
getActiveExtensionContext(): string | undefined {
|
||||
return this.activeExtensionContext;
|
||||
}
|
||||
|
||||
setActiveExtensionContext(context: string | undefined): void {
|
||||
this.activeExtensionContext = context;
|
||||
}
|
||||
|
||||
hasExtensionPlanDir(name: string): boolean {
|
||||
return !!this.extensionPlanDirs[name];
|
||||
}
|
||||
|
||||
getActiveExtensionPlanDir(): string | undefined {
|
||||
const context = this.getActiveExtensionContext();
|
||||
if (context) {
|
||||
return this.extensionPlanDirs[context];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
getPlansDir(): string {
|
||||
const plansDir = this.storage.getPlansDir(this.getActiveExtensionPlanDir());
|
||||
|
||||
if (this.initializedPlanDirs.has(plansDir)) {
|
||||
return plansDir;
|
||||
}
|
||||
|
||||
const realProjectRoot = this.storage.getRealProjectRoot();
|
||||
let realGlobalGeminiDir = this.globalGeminiDirCache.get('default');
|
||||
if (!this.globalGeminiDirCache.has('default')) {
|
||||
try {
|
||||
realGlobalGeminiDir = resolveToRealPath(Storage.getGlobalGeminiDir());
|
||||
} catch {
|
||||
// If we can't securely resolve the global config dir (e.g. strict EACCES permissions on ~/),
|
||||
// we gracefully degrade by not allowing it as a valid security boundary for plans.
|
||||
realGlobalGeminiDir = undefined;
|
||||
}
|
||||
this.globalGeminiDirCache.set('default', realGlobalGeminiDir);
|
||||
}
|
||||
|
||||
// 1. Lexical security check (before any filesystem mutation or return)
|
||||
// We compare purely unresolved paths here to avoid static analyzer warnings about mixing resolved and unresolved paths.
|
||||
// The physical security check happens AFTER mkdirSync.
|
||||
const unresolvedProjectRoot = path.resolve(this.storage.getProjectRoot());
|
||||
const unresolvedGlobalDir = path.resolve(Storage.getGlobalGeminiDir());
|
||||
const unresolvedPlansDir = path.resolve(plansDir);
|
||||
|
||||
if (
|
||||
!isSubpath(unresolvedProjectRoot, unresolvedPlansDir) &&
|
||||
!isSubpath(unresolvedGlobalDir, unresolvedPlansDir)
|
||||
) {
|
||||
throw new SecurityError(
|
||||
`Security violation: Plan directory '${unresolvedPlansDir}' is outside both the project root '${unresolvedProjectRoot}' and the global configuration directory.`,
|
||||
);
|
||||
}
|
||||
|
||||
// 2. We only attempt to physically create the directory if plan mode is enabled
|
||||
if (this.planEnabled) {
|
||||
let mkdirError: unknown;
|
||||
try {
|
||||
fs.mkdirSync(plansDir, { recursive: true });
|
||||
} catch (e: unknown) {
|
||||
mkdirError = e;
|
||||
}
|
||||
|
||||
// 3. Physical security check (after creation, to mitigate TOCTOU symlink attacks)
|
||||
let realPlansDir: string;
|
||||
try {
|
||||
realPlansDir = resolveToRealPath(plansDir);
|
||||
} catch (e: unknown) {
|
||||
if (
|
||||
mkdirError &&
|
||||
e instanceof Error &&
|
||||
'code' in e &&
|
||||
e.code === 'ENOENT'
|
||||
) {
|
||||
const errorMessage =
|
||||
mkdirError instanceof Error
|
||||
? mkdirError.message
|
||||
: String(mkdirError);
|
||||
process.stderr.write(
|
||||
`Failed to initialize active plan directory at '${plansDir}': ${errorMessage}\n`,
|
||||
);
|
||||
this.initializedPlanDirs.add(plansDir);
|
||||
return plansDir;
|
||||
}
|
||||
throw new SecurityError(
|
||||
`Security violation: Could not securely resolve plan directory '${plansDir}'. System error: ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!isSubpath(realProjectRoot, realPlansDir) &&
|
||||
(!realGlobalGeminiDir || !isSubpath(realGlobalGeminiDir, realPlansDir))
|
||||
) {
|
||||
throw new SecurityError(
|
||||
`Security violation: Resolved plan directory '${realPlansDir}' is outside both the project root '${realProjectRoot}' and the global configuration directory.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (mkdirError) {
|
||||
const errorMessage =
|
||||
mkdirError instanceof Error ? mkdirError.message : String(mkdirError);
|
||||
process.stderr.write(
|
||||
`Failed to initialize active plan directory at '${plansDir}': ${errorMessage}\n`,
|
||||
);
|
||||
} else {
|
||||
this.workspaceContext.addDirectory(realPlansDir);
|
||||
}
|
||||
}
|
||||
|
||||
this.initializedPlanDirs.add(plansDir);
|
||||
return plansDir;
|
||||
}
|
||||
|
||||
getMcpEnablementCallbacks(): McpEnablementCallbacks | undefined {
|
||||
return this.mcpEnablementCallbacks;
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ vi.mock('fs', async (importOriginal) => {
|
||||
});
|
||||
|
||||
import { Storage } from './storage.js';
|
||||
import { GEMINI_DIR, homedir, resolveToRealPath } from '../utils/paths.js';
|
||||
import { GEMINI_DIR, homedir } from '../utils/paths.js';
|
||||
import { ProjectRegistry } from './projectRegistry.js';
|
||||
import { StorageMigration } from './storageMigration.js';
|
||||
|
||||
@@ -103,8 +103,8 @@ describe('Storage - Security', () => {
|
||||
});
|
||||
|
||||
describe('Storage – additional helpers', () => {
|
||||
const projectRoot = resolveToRealPath(path.resolve('/tmp/project'));
|
||||
const storage = new Storage(projectRoot);
|
||||
const projectRoot = path.resolve('/tmp/project');
|
||||
let storage = new Storage(projectRoot);
|
||||
|
||||
beforeEach(() => {
|
||||
ProjectRegistry.prototype.getShortId = vi
|
||||
@@ -306,12 +306,6 @@ describe('Storage – additional helpers', () => {
|
||||
customDir: '.my-plans',
|
||||
expected: path.resolve(projectRoot, '.my-plans'),
|
||||
},
|
||||
{
|
||||
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)}'.`,
|
||||
},
|
||||
{
|
||||
name: 'absolute path that happens to be inside project root',
|
||||
customDir: path.join(projectRoot, 'internal-plans'),
|
||||
@@ -332,37 +326,39 @@ describe('Storage – additional helpers', () => {
|
||||
customDir: undefined,
|
||||
expected: () => storage.getProjectTempPlansDir(),
|
||||
},
|
||||
{
|
||||
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)}'.`,
|
||||
},
|
||||
{
|
||||
name: 'hidden directory starting with ..',
|
||||
customDir: '..plans',
|
||||
expected: path.resolve(projectRoot, '..plans'),
|
||||
},
|
||||
{
|
||||
name: 'security escape via symbolic link throws',
|
||||
customDir: 'symlink-to-outside',
|
||||
name: 'non-existent plan dir in a symlinked project root',
|
||||
customDir: 'new-plans',
|
||||
setup: () => {
|
||||
vi.mocked(fs.realpathSync).mockImplementation((p: fs.PathLike) => {
|
||||
if (p.toString().includes('symlink-to-outside')) {
|
||||
return path.resolve('/outside/project/root');
|
||||
const pStr = p.toString();
|
||||
if (pStr === projectRoot) {
|
||||
return '/private/tmp/project';
|
||||
}
|
||||
return p.toString();
|
||||
if (pStr.includes('new-plans')) {
|
||||
const err = new Error('ENOENT') as NodeJS.ErrnoException;
|
||||
err.code = 'ENOENT';
|
||||
throw err;
|
||||
}
|
||||
return pStr;
|
||||
});
|
||||
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)}'.`,
|
||||
expected: path.resolve(projectRoot, 'new-plans'),
|
||||
},
|
||||
];
|
||||
|
||||
testCases.forEach(({ name, customDir, expected, expectedError, setup }) => {
|
||||
it(`should handle ${name}`, async () => {
|
||||
const cleanup = setup?.();
|
||||
if (setup) {
|
||||
storage = new Storage(projectRoot, 'test-session');
|
||||
}
|
||||
try {
|
||||
if (name.includes('default behavior')) {
|
||||
await storage.initialize();
|
||||
|
||||
@@ -12,13 +12,11 @@ import {
|
||||
GEMINI_DIR,
|
||||
homedir,
|
||||
GOOGLE_ACCOUNTS_FILENAME,
|
||||
isSubpath,
|
||||
resolveToRealPath,
|
||||
normalizePath,
|
||||
} from '../utils/paths.js';
|
||||
import { ProjectRegistry } from './projectRegistry.js';
|
||||
import { StorageMigration } from './storageMigration.js';
|
||||
|
||||
export const OAUTH_FILE = 'oauth_creds.json';
|
||||
const TMP_DIR_NAME = 'tmp';
|
||||
const BIN_DIR_NAME = 'bin';
|
||||
@@ -32,10 +30,16 @@ export class Storage {
|
||||
private projectIdentifier: string | undefined;
|
||||
private initPromise: Promise<void> | undefined;
|
||||
private customPlansDir: string | undefined;
|
||||
private readonly realProjectRoot: string;
|
||||
|
||||
constructor(targetDir: string, sessionId?: string) {
|
||||
this.targetDir = targetDir;
|
||||
this.sessionId = sessionId;
|
||||
this.realProjectRoot = resolveToRealPath(targetDir);
|
||||
}
|
||||
|
||||
getRealProjectRoot(): string {
|
||||
return this.realProjectRoot;
|
||||
}
|
||||
|
||||
setCustomPlansDir(dir: string | undefined): void {
|
||||
@@ -320,22 +324,10 @@ 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}'.`,
|
||||
);
|
||||
}
|
||||
|
||||
return resolvedPath;
|
||||
getPlansDir(extensionPlanDir?: string): string {
|
||||
const customDir = extensionPlanDir || this.customPlansDir;
|
||||
if (customDir) {
|
||||
return path.resolve(this.getProjectRoot(), customDir);
|
||||
}
|
||||
return this.getProjectTempPlansDir();
|
||||
}
|
||||
|
||||
@@ -572,7 +572,7 @@ For example:
|
||||
|
||||
# Active Approval Mode: Plan
|
||||
|
||||
You are operating in **Plan Mode**. Your goal is to produce an implementation plan in \`/tmp/project-temp/plans/\` and get user approval before editing source code.
|
||||
You are operating in **Plan Mode**. Your goal is to produce an implementation plan in \`/tmp/plans/\` and get user approval before editing source code.
|
||||
|
||||
## Available Tools
|
||||
The following tools are available in Plan Mode:
|
||||
@@ -588,8 +588,8 @@ The following tools are available in Plan Mode:
|
||||
</available_tools>
|
||||
|
||||
## Rules
|
||||
1. **Read-Only:** You cannot modify source code. You may ONLY use read-only tools to explore, and you can only write to \`/tmp/project-temp/plans/\`. If the user asks you to modify source code directly, you MUST explain that you are in Plan Mode and must first create a plan and get approval.
|
||||
2. **Write Constraint:** \`write_file\` and \`replace\` may ONLY be used to write .md plan files to \`/tmp/project-temp/plans/\`. They cannot modify source code.
|
||||
1. **Read-Only:** You cannot modify source code. You may ONLY use read-only tools to explore, and you can only write to \`/tmp/plans/\`. If the user asks you to modify source code directly, you MUST explain that you are in Plan Mode and must first create a plan and get approval.
|
||||
2. **Write Constraint:** \`write_file\` and \`replace\` may ONLY be used to write .md plan files to \`/tmp/plans/\`. They cannot modify source code.
|
||||
3. **Efficiency:** Autonomously combine discovery and drafting phases to minimize conversational turns. If the request is ambiguous, use \`ask_user\` to clarify. Use multi-select to offer flexibility and include detailed descriptions for each option to help the user understand the implications of their choice.
|
||||
4. **Inquiries and Directives:** Distinguish between Inquiries and Directives to minimize unnecessary planning.
|
||||
- **Inquiries:** If the request is an **Inquiry** (e.g., "How does X work?"), answer directly. DO NOT create a plan.
|
||||
@@ -612,7 +612,7 @@ The depth of your consultation should be proportional to the task's complexity.
|
||||
**CRITICAL:** You MUST NOT proceed to Step 3 (Draft) or Step 4 (Review & Approval) in the same turn as your initial strategy proposal. You MUST wait for user feedback and reach a clear agreement before drafting or submitting the plan.
|
||||
|
||||
### 3. Draft
|
||||
Write the implementation plan to \`/tmp/project-temp/plans/\`. The plan's structure adapts to the task:
|
||||
Write the implementation plan to \`/tmp/plans/\`. The plan's structure adapts to the task:
|
||||
- **Simple Tasks:** Include a bulleted list of specific **Changes** and **Verification** steps.
|
||||
- **Standard Tasks:** Include an **Objective**, **Key Files & Context**, **Implementation Steps**, and **Verification & Testing**.
|
||||
- **Complex Tasks:** Include **Background & Motivation**, **Scope & Impact**, **Proposed Solution**, **Alternatives Considered**, a phased **Implementation Plan**, **Verification**, and **Migration & Rollback** strategies.
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
export class SecurityError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'SecurityError';
|
||||
}
|
||||
}
|
||||
@@ -90,9 +90,10 @@ describe('Core System Prompt (prompts.ts)', () => {
|
||||
getToolRegistry: vi.fn().mockReturnValue(mockRegistry),
|
||||
getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true),
|
||||
getSandboxEnabled: vi.fn().mockReturnValue(false),
|
||||
getPlansDir: vi.fn().mockReturnValue('/tmp/plans'),
|
||||
storage: {
|
||||
getProjectTempDir: vi.fn().mockReturnValue('/tmp/project-temp'),
|
||||
getPlansDir: vi.fn().mockReturnValue('/tmp/project-temp/plans'),
|
||||
getPlansDir: vi.fn().mockReturnValue('/tmp/plans'),
|
||||
getProjectTempTrackerDir: vi
|
||||
.fn()
|
||||
.mockReturnValue('/mock/.gemini/tmp/session/tracker'),
|
||||
@@ -423,6 +424,7 @@ describe('Core System Prompt (prompts.ts)', () => {
|
||||
getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry),
|
||||
getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true),
|
||||
getSandboxEnabled: vi.fn().mockReturnValue(false),
|
||||
getPlansDir: vi.fn().mockReturnValue('/tmp/plans'),
|
||||
storage: {
|
||||
getProjectTempDir: vi.fn().mockReturnValue('/tmp/project-temp'),
|
||||
},
|
||||
|
||||
@@ -276,7 +276,8 @@ export * from './voice/responseFormatter.js';
|
||||
|
||||
// Export types from @google/genai
|
||||
export type { Content, Part, FunctionCall } from '@google/genai';
|
||||
|
||||
// Export context types and profiles
|
||||
export * from './context/types.js';
|
||||
export * from './context/profiles.js';
|
||||
|
||||
export * from './core/errors.js';
|
||||
|
||||
@@ -61,6 +61,7 @@ describe('PromptProvider', () => {
|
||||
topicState: new TopicState(),
|
||||
getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true),
|
||||
getSandboxEnabled: vi.fn().mockReturnValue(false),
|
||||
getPlansDir: vi.fn().mockReturnValue('/tmp/project-temp/plans'),
|
||||
storage: {
|
||||
getProjectTempDir: vi.fn().mockReturnValue('/tmp/project-temp'),
|
||||
getPlansDir: vi.fn().mockReturnValue('/tmp/project-temp/plans'),
|
||||
|
||||
@@ -188,7 +188,7 @@ export class PromptProvider {
|
||||
() => ({
|
||||
interactive: interactiveMode,
|
||||
planModeToolsList,
|
||||
plansDir: context.config.storage.getPlansDir(),
|
||||
plansDir: context.config.getPlansDir(),
|
||||
approvedPlanPath: context.config.getApprovedPlanPath(),
|
||||
}),
|
||||
isPlanMode,
|
||||
|
||||
@@ -132,9 +132,10 @@ describe('EditTool', () => {
|
||||
getDisableLLMCorrection: vi.fn(() => true),
|
||||
getExperiments: () => {},
|
||||
isPlanMode: vi.fn(() => false),
|
||||
getPlansDir: vi.fn().mockReturnValue('/tmp/project/plans'),
|
||||
storage: {
|
||||
getProjectTempDir: vi.fn().mockReturnValue('/tmp/project'),
|
||||
getPlansDir: vi.fn().mockReturnValue('/tmp/plans'),
|
||||
getPlansDir: vi.fn().mockReturnValue('/tmp/project/plans'),
|
||||
},
|
||||
isPathAllowed(this: Config, absolutePath: string): boolean {
|
||||
const workspaceContext = this.getWorkspaceContext();
|
||||
@@ -1314,6 +1315,7 @@ function doIt() {
|
||||
fs.mkdirSync(plansDir);
|
||||
|
||||
vi.mocked(mockConfig.isPlanMode).mockReturnValue(true);
|
||||
vi.mocked(mockConfig.getPlansDir).mockReturnValue(plansDir);
|
||||
vi.mocked(mockConfig.storage.getPlansDir).mockReturnValue(plansDir);
|
||||
|
||||
const filePath = path.join(rootDir, 'test-file.txt');
|
||||
|
||||
@@ -465,10 +465,7 @@ class EditToolInvocation
|
||||
);
|
||||
if (this.config.isPlanMode()) {
|
||||
const safeFilename = path.basename(this.params.file_path);
|
||||
this.resolvedPath = path.join(
|
||||
this.config.storage.getPlansDir(),
|
||||
safeFilename,
|
||||
);
|
||||
this.resolvedPath = path.join(this.config.getPlansDir(), safeFilename);
|
||||
} else if (!path.isAbsolute(this.params.file_path)) {
|
||||
const result = correctPath(this.params.file_path, this.config);
|
||||
if (result.success) {
|
||||
|
||||
@@ -11,7 +11,6 @@ import type { Config } from '../config/config.js';
|
||||
import type { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||
import { ToolConfirmationOutcome } from './tools.js';
|
||||
import { ApprovalMode } from '../policy/types.js';
|
||||
import fs from 'node:fs';
|
||||
|
||||
vi.mock('node:fs', async () => {
|
||||
const actual = await vi.importActual<typeof import('node:fs')>('node:fs');
|
||||
@@ -39,6 +38,7 @@ describe('EnterPlanModeTool', () => {
|
||||
|
||||
mockConfig = {
|
||||
setApprovalMode: vi.fn(),
|
||||
getPlansDir: vi.fn().mockReturnValue('/mock/plans/dir'),
|
||||
storage: {
|
||||
getPlansDir: vi.fn().mockReturnValue('/mock/plans/dir'),
|
||||
} as unknown as Config['storage'],
|
||||
@@ -119,7 +119,6 @@ describe('EnterPlanModeTool', () => {
|
||||
describe('execute', () => {
|
||||
it('should set approval mode to PLAN and return message', async () => {
|
||||
const invocation = tool.build({});
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
|
||||
@@ -130,21 +129,9 @@ 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({});
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
|
||||
await invocation.execute(new AbortController().signal);
|
||||
|
||||
expect(fs.mkdirSync).toHaveBeenCalledWith('/mock/plans/dir', {
|
||||
recursive: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should include optional reason in output display but not in llmContent', async () => {
|
||||
const reason = 'Design new database schema';
|
||||
const invocation = tool.build({ reason });
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import {
|
||||
BaseDeclarativeTool,
|
||||
BaseToolInvocation,
|
||||
@@ -19,7 +18,6 @@ import { ENTER_PLAN_MODE_TOOL_NAME } from './tool-names.js';
|
||||
import { ApprovalMode } from '../policy/types.js';
|
||||
import { ENTER_PLAN_MODE_DEFINITION } from './definitions/coreTools.js';
|
||||
import { resolveToolDeclaration } from './definitions/resolver.js';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
|
||||
export interface EnterPlanModeParams {
|
||||
reason?: string;
|
||||
@@ -124,19 +122,6 @@ export class EnterPlanModeInvocation extends BaseToolInvocation<
|
||||
|
||||
this.config.setApprovalMode(ApprovalMode.PLAN);
|
||||
|
||||
// Ensure plans directory exists so that the agent can write the plan file.
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
llmContent: 'Switching to Plan mode.',
|
||||
returnDisplay: this.params.reason
|
||||
|
||||
@@ -44,6 +44,7 @@ describe('ExitPlanModeTool', () => {
|
||||
getTargetDir: vi.fn().mockReturnValue(tempRootDir),
|
||||
setApprovalMode: vi.fn(),
|
||||
setApprovedPlanPath: vi.fn(),
|
||||
getPlansDir: vi.fn().mockReturnValue(mockPlansDir),
|
||||
storage: {
|
||||
getPlansDir: vi.fn().mockReturnValue(mockPlansDir),
|
||||
} as unknown as Config['storage'],
|
||||
|
||||
@@ -60,11 +60,8 @@ 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,
|
||||
);
|
||||
const plansDir = resolveToRealPath(this.config.getPlansDir());
|
||||
const resolvedPath = path.join(this.config.getPlansDir(), safeFilename);
|
||||
|
||||
const realPath = resolveToRealPath(resolvedPath);
|
||||
|
||||
@@ -120,7 +117,7 @@ export class ExitPlanModeInvocation extends BaseToolInvocation<
|
||||
|
||||
const pathError = await validatePlanPath(
|
||||
this.params.plan_filename,
|
||||
this.config.storage.getPlansDir(),
|
||||
this.config.getPlansDir(),
|
||||
);
|
||||
if (pathError) {
|
||||
this.planValidationError = pathError;
|
||||
@@ -170,7 +167,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.config.getPlansDir(), this.params.plan_filename)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -179,7 +176,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.config.getPlansDir(), safeFilename);
|
||||
}
|
||||
|
||||
async execute(_signal: AbortSignal): Promise<ToolResult> {
|
||||
|
||||
@@ -237,6 +237,7 @@ describe('ToolRegistry', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.realpathSync).mockImplementation((p) => p.toString());
|
||||
vi.mocked(fs.statSync).mockReturnValue({
|
||||
isDirectory: () => true,
|
||||
} as fs.Stats);
|
||||
|
||||
@@ -617,7 +617,7 @@ export class ToolRegistry {
|
||||
*/
|
||||
getFunctionDeclarations(modelId?: string): FunctionDeclaration[] {
|
||||
const isPlanMode = this.config.getApprovalMode() === ApprovalMode.PLAN;
|
||||
const plansDir = this.config.storage.getPlansDir();
|
||||
const plansDir = this.config.getPlansDir();
|
||||
|
||||
const declarations: FunctionDeclaration[] = [];
|
||||
const seenNames = new Set<string>();
|
||||
|
||||
@@ -107,6 +107,7 @@ const mockConfigInternal = {
|
||||
getDisableLLMCorrection: vi.fn(() => true),
|
||||
isPlanMode: vi.fn(() => false),
|
||||
getActiveModel: () => 'test-model',
|
||||
getPlansDir: vi.fn().mockReturnValue('/tmp/plans'),
|
||||
storage: {
|
||||
getProjectTempDir: vi.fn().mockReturnValue('/tmp/project'),
|
||||
},
|
||||
|
||||
@@ -168,10 +168,7 @@ 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,
|
||||
);
|
||||
this.resolvedPath = path.join(this.config.getPlansDir(), safeFilename);
|
||||
} else {
|
||||
this.resolvedPath = path.resolve(
|
||||
this.config.getTargetDir(),
|
||||
|
||||
Reference in New Issue
Block a user