mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-23 00:01:24 -07:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a2dda25890 | |||
| 9b5c3775e0 | |||
| 736690fd35 | |||
| c57123cd81 | |||
| 8d584e4d96 | |||
| a99cd0be28 | |||
| 2ca07e8917 | |||
| 28c5fe7b28 | |||
| c29ab1afb8 | |||
| 74ce3eef0c | |||
| 08ae3ff9b8 | |||
| fd8cfad6f9 | |||
| 073ecc1d4c | |||
| b9d987b08d | |||
| 8abe822ba1 | |||
| 0c63c05c45 | |||
| 18f999a30d | |||
| 15aa924932 | |||
| 2c4d03a843 |
+39
-2
@@ -297,13 +297,13 @@ describe('plan_mode', () => {
|
||||
},
|
||||
});
|
||||
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'should transition from plan mode to normal execution and create a plan file from scratch',
|
||||
params: {
|
||||
settings,
|
||||
},
|
||||
prompt:
|
||||
'Enter plan mode and plan to create a new module called foo. The plan should be saved as foo-plan.md. Then, exit plan mode.',
|
||||
'Enter plan mode and plan to create a new module called foo. The plan should be saved as foo-plan.md. I agree with the strategy, so please create a detailed implementation plan and then execute it.',
|
||||
assert: async (rig, result) => {
|
||||
const enterPlanCalled = await rig.waitForToolCall('enter_plan_mode');
|
||||
expect(
|
||||
@@ -372,4 +372,41 @@ describe('plan_mode', () => {
|
||||
assertModelHasOutput(result);
|
||||
},
|
||||
});
|
||||
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
name: 'should handle nested plan directories correctly',
|
||||
approvalMode: ApprovalMode.PLAN,
|
||||
params: {
|
||||
settings,
|
||||
},
|
||||
prompt:
|
||||
'Please create a new architectural plan in a nested folder called "architecture/frontend-v2.md" within the plans directory. The plan should contain the text "# Frontend V2 Plan". Do not ask for user approval, just create the plan.',
|
||||
assert: async (rig, result) => {
|
||||
await rig.waitForTelemetryReady();
|
||||
const toolLogs = rig.readToolLogs();
|
||||
|
||||
const writeCalls = toolLogs.filter((log) =>
|
||||
['write_file', 'replace'].includes(log.toolRequest.name),
|
||||
);
|
||||
|
||||
const wroteToNestedPath = writeCalls.some((log) => {
|
||||
try {
|
||||
const args = JSON.parse(log.toolRequest.args);
|
||||
return (
|
||||
args.file_path &&
|
||||
args.file_path.includes('architecture/frontend-v2.md')
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
expect(
|
||||
wroteToNestedPath,
|
||||
'Expected model to successfully target the nested plan file path',
|
||||
).toBe(true);
|
||||
|
||||
assertModelHasOutput(result);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,6 +3195,157 @@ describe('AppContainer State Management', () => {
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('sets activeExtensionContext when an extension command WITH a plan dir is executed', async () => {
|
||||
const { checkPermissions } = await import(
|
||||
'./hooks/atCommandProcessor.js'
|
||||
);
|
||||
vi.mocked(checkPermissions).mockResolvedValue([]);
|
||||
|
||||
mockedUseSlashCommandProcessor.mockReturnValue({
|
||||
handleSlashCommand: vi.fn(),
|
||||
slashCommands: [
|
||||
{
|
||||
name: 'conductor:setup',
|
||||
extensionName: 'conductor',
|
||||
description: 'test',
|
||||
action: vi.fn(),
|
||||
},
|
||||
],
|
||||
pendingHistoryItems: [],
|
||||
commandContext: {},
|
||||
shellConfirmationRequest: null,
|
||||
confirmationRequest: null,
|
||||
});
|
||||
|
||||
const spyHasExtensionPlanDir = vi
|
||||
.spyOn(mockConfig, 'hasExtensionPlanDir')
|
||||
.mockReturnValue(true);
|
||||
const spySetActiveExtensionContext = vi.spyOn(
|
||||
mockConfig,
|
||||
'setActiveExtensionContext',
|
||||
);
|
||||
|
||||
const { unmount } = await act(async () => renderAppContainer());
|
||||
|
||||
expect(capturedUIActions).toBeTruthy();
|
||||
|
||||
await act(async () =>
|
||||
capturedUIActions.handleFinalSubmit('/conductor:setup'),
|
||||
);
|
||||
|
||||
expect(spyHasExtensionPlanDir).toHaveBeenCalledWith('conductor');
|
||||
expect(spySetActiveExtensionContext).toHaveBeenCalledWith('conductor');
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('clears activeExtensionContext when an extension command WITHOUT a plan dir is executed', async () => {
|
||||
const { checkPermissions } = await import(
|
||||
'./hooks/atCommandProcessor.js'
|
||||
);
|
||||
vi.mocked(checkPermissions).mockResolvedValue([]);
|
||||
|
||||
mockedUseSlashCommandProcessor.mockReturnValue({
|
||||
handleSlashCommand: vi.fn(),
|
||||
slashCommands: [
|
||||
{
|
||||
name: 'other:cmd',
|
||||
extensionName: 'other',
|
||||
description: 'test',
|
||||
action: vi.fn(),
|
||||
},
|
||||
],
|
||||
pendingHistoryItems: [],
|
||||
commandContext: {},
|
||||
shellConfirmationRequest: null,
|
||||
confirmationRequest: null,
|
||||
});
|
||||
|
||||
const spyHasExtensionPlanDir = vi
|
||||
.spyOn(mockConfig, 'hasExtensionPlanDir')
|
||||
.mockReturnValue(false);
|
||||
const spySetActiveExtensionContext = vi.spyOn(
|
||||
mockConfig,
|
||||
'setActiveExtensionContext',
|
||||
);
|
||||
|
||||
const { unmount } = await act(async () => renderAppContainer());
|
||||
|
||||
expect(capturedUIActions).toBeTruthy();
|
||||
|
||||
await act(async () => capturedUIActions.handleFinalSubmit('/other:cmd'));
|
||||
|
||||
expect(spyHasExtensionPlanDir).toHaveBeenCalledWith('other');
|
||||
expect(spySetActiveExtensionContext).toHaveBeenCalledWith(undefined);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('clears activeExtensionContext when /plan is explicitly executed', async () => {
|
||||
const { checkPermissions } = await import(
|
||||
'./hooks/atCommandProcessor.js'
|
||||
);
|
||||
vi.mocked(checkPermissions).mockResolvedValue([]);
|
||||
|
||||
mockedUseSlashCommandProcessor.mockReturnValue({
|
||||
handleSlashCommand: vi.fn(),
|
||||
slashCommands: [{ name: 'plan', description: 'test', action: vi.fn() }],
|
||||
pendingHistoryItems: [],
|
||||
commandContext: {},
|
||||
shellConfirmationRequest: null,
|
||||
confirmationRequest: null,
|
||||
});
|
||||
|
||||
const spySetActiveExtensionContext = vi.spyOn(
|
||||
mockConfig,
|
||||
'setActiveExtensionContext',
|
||||
);
|
||||
|
||||
const { unmount } = await act(async () => renderAppContainer());
|
||||
|
||||
expect(capturedUIActions).toBeTruthy();
|
||||
|
||||
await act(async () =>
|
||||
capturedUIActions.handleFinalSubmit('/plan my task'),
|
||||
);
|
||||
|
||||
expect(spySetActiveExtensionContext).toHaveBeenCalledWith(undefined);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('does NOT clear activeExtensionContext when a standard non-plan command is executed', async () => {
|
||||
const { checkPermissions } = await import(
|
||||
'./hooks/atCommandProcessor.js'
|
||||
);
|
||||
vi.mocked(checkPermissions).mockResolvedValue([]);
|
||||
|
||||
mockedUseSlashCommandProcessor.mockReturnValue({
|
||||
handleSlashCommand: vi.fn(),
|
||||
slashCommands: [{ name: 'help', description: 'test', action: vi.fn() }],
|
||||
pendingHistoryItems: [],
|
||||
commandContext: {},
|
||||
shellConfirmationRequest: null,
|
||||
confirmationRequest: null,
|
||||
});
|
||||
|
||||
const spySetActiveExtensionContext = vi.spyOn(
|
||||
mockConfig,
|
||||
'setActiveExtensionContext',
|
||||
);
|
||||
|
||||
const { unmount } = await act(async () => renderAppContainer());
|
||||
|
||||
expect(capturedUIActions).toBeTruthy();
|
||||
|
||||
await act(async () => capturedUIActions.handleFinalSubmit('/help'));
|
||||
|
||||
// It should not touch the context at all
|
||||
expect(spySetActiveExtensionContext).not.toHaveBeenCalled();
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Overflow Hint Handling', () => {
|
||||
|
||||
@@ -1377,6 +1377,23 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
}
|
||||
}
|
||||
|
||||
const parsedCommand = parseSlashCommand(
|
||||
submittedValue,
|
||||
slashCommands ?? [],
|
||||
);
|
||||
|
||||
if (config) {
|
||||
if (parsedCommand.extensionContext) {
|
||||
if (config.hasExtensionPlanDir(parsedCommand.extensionContext)) {
|
||||
config.setActiveExtensionContext(parsedCommand.extensionContext);
|
||||
} else {
|
||||
config.setActiveExtensionContext(undefined);
|
||||
}
|
||||
} else if (parsedCommand.commandToExecute?.name === 'plan') {
|
||||
config.setActiveExtensionContext(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
const isSlash = isSlashCommand(submittedValue.trim());
|
||||
const isIdle = streamingState === StreamingState.Idle;
|
||||
const isAgentRunning =
|
||||
@@ -1384,10 +1401,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
isToolExecuting(pendingHistoryItems);
|
||||
|
||||
if (isSlash && isAgentRunning) {
|
||||
const { commandToExecute } = 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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -84,7 +84,7 @@ function usePlanContent(planPath: string, config: Config): PlanContentState {
|
||||
try {
|
||||
const pathError = await validatePlanPath(
|
||||
planPath,
|
||||
config.storage.getPlansDir(),
|
||||
config.getPlansDir(),
|
||||
);
|
||||
if (ignore) return;
|
||||
if (pathError) {
|
||||
@@ -101,7 +101,7 @@ function usePlanContent(planPath: string, config: Config): PlanContentState {
|
||||
|
||||
const result = await processSingleFileContent(
|
||||
planPath,
|
||||
config.storage.getPlansDir(),
|
||||
config.getPlansDir(),
|
||||
config.getFileSystemService(),
|
||||
);
|
||||
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -3357,11 +3357,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,
|
||||
@@ -3369,34 +3370,123 @@ 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 and ApprovalMode.PLAN is active', async () => {
|
||||
vi.spyOn(fs, 'mkdirSync').mockReturnValue(undefined);
|
||||
const config = new Config({
|
||||
...baseParams,
|
||||
plan: true,
|
||||
});
|
||||
|
||||
await config.initialize();
|
||||
config.setApprovalMode(ApprovalMode.PLAN);
|
||||
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 NOT create plans directory if ApprovalMode is not PLAN even if plan is enabled', async () => {
|
||||
vi.spyOn(fs, 'mkdirSync').mockReturnValue(undefined);
|
||||
const config = new Config({
|
||||
...baseParams,
|
||||
plan: true,
|
||||
});
|
||||
|
||||
await config.initialize();
|
||||
// Default mode is DEFAULT, not PLAN
|
||||
const plansDir = config.getPlansDir();
|
||||
|
||||
const plansDir = config.storage.getPlansDir();
|
||||
expect(fs.promises.mkdir).not.toHaveBeenCalled();
|
||||
expect(fs.promises.access).toHaveBeenCalledWith(plansDir);
|
||||
|
||||
expect(fs.mkdirSync).not.toHaveBeenCalled();
|
||||
const context = config.getWorkspaceContext();
|
||||
expect(context.getDirectories()).not.toContain(plansDir);
|
||||
});
|
||||
|
||||
it('should gracefully handle existing directories by relying on mkdirSync recursive: true when in PLAN mode', async () => {
|
||||
vi.spyOn(fs, 'mkdirSync').mockReturnValue(undefined);
|
||||
const config = new Config({
|
||||
...baseParams,
|
||||
plan: true,
|
||||
});
|
||||
|
||||
await config.initialize();
|
||||
config.setApprovalMode(ApprovalMode.PLAN);
|
||||
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) in PLAN mode', async () => {
|
||||
const writeSpy = vi
|
||||
.spyOn(debugLogger, 'error')
|
||||
.mockImplementation(() => {});
|
||||
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.setApprovalMode(ApprovalMode.PLAN);
|
||||
config.getPlansDir();
|
||||
|
||||
expect(writeSpy).toHaveBeenCalledWith(
|
||||
expect.stringMatching(
|
||||
/Failed to initialize active plan directory.*File exists/,
|
||||
),
|
||||
);
|
||||
writeSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should log a warning if mkdirSync fails during getPlansDir (e.g. EACCES) in PLAN mode', async () => {
|
||||
const writeSpy = vi
|
||||
.spyOn(debugLogger, 'error')
|
||||
.mockImplementation(() => {});
|
||||
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.setApprovalMode(ApprovalMode.PLAN);
|
||||
config.getPlansDir();
|
||||
|
||||
expect(writeSpy).toHaveBeenCalledWith(
|
||||
expect.stringMatching(
|
||||
/Failed to initialize active plan directory.*Permission denied/,
|
||||
),
|
||||
);
|
||||
writeSpy.mockRestore();
|
||||
});
|
||||
|
||||
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,
|
||||
@@ -3405,9 +3495,10 @@ describe('Plans Directory Initialization', () => {
|
||||
await config.initialize();
|
||||
|
||||
const plansDir = config.storage.getPlansDir();
|
||||
expect(fs.promises.mkdir).not.toHaveBeenCalledWith(plansDir, {
|
||||
recursive: true,
|
||||
});
|
||||
expect(fs.mkdirSync).not.toHaveBeenCalled();
|
||||
expect(config.getWorkspaceContext().getDirectories()).not.toContain(
|
||||
plansDir,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { getErrorMessage } from '../utils/errors.js';
|
||||
import { SandboxPolicyManager } from '../policy/sandboxPolicyManager.js';
|
||||
import { inspect } from 'node:util';
|
||||
import process from 'node:process';
|
||||
@@ -710,6 +711,7 @@ export interface ConfigParameters {
|
||||
plan?: boolean;
|
||||
tracker?: boolean;
|
||||
planSettings?: PlanSettings;
|
||||
extensionPlanDirs?: Record<string, string>;
|
||||
worktreeSettings?: WorktreeSettings;
|
||||
modelSteering?: boolean;
|
||||
onModelChange?: (model: string) => void;
|
||||
@@ -773,6 +775,9 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
private readonly extensionsEnabled: boolean;
|
||||
private mcpServers: Record<string, MCPServerConfig> | undefined;
|
||||
private readonly mcpEnablementCallbacks?: McpEnablementCallbacks;
|
||||
private activeExtensionContext?: string;
|
||||
private initializedPlanDirs = new Set<string>();
|
||||
private readonly extensionPlanDirs: Record<string, string>;
|
||||
private userMemory: string | HierarchicalMemory;
|
||||
private geminiMdFileCount: number;
|
||||
private geminiMdFilePaths: string[];
|
||||
@@ -1037,6 +1042,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 ?? [];
|
||||
@@ -1408,20 +1414,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();
|
||||
@@ -2251,6 +2243,57 @@ 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 {
|
||||
if (this.activeExtensionContext) {
|
||||
return this.extensionPlanDirs[this.activeExtensionContext];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
getPlansDir(): string {
|
||||
const plansDir = this.storage.getPlansDir(this.getActiveExtensionPlanDir());
|
||||
|
||||
if (this.initializedPlanDirs.has(plansDir)) {
|
||||
return plansDir;
|
||||
}
|
||||
|
||||
if (this.planEnabled && this.isPlanMode()) {
|
||||
try {
|
||||
fs.mkdirSync(plansDir, { recursive: true });
|
||||
|
||||
let realPlansDir = plansDir;
|
||||
try {
|
||||
realPlansDir = resolveToRealPath(plansDir);
|
||||
} catch {
|
||||
// Ignore failures in mock environments
|
||||
}
|
||||
this.workspaceContext.addDirectory(realPlansDir);
|
||||
} catch (e: unknown) {
|
||||
debugLogger.error(
|
||||
`Failed to initialize active plan directory at '${plansDir}': ${getErrorMessage(e)}`,
|
||||
);
|
||||
} finally {
|
||||
this.initializedPlanDirs.add(plansDir);
|
||||
}
|
||||
} else if (!this.planEnabled) {
|
||||
this.initializedPlanDirs.add(plansDir);
|
||||
}
|
||||
|
||||
return plansDir;
|
||||
}
|
||||
|
||||
getMcpEnablementCallbacks(): McpEnablementCallbacks | undefined {
|
||||
return this.mcpEnablementCallbacks;
|
||||
}
|
||||
|
||||
@@ -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, getErrorMessage } from '../utils/errors.js';
|
||||
|
||||
export interface RegistryData {
|
||||
projects: Record<string, string>;
|
||||
@@ -62,7 +63,7 @@ export class ProjectRegistry {
|
||||
const content = await fs.promises.readFile(this.registryPath, 'utf8');
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
return JSON.parse(content);
|
||||
} catch (e) {
|
||||
} catch (e: unknown) {
|
||||
debugLogger.debug('Failed to load registry: ', e);
|
||||
// If the registry is corrupted, we'll start fresh to avoid blocking the CLI
|
||||
return { projects: {} };
|
||||
@@ -83,17 +84,37 @@ export class ProjectRegistry {
|
||||
await fs.promises.mkdir(dir, { recursive: true });
|
||||
}
|
||||
|
||||
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);
|
||||
} catch (error) {
|
||||
debugLogger.error(
|
||||
`Failed to save project registry to ${this.registryPath}:`,
|
||||
error,
|
||||
);
|
||||
let attempt = 0;
|
||||
const maxAttempts = 5;
|
||||
const retryDelayMs = 100;
|
||||
|
||||
while (attempt < maxAttempts) {
|
||||
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);
|
||||
return; // Success
|
||||
} catch (error: unknown) {
|
||||
attempt++;
|
||||
const isRetryable =
|
||||
isNodeError(error) &&
|
||||
(error.code === 'EPERM' ||
|
||||
error.code === 'EBUSY' ||
|
||||
error.code === 'EACCES');
|
||||
|
||||
if (attempt >= maxAttempts || !isRetryable) {
|
||||
debugLogger.error(
|
||||
`Failed to save project registry to ${this.registryPath} after ${attempt} attempts:`,
|
||||
getErrorMessage(error),
|
||||
);
|
||||
return; // Stop trying
|
||||
} else {
|
||||
// Wait before retrying (exponential backoff could be used here too)
|
||||
await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,7 +198,7 @@ export class ProjectRegistry {
|
||||
if (this.normalizePath(owner) !== this.normalizePath(projectPath)) {
|
||||
return false;
|
||||
}
|
||||
} catch (e) {
|
||||
} catch (e: unknown) {
|
||||
debugLogger.debug(
|
||||
`Failed to read ownership marker ${markerPath}:`,
|
||||
e,
|
||||
@@ -220,7 +241,7 @@ export class ProjectRegistry {
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
} catch (e: unknown) {
|
||||
debugLogger.debug(`Failed to scan base dir ${baseDir}:`, e);
|
||||
}
|
||||
}
|
||||
@@ -239,6 +260,12 @@ export class ProjectRegistry {
|
||||
const existingIds = new Set(Object.values(existingMappings));
|
||||
|
||||
while (true) {
|
||||
if (counter > 1000) {
|
||||
throw new Error(
|
||||
'Failed to generate a unique project short ID after 1000 attempts. Check for fs mocks or filesystem permission issues.',
|
||||
);
|
||||
}
|
||||
|
||||
const candidate = counter === 0 ? slug : `${slug}-${counter}`;
|
||||
counter++;
|
||||
|
||||
|
||||
@@ -358,6 +358,26 @@ describe('Storage – additional helpers', () => {
|
||||
expected: '',
|
||||
expectedError: `Custom plans directory 'symlink-to-outside' resolves to '${path.resolve('/outside/project/root')}', which is outside the project root '${resolveToRealPath(projectRoot)}'.`,
|
||||
},
|
||||
{
|
||||
name: 'non-existent plan dir in a symlinked project root',
|
||||
customDir: 'new-plans',
|
||||
setup: () => {
|
||||
vi.mocked(fs.realpathSync).mockImplementation((p: fs.PathLike) => {
|
||||
const pStr = p.toString();
|
||||
if (pStr === projectRoot) {
|
||||
return '/private/tmp/project';
|
||||
}
|
||||
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: path.resolve(projectRoot, 'new-plans'),
|
||||
},
|
||||
];
|
||||
|
||||
testCases.forEach(({ name, customDir, expected, expectedError, setup }) => {
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from '../utils/paths.js';
|
||||
import { ProjectRegistry } from './projectRegistry.js';
|
||||
import { StorageMigration } from './storageMigration.js';
|
||||
import { isNodeError } from '../utils/errors.js';
|
||||
|
||||
export const OAUTH_FILE = 'oauth_creds.json';
|
||||
const TMP_DIR_NAME = 'tmp';
|
||||
@@ -320,18 +321,26 @@ export class Storage {
|
||||
return path.join(this.getProjectTempDir(), 'tracker');
|
||||
}
|
||||
|
||||
getPlansDir(): string {
|
||||
if (this.customPlansDir) {
|
||||
const resolvedPath = path.resolve(
|
||||
this.getProjectRoot(),
|
||||
this.customPlansDir,
|
||||
);
|
||||
getPlansDir(extensionPlanDir?: string): string {
|
||||
const customDir = extensionPlanDir || this.customPlansDir;
|
||||
if (customDir) {
|
||||
const resolvedPath = path.resolve(this.getProjectRoot(), customDir);
|
||||
const realProjectRoot = resolveToRealPath(this.getProjectRoot());
|
||||
const realResolvedPath = resolveToRealPath(resolvedPath);
|
||||
let realResolvedPath = resolvedPath;
|
||||
|
||||
try {
|
||||
realResolvedPath = resolveToRealPath(resolvedPath);
|
||||
} catch (e: unknown) {
|
||||
if (!(isNodeError(e) && (e.code === 'ENOENT' || e.code === 'EISDIR'))) {
|
||||
throw e;
|
||||
}
|
||||
// Construct the fallback path safely against the real project root
|
||||
realResolvedPath = path.resolve(realProjectRoot, customDir);
|
||||
}
|
||||
|
||||
if (!isSubpath(realProjectRoot, realResolvedPath)) {
|
||||
throw new Error(
|
||||
`Custom plans directory '${this.customPlansDir}' resolves to '${realResolvedPath}', which is outside the project root '${realProjectRoot}'.`,
|
||||
`Custom plans directory '${customDir}' resolves to '${realResolvedPath}', which is outside the project root '${realProjectRoot}'.`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -93,9 +93,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'),
|
||||
@@ -451,6 +452,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'),
|
||||
},
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -195,7 +195,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();
|
||||
@@ -1334,10 +1335,11 @@ 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');
|
||||
const planFilePath = path.join(plansDir, 'test-file.txt');
|
||||
const filePath = 'test-file.txt';
|
||||
const planFilePath = path.join(plansDir, filePath);
|
||||
const initialContent = 'some initial content';
|
||||
fs.writeFileSync(planFilePath, initialContent, 'utf8');
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ import { EDIT_DEFINITION } from './definitions/coreTools.js';
|
||||
import { resolveToolDeclaration } from './definitions/resolver.js';
|
||||
import { detectOmissionPlaceholders } from './omissionPlaceholderDetector.js';
|
||||
import { discoverJitContext, appendJitContext } from './jit-context.js';
|
||||
import { resolveAndValidatePlanPath } from '../utils/planUtils.js';
|
||||
|
||||
const ENABLE_FUZZY_MATCH_RECOVERY = true;
|
||||
const FUZZY_MATCH_THRESHOLD = 0.1; // Allow up to 10% weighted difference
|
||||
@@ -465,10 +466,9 @@ class EditToolInvocation
|
||||
() => this.config.getApprovalMode(),
|
||||
);
|
||||
if (this.config.isPlanMode()) {
|
||||
const safeFilename = path.basename(this.params.file_path);
|
||||
this.resolvedPath = path.join(
|
||||
this.config.storage.getPlansDir(),
|
||||
safeFilename,
|
||||
this.resolvedPath = resolveAndValidatePlanPath(
|
||||
this.params.file_path,
|
||||
this.config.getPlansDir(),
|
||||
);
|
||||
} else if (!path.isAbsolute(this.params.file_path)) {
|
||||
const result = correctPath(this.params.file_path, this.config);
|
||||
@@ -1054,7 +1054,16 @@ export class EditTool
|
||||
}
|
||||
|
||||
let resolvedPath: string;
|
||||
if (!path.isAbsolute(params.file_path)) {
|
||||
if (this.config.isPlanMode()) {
|
||||
try {
|
||||
resolvedPath = resolveAndValidatePlanPath(
|
||||
params.file_path,
|
||||
this.config.storage.getPlansDir(),
|
||||
);
|
||||
} catch (err) {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
} else if (!path.isAbsolute(params.file_path)) {
|
||||
const result = correctPath(params.file_path, this.config);
|
||||
if (result.success) {
|
||||
resolvedPath = result.correctedPath;
|
||||
|
||||
@@ -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({
|
||||
abortSignal: new AbortController().signal,
|
||||
@@ -132,21 +131,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({ abortSignal: 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({
|
||||
abortSignal: new AbortController().signal,
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import {
|
||||
BaseDeclarativeTool,
|
||||
BaseToolInvocation,
|
||||
@@ -20,7 +19,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;
|
||||
@@ -125,19 +123,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'],
|
||||
@@ -72,8 +73,10 @@ describe('ExitPlanModeTool', () => {
|
||||
|
||||
const createPlanFile = (name: string, content: string) => {
|
||||
const filePath = path.join(mockPlansDir, name);
|
||||
// Ensure parent directory exists for nested tests
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, content);
|
||||
return path.join('plans', name);
|
||||
return name;
|
||||
};
|
||||
|
||||
describe('shouldConfirmExecute', () => {
|
||||
|
||||
@@ -19,9 +19,13 @@ import type { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||
import path from 'node:path';
|
||||
import type { Config } from '../config/config.js';
|
||||
import { EXIT_PLAN_MODE_TOOL_NAME } from './tool-names.js';
|
||||
import { validatePlanPath, validatePlanContent } from '../utils/planUtils.js';
|
||||
import {
|
||||
validatePlanPath,
|
||||
validatePlanContent,
|
||||
resolveAndValidatePlanPath,
|
||||
} from '../utils/planUtils.js';
|
||||
import { ApprovalMode } from '../policy/types.js';
|
||||
import { resolveToRealPath, isSubpath } from '../utils/paths.js';
|
||||
// Remove unused imports
|
||||
import { logPlanExecution } from '../telemetry/loggers.js';
|
||||
import { PlanExecutionEvent } from '../telemetry/types.js';
|
||||
import { getExitPlanModeDefinition } from './definitions/coreTools.js';
|
||||
@@ -59,18 +63,19 @@ export class ExitPlanModeTool extends BaseDeclarativeTool<
|
||||
if (!params.plan_filename || params.plan_filename.trim() === '') {
|
||||
return 'plan_filename is required.';
|
||||
}
|
||||
|
||||
const safeFilename = path.basename(params.plan_filename);
|
||||
const plansDir = resolveToRealPath(this.config.storage.getPlansDir());
|
||||
const resolvedPath = path.join(
|
||||
this.config.storage.getPlansDir(),
|
||||
safeFilename,
|
||||
);
|
||||
|
||||
const realPath = resolveToRealPath(resolvedPath);
|
||||
|
||||
if (!isSubpath(plansDir, realPath)) {
|
||||
return `Access denied: plan path (${resolvedPath}) must be within the designated plans directory (${plansDir}).`;
|
||||
try {
|
||||
resolveAndValidatePlanPath(
|
||||
params.plan_filename,
|
||||
this.config.getPlansDir(),
|
||||
);
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message.startsWith('Security violation')) {
|
||||
return `Access denied: plan path (${path.join(
|
||||
this.config.getPlansDir(),
|
||||
params.plan_filename,
|
||||
)}) must be within the designated plans directory (${this.config.getPlansDir()}).`;
|
||||
}
|
||||
return e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -121,7 +126,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;
|
||||
@@ -171,7 +176,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,8 +184,10 @@ export class ExitPlanModeInvocation extends BaseToolInvocation<
|
||||
* Note: Validation is done in validateToolParamValues, so this assumes the path is valid.
|
||||
*/
|
||||
private getResolvedPlanPath(): string {
|
||||
const safeFilename = path.basename(this.params.plan_filename);
|
||||
return path.join(this.config.storage.getPlansDir(), safeFilename);
|
||||
return resolveAndValidatePlanPath(
|
||||
this.params.plan_filename,
|
||||
this.config.getPlansDir(),
|
||||
);
|
||||
}
|
||||
|
||||
async execute({ abortSignal: _signal }: ExecuteOptions): Promise<ToolResult> {
|
||||
|
||||
@@ -634,7 +634,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'),
|
||||
},
|
||||
|
||||
@@ -49,6 +49,7 @@ import { debugLogger } from '../utils/debugLogger.js';
|
||||
import { WRITE_FILE_DEFINITION } from './definitions/coreTools.js';
|
||||
import { resolveToolDeclaration } from './definitions/resolver.js';
|
||||
import { detectOmissionPlaceholders } from './omissionPlaceholderDetector.js';
|
||||
import { resolveAndValidatePlanPath } from '../utils/planUtils.js';
|
||||
import { isGemini3Model } from '../config/models.js';
|
||||
import { discoverJitContext, appendJitContext } from './jit-context.js';
|
||||
|
||||
@@ -168,10 +169,9 @@ 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 = resolveAndValidatePlanPath(
|
||||
this.params.file_path,
|
||||
this.config.getPlansDir(),
|
||||
);
|
||||
} else {
|
||||
this.resolvedPath = path.resolve(
|
||||
@@ -499,7 +499,19 @@ export class WriteFileTool
|
||||
return `Missing or empty "file_path"`;
|
||||
}
|
||||
|
||||
const resolvedPath = path.resolve(this.config.getTargetDir(), filePath);
|
||||
let resolvedPath: string;
|
||||
if (this.config.isPlanMode()) {
|
||||
try {
|
||||
resolvedPath = resolveAndValidatePlanPath(
|
||||
filePath,
|
||||
this.config.getPlansDir(),
|
||||
);
|
||||
} catch (err) {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
} else {
|
||||
resolvedPath = path.resolve(this.config.getTargetDir(), filePath);
|
||||
}
|
||||
|
||||
const validationError = this.config.validatePathAccess(resolvedPath);
|
||||
if (validationError) {
|
||||
|
||||
@@ -8,7 +8,11 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import path from 'node:path';
|
||||
import * as fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import { validatePlanPath, validatePlanContent } from './planUtils.js';
|
||||
import {
|
||||
validatePlanPath,
|
||||
validatePlanContent,
|
||||
resolveAndValidatePlanPath,
|
||||
} from './planUtils.js';
|
||||
|
||||
describe('planUtils', () => {
|
||||
let tempRootDir: string;
|
||||
@@ -29,10 +33,60 @@ describe('planUtils', () => {
|
||||
}
|
||||
});
|
||||
|
||||
describe('resolveAndValidatePlanPath', () => {
|
||||
it('should strip redundant prefix matching plansDir basename', () => {
|
||||
const result = resolveAndValidatePlanPath('plans/test.md', plansDir);
|
||||
expect(result).toBe(path.join(plansDir, 'test.md'));
|
||||
});
|
||||
|
||||
it('should strip redundant prefix when path starts with ./', () => {
|
||||
const result = resolveAndValidatePlanPath('./plans/test.md', plansDir);
|
||||
expect(result).toBe(path.join(plansDir, 'test.md'));
|
||||
});
|
||||
|
||||
it('should strip redundant prefix matching plansDir basename (with nested path)', () => {
|
||||
const result = resolveAndValidatePlanPath(
|
||||
'plans/nested/test.md',
|
||||
plansDir,
|
||||
);
|
||||
expect(result).toBe(path.join(plansDir, 'nested/test.md'));
|
||||
});
|
||||
|
||||
it('should handle standard paths without the prefix', () => {
|
||||
const result = resolveAndValidatePlanPath('test.md', plansDir);
|
||||
expect(result).toBe(path.join(plansDir, 'test.md'));
|
||||
});
|
||||
|
||||
it('should handle standard paths with ./ prefix', () => {
|
||||
const result = resolveAndValidatePlanPath('./test.md', plansDir);
|
||||
expect(result).toBe(path.join(plansDir, 'test.md'));
|
||||
});
|
||||
|
||||
it('should throw if path is empty after stripping prefix', () => {
|
||||
expect(() => resolveAndValidatePlanPath('plans', plansDir)).toThrowError(
|
||||
/must include a filename/,
|
||||
);
|
||||
expect(() => resolveAndValidatePlanPath('plans/', plansDir)).toThrowError(
|
||||
/must include a filename/,
|
||||
);
|
||||
expect(() =>
|
||||
resolveAndValidatePlanPath('./plans', plansDir),
|
||||
).toThrowError(/must include a filename/);
|
||||
});
|
||||
|
||||
it('should handle mixed separators', () => {
|
||||
const result = resolveAndValidatePlanPath(
|
||||
'plans\\windows-style.md',
|
||||
plansDir,
|
||||
);
|
||||
expect(result).toBe(path.join(plansDir, 'windows-style.md'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('validatePlanPath', () => {
|
||||
it('should return null for a valid path within plans directory', async () => {
|
||||
const planPath = path.join('plans', 'test.md');
|
||||
const fullPath = path.join(tempRootDir, planPath);
|
||||
const planPath = 'test.md';
|
||||
const fullPath = path.join(plansDir, planPath);
|
||||
fs.writeFileSync(fullPath, '# My Plan');
|
||||
|
||||
const result = await validatePlanPath(planPath, plansDir);
|
||||
@@ -40,14 +94,14 @@ describe('planUtils', () => {
|
||||
});
|
||||
|
||||
it('should return error for non-existent file', async () => {
|
||||
const planPath = path.join('plans', 'ghost.md');
|
||||
const planPath = 'ghost.md';
|
||||
const result = await validatePlanPath(planPath, plansDir);
|
||||
expect(result).toContain('Plan file does not exist');
|
||||
});
|
||||
|
||||
it('should detect path traversal via symbolic links', async () => {
|
||||
const maliciousPath = path.join('plans', 'malicious.md');
|
||||
const fullMaliciousPath = path.join(tempRootDir, maliciousPath);
|
||||
const maliciousPath = 'malicious.md';
|
||||
const fullMaliciousPath = path.join(plansDir, maliciousPath);
|
||||
const outsideFile = path.join(tempRootDir, 'outside.txt');
|
||||
fs.writeFileSync(outsideFile, 'secret content');
|
||||
|
||||
|
||||
@@ -22,31 +22,84 @@ export const PlanErrorMessages = {
|
||||
READ_FAILURE: (detail: string) => `Failed to read plan file: ${detail}`,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Resolves a plan file path and strictly validates it against the plans directory boundary.
|
||||
* Useful for tools that need to write or read plans.
|
||||
* @param planPath The untrusted file path provided by the model.
|
||||
* @param plansDir The authorized project plans directory.
|
||||
* @returns The safely resolved path string.
|
||||
* @throws Error if the path is empty, malicious, or escapes boundaries.
|
||||
*/
|
||||
export function resolveAndValidatePlanPath(
|
||||
planPath: string,
|
||||
plansDir: string,
|
||||
): string {
|
||||
const trimmedPath = planPath.trim();
|
||||
if (!trimmedPath) {
|
||||
throw new Error('Plan file path must be non-empty.');
|
||||
}
|
||||
|
||||
// Normalize separators to forward slashes for easier manipulation
|
||||
const normalizedInput = trimmedPath.replace(/\\/g, '/');
|
||||
|
||||
// Prevent redundant nesting if the agent includes the plans directory name in the path.
|
||||
// E.g. plansDir='/repo/conductor', planPath='conductor/test.md' -> we want '/repo/conductor/test.md'
|
||||
// Also handle './conductor/test.md' or 'conductor/nested/test.md'
|
||||
let normalizedPlanPath = normalizedInput;
|
||||
const plansDirName = path.basename(plansDir);
|
||||
|
||||
// Split into segments and remove empty or '.' segments from the beginning
|
||||
const segments = normalizedInput.split('/').filter((s) => s !== '');
|
||||
if (segments[0] === '.') {
|
||||
segments.shift();
|
||||
}
|
||||
|
||||
if (segments[0] === plansDirName) {
|
||||
// Strip the redundant prefix.
|
||||
segments.shift();
|
||||
normalizedPlanPath = segments.join('/');
|
||||
|
||||
// If the path was EXACTLY just the directory name (e.g. "conductor"), it's invalid for a file.
|
||||
if (!normalizedPlanPath) {
|
||||
throw new Error('Plan file path must include a filename.');
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedPath = path.resolve(plansDir, normalizedPlanPath);
|
||||
const realPath = resolveToRealPath(resolvedPath);
|
||||
const realPlansDir = resolveToRealPath(plansDir);
|
||||
|
||||
if (!isSubpath(realPlansDir, realPath)) {
|
||||
throw new Error(
|
||||
`Security violation: plan path (${trimmedPath}) must be within the designated plans directory (${plansDir}).`,
|
||||
);
|
||||
}
|
||||
|
||||
return resolvedPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a plan file path for safety (traversal) and existence.
|
||||
* @param planPath The untrusted path to the plan file.
|
||||
* @param plansDir The authorized project plans directory.
|
||||
* @param targetDir The current working directory (project root).
|
||||
* @returns An error message if validation fails, or null if successful.
|
||||
*/
|
||||
export async function validatePlanPath(
|
||||
planPath: string,
|
||||
plansDir: string,
|
||||
): Promise<string | null> {
|
||||
const safeFilename = path.basename(planPath);
|
||||
const resolvedPath = path.join(plansDir, safeFilename);
|
||||
const realPath = resolveToRealPath(resolvedPath);
|
||||
const realPlansDir = resolveToRealPath(plansDir);
|
||||
|
||||
if (!isSubpath(realPlansDir, realPath)) {
|
||||
return PlanErrorMessages.PATH_ACCESS_DENIED(planPath, realPlansDir);
|
||||
try {
|
||||
const resolvedPath = resolveAndValidatePlanPath(planPath, plansDir);
|
||||
if (!(await fileExists(resolvedPath))) {
|
||||
return PlanErrorMessages.FILE_NOT_FOUND(planPath);
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return PlanErrorMessages.PATH_ACCESS_DENIED(
|
||||
planPath,
|
||||
resolveToRealPath(plansDir),
|
||||
);
|
||||
}
|
||||
|
||||
if (!(await fileExists(resolvedPath))) {
|
||||
return PlanErrorMessages.FILE_NOT_FOUND(planPath);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -70,7 +70,11 @@ if (!geminiSandbox) {
|
||||
geminiSandbox = process.env.GEMINI_SANDBOX;
|
||||
}
|
||||
|
||||
geminiSandbox = (geminiSandbox || '').toLowerCase();
|
||||
if (typeof geminiSandbox === 'object' && geminiSandbox !== null) {
|
||||
geminiSandbox = geminiSandbox.enabled ? 'true' : 'false';
|
||||
}
|
||||
|
||||
geminiSandbox = (geminiSandbox || '').toString().toLowerCase();
|
||||
|
||||
const commandExists = (cmd) => {
|
||||
const checkCommand = os.platform() === 'win32' ? 'where' : 'command -v';
|
||||
|
||||
Reference in New Issue
Block a user