feat(plan): support configuring custom plans storage directory (#19577)

This commit is contained in:
Jerop Kipruto
2026-02-19 17:47:08 -05:00
committed by GitHub
parent 2cba2ab37a
commit 537e56ffae
24 changed files with 337 additions and 58 deletions
+41
View File
@@ -127,6 +127,47 @@ To use a skill in Plan Mode, you can explicitly ask the agent to "use the
[skill-name] skill to plan..." or the agent may autonomously activate it based [skill-name] skill to plan..." or the agent may autonomously activate it based
on the task description. on the task description.
### Custom Plan Directory and Policies
By default, planning artifacts are stored in a managed temporary directory
outside your project: `~/.gemini/tmp/<project>/<session-id>/plans/`.
You can configure a custom directory for plans in your `settings.json`. For
example, to store plans in a `.gemini/plans` directory within your project:
```json
{
"general": {
"plan": {
"directory": ".gemini/plans"
}
}
}
```
To maintain the safety of Plan Mode, user-configured paths for the plans
directory are restricted to the project root. This ensures that custom planning
locations defined within a project's workspace cannot be used to escape and
overwrite sensitive files elsewhere. Any user-configured directory must reside
within the project boundary.
Because Plan Mode is read-only by default, using a custom directory requires
updating your [Policy Engine] configurations to allow `write_file` and `replace`
in that specific location. For example, to allow writing to the `.gemini/plans`
directory within your project, create a policy file at
`~/.gemini/policies/plan-custom-directory.toml`:
```toml
[[rule]]
toolName = ["write_file", "replace"]
decision = "allow"
priority = 100
modes = ["plan"]
# Adjust the pattern to match your custom directory.
# This example matches any .md file in a .gemini/plans directory within the project.
argsPattern = "\"file_path\":\".*\\\\.gemini/plans/.*\\\\.md\""
```
### Customizing Policies ### Customizing Policies
Plan Mode is designed to be read-only by default to ensure safety during the Plan Mode is designed to be read-only by default to ensure safety during the
+1
View File
@@ -28,6 +28,7 @@ they appear in the UI.
| Default Approval Mode | `general.defaultApprovalMode` | The default approval mode for tool execution. 'default' prompts for approval, 'auto_edit' auto-approves edit tools, and 'plan' is read-only mode. 'yolo' is not supported yet. | `"default"` | | Default Approval Mode | `general.defaultApprovalMode` | The default approval mode for tool execution. 'default' prompts for approval, 'auto_edit' auto-approves edit tools, and 'plan' is read-only mode. 'yolo' is not supported yet. | `"default"` |
| Enable Auto Update | `general.enableAutoUpdate` | Enable automatic updates. | `true` | | Enable Auto Update | `general.enableAutoUpdate` | Enable automatic updates. | `true` |
| Enable Notifications | `general.enableNotifications` | Enable run-event notifications for action-required prompts and session completion. Currently macOS only. | `false` | | Enable Notifications | `general.enableNotifications` | Enable run-event notifications for action-required prompts and session completion. Currently macOS only. | `false` |
| Plan Directory | `general.plan.directory` | The directory where planning artifacts are stored. If not specified, defaults to the system temporary directory. | `undefined` |
| Enable Prompt Completion | `general.enablePromptCompletion` | Enable AI-powered prompt completion suggestions while typing. | `false` | | Enable Prompt Completion | `general.enablePromptCompletion` | Enable AI-powered prompt completion suggestions while typing. | `false` |
| Debug Keystroke Logging | `general.debugKeystrokeLogging` | Enable debug logging of keystrokes to the console. | `false` | | Debug Keystroke Logging | `general.debugKeystrokeLogging` | Enable debug logging of keystrokes to the console. | `false` |
| Enable Session Cleanup | `general.sessionRetention.enabled` | Enable automatic session cleanup | `false` | | Enable Session Cleanup | `general.sessionRetention.enabled` | Enable automatic session cleanup | `false` |
+6
View File
@@ -131,6 +131,12 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `false` - **Default:** `false`
- **Requires restart:** Yes - **Requires restart:** Yes
- **`general.plan.directory`** (string):
- **Description:** The directory where planning artifacts are stored. If not
specified, defaults to the system temporary directory.
- **Default:** `undefined`
- **Requires restart:** Yes
- **`general.enablePromptCompletion`** (boolean): - **`general.enablePromptCompletion`** (boolean):
- **Description:** Enable AI-powered prompt completion suggestions while - **Description:** Enable AI-powered prompt completion suggestions while
typing. typing.
+20 -1
View File
@@ -21,7 +21,11 @@ import {
type MCPServerConfig, type MCPServerConfig,
} from '@google/gemini-cli-core'; } from '@google/gemini-cli-core';
import { loadCliConfig, parseArguments, type CliArgs } from './config.js'; import { loadCliConfig, parseArguments, type CliArgs } from './config.js';
import { type Settings, createTestMergedSettings } from './settings.js'; import {
type Settings,
type MergedSettings,
createTestMergedSettings,
} from './settings.js';
import * as ServerConfig from '@google/gemini-cli-core'; import * as ServerConfig from '@google/gemini-cli-core';
import { isWorkspaceTrusted } from './trustedFolders.js'; import { isWorkspaceTrusted } from './trustedFolders.js';
@@ -2599,6 +2603,21 @@ describe('loadCliConfig approval mode', () => {
expect(config.getApprovalMode()).toBe(ApprovalMode.DEFAULT); expect(config.getApprovalMode()).toBe(ApprovalMode.DEFAULT);
}); });
it('should pass planSettings.directory from settings to config', async () => {
process.argv = ['node', 'script.js'];
const settings = createTestMergedSettings({
general: {
plan: {
directory: '.custom-plans',
},
},
} as unknown as MergedSettings);
const argv = await parseArguments(settings);
const config = await loadCliConfig(settings, 'test-session', argv);
const plansDir = config.storage.getPlansDir();
expect(plansDir).toContain('.custom-plans');
});
// --- Untrusted Folder Scenarios --- // --- Untrusted Folder Scenarios ---
describe('when folder is NOT trusted', () => { describe('when folder is NOT trusted', () => {
beforeEach(() => { beforeEach(() => {
+1
View File
@@ -814,6 +814,7 @@ export async function loadCliConfig(
enableExtensionReloading: settings.experimental?.extensionReloading, enableExtensionReloading: settings.experimental?.extensionReloading,
enableAgents: settings.experimental?.enableAgents, enableAgents: settings.experimental?.enableAgents,
plan: settings.experimental?.plan, plan: settings.experimental?.plan,
planSettings: settings.general.plan,
enableEventDrivenScheduler: true, enableEventDrivenScheduler: true,
skillsSupport: settings.skills?.enabled ?? true, skillsSupport: settings.skills?.enabled ?? true,
disabledSkills: settings.skills?.disabled, disabledSkills: settings.skills?.disabled,
@@ -107,6 +107,16 @@ describe('SettingsSchema', () => {
).toBe('boolean'); ).toBe('boolean');
}); });
it('should have plan nested properties', () => {
expect(
getSettingsSchema().general?.properties?.plan?.properties?.directory,
).toBeDefined();
expect(
getSettingsSchema().general?.properties?.plan?.properties?.directory
.type,
).toBe('string');
});
it('should have fileFiltering nested properties', () => { it('should have fileFiltering nested properties', () => {
expect( expect(
getSettingsSchema().context.properties.fileFiltering.properties getSettingsSchema().context.properties.fileFiltering.properties
+22
View File
@@ -266,6 +266,27 @@ const SETTINGS_SCHEMA = {
}, },
}, },
}, },
plan: {
type: 'object',
label: 'Plan',
category: 'General',
requiresRestart: true,
default: {},
description: 'Planning features configuration.',
showInDialog: false,
properties: {
directory: {
type: 'string',
label: 'Plan Directory',
category: 'General',
requiresRestart: true,
default: undefined as string | undefined,
description:
'The directory where planning artifacts are stored. If not specified, defaults to the system temporary directory.',
showInDialog: true,
},
},
},
enablePromptCompletion: { enablePromptCompletion: {
type: 'boolean', type: 'boolean',
label: 'Enable Prompt Completion', label: 'Enable Prompt Completion',
@@ -1313,6 +1334,7 @@ const SETTINGS_SCHEMA = {
}, },
}, },
}, },
useWriteTodos: { useWriteTodos: {
type: 'boolean', type: 'boolean',
label: 'Use WriteTodos', label: 'Use WriteTodos',
@@ -51,7 +51,7 @@ describe('planCommand', () => {
getApprovalMode: vi.fn(), getApprovalMode: vi.fn(),
getFileSystemService: vi.fn(), getFileSystemService: vi.fn(),
storage: { storage: {
getProjectTempPlansDir: vi.fn().mockReturnValue('/mock/plans/dir'), getPlansDir: vi.fn().mockReturnValue('/mock/plans/dir'),
}, },
}, },
}, },
+1 -1
View File
@@ -43,7 +43,7 @@ export const planCommand: SlashCommand = {
try { try {
const content = await processSingleFileContent( const content = await processSingleFileContent(
approvedPlanPath, approvedPlanPath,
config.storage.getProjectTempPlansDir(), config.storage.getPlansDir(),
config.getFileSystemService(), config.getFileSystemService(),
); );
const fileName = path.basename(approvedPlanPath); const fileName = path.basename(approvedPlanPath);
@@ -154,7 +154,7 @@ Implement a comprehensive authentication system with multiple providers.
getIdeMode: () => false, getIdeMode: () => false,
isTrustedFolder: () => true, isTrustedFolder: () => true,
storage: { storage: {
getProjectTempPlansDir: () => mockPlansDir, getPlansDir: () => mockPlansDir,
}, },
getFileSystemService: (): FileSystemService => ({ getFileSystemService: (): FileSystemService => ({
readTextFile: vi.fn(), readTextFile: vi.fn(),
@@ -429,7 +429,7 @@ Implement a comprehensive authentication system with multiple providers.
getIdeMode: () => false, getIdeMode: () => false,
isTrustedFolder: () => true, isTrustedFolder: () => true,
storage: { storage: {
getProjectTempPlansDir: () => mockPlansDir, getPlansDir: () => mockPlansDir,
}, },
getFileSystemService: (): FileSystemService => ({ getFileSystemService: (): FileSystemService => ({
readTextFile: vi.fn(), readTextFile: vi.fn(),
@@ -65,7 +65,7 @@ function usePlanContent(planPath: string, config: Config): PlanContentState {
try { try {
const pathError = await validatePlanPath( const pathError = await validatePlanPath(
planPath, planPath,
config.storage.getProjectTempPlansDir(), config.storage.getPlansDir(),
config.getTargetDir(), config.getTargetDir(),
); );
if (ignore) return; if (ignore) return;
@@ -83,7 +83,7 @@ function usePlanContent(planPath: string, config: Config): PlanContentState {
const result = await processSingleFileContent( const result = await processSingleFileContent(
planPath, planPath,
config.storage.getProjectTempPlansDir(), config.storage.getPlansDir(),
config.getFileSystemService(), config.getFileSystemService(),
); );
@@ -49,7 +49,7 @@ describe('ToolConfirmationQueue', () => {
readFile: vi.fn().mockResolvedValue('Plan content'), readFile: vi.fn().mockResolvedValue('Plan content'),
}), }),
storage: { storage: {
getProjectTempPlansDir: () => '/mock/temp/plans', getPlansDir: () => '/mock/temp/plans',
}, },
} as unknown as Config; } as unknown as Config;
@@ -22,6 +22,9 @@ exports[`SettingsDialog > Initial Rendering > should render settings list with v
│ Enable Notifications false │ │ Enable Notifications false │
│ Enable run-event notifications for action-required prompts and session completion. … │ │ Enable run-event notifications for action-required prompts and session completion. … │
│ │ │ │
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
│ │
│ Enable Prompt Completion false │ │ Enable Prompt Completion false │
│ Enable AI-powered prompt completion suggestions while typing. │ │ Enable AI-powered prompt completion suggestions while typing. │
│ │ │ │
@@ -31,9 +34,6 @@ exports[`SettingsDialog > Initial Rendering > should render settings list with v
│ Enable Session Cleanup false │ │ Enable Session Cleanup false │
│ Enable automatic session cleanup │ │ Enable automatic session cleanup │
│ │ │ │
│ Keep chat history undefined │
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
│ │
│ ▼ │ │ ▼ │
│ │ │ │
│ Apply To │ │ Apply To │
@@ -69,6 +69,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'accessibility settings
│ Enable Notifications false │ │ Enable Notifications false │
│ Enable run-event notifications for action-required prompts and session completion. … │ │ Enable run-event notifications for action-required prompts and session completion. … │
│ │ │ │
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
│ │
│ Enable Prompt Completion false │ │ Enable Prompt Completion false │
│ Enable AI-powered prompt completion suggestions while typing. │ │ Enable AI-powered prompt completion suggestions while typing. │
│ │ │ │
@@ -78,9 +81,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'accessibility settings
│ Enable Session Cleanup false │ │ Enable Session Cleanup false │
│ Enable automatic session cleanup │ │ Enable automatic session cleanup │
│ │ │ │
│ Keep chat history undefined │
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
│ │
│ ▼ │ │ ▼ │
│ │ │ │
│ Apply To │ │ Apply To │
@@ -116,6 +116,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'all boolean settings d
│ Enable Notifications false │ │ Enable Notifications false │
│ Enable run-event notifications for action-required prompts and session completion. … │ │ Enable run-event notifications for action-required prompts and session completion. … │
│ │ │ │
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
│ │
│ Enable Prompt Completion false* │ │ Enable Prompt Completion false* │
│ Enable AI-powered prompt completion suggestions while typing. │ │ Enable AI-powered prompt completion suggestions while typing. │
│ │ │ │
@@ -125,9 +128,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'all boolean settings d
│ Enable Session Cleanup false │ │ Enable Session Cleanup false │
│ Enable automatic session cleanup │ │ Enable automatic session cleanup │
│ │ │ │
│ Keep chat history undefined │
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
│ │
│ ▼ │ │ ▼ │
│ │ │ │
│ Apply To │ │ Apply To │
@@ -163,6 +163,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'default state' correct
│ Enable Notifications false │ │ Enable Notifications false │
│ Enable run-event notifications for action-required prompts and session completion. … │ │ Enable run-event notifications for action-required prompts and session completion. … │
│ │ │ │
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
│ │
│ Enable Prompt Completion false │ │ Enable Prompt Completion false │
│ Enable AI-powered prompt completion suggestions while typing. │ │ Enable AI-powered prompt completion suggestions while typing. │
│ │ │ │
@@ -172,9 +175,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'default state' correct
│ Enable Session Cleanup false │ │ Enable Session Cleanup false │
│ Enable automatic session cleanup │ │ Enable automatic session cleanup │
│ │ │ │
│ Keep chat history undefined │
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
│ │
│ ▼ │ │ ▼ │
│ │ │ │
│ Apply To │ │ Apply To │
@@ -210,6 +210,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'file filtering setting
│ Enable Notifications false │ │ Enable Notifications false │
│ Enable run-event notifications for action-required prompts and session completion. … │ │ Enable run-event notifications for action-required prompts and session completion. … │
│ │ │ │
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
│ │
│ Enable Prompt Completion false │ │ Enable Prompt Completion false │
│ Enable AI-powered prompt completion suggestions while typing. │ │ Enable AI-powered prompt completion suggestions while typing. │
│ │ │ │
@@ -219,9 +222,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'file filtering setting
│ Enable Session Cleanup false │ │ Enable Session Cleanup false │
│ Enable automatic session cleanup │ │ Enable automatic session cleanup │
│ │ │ │
│ Keep chat history undefined │
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
│ │
│ ▼ │ │ ▼ │
│ │ │ │
│ Apply To │ │ Apply To │
@@ -257,6 +257,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'focused on scope selec
│ Enable Notifications false │ │ Enable Notifications false │
│ Enable run-event notifications for action-required prompts and session completion. … │ │ Enable run-event notifications for action-required prompts and session completion. … │
│ │ │ │
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
│ │
│ Enable Prompt Completion false │ │ Enable Prompt Completion false │
│ Enable AI-powered prompt completion suggestions while typing. │ │ Enable AI-powered prompt completion suggestions while typing. │
│ │ │ │
@@ -266,9 +269,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'focused on scope selec
│ Enable Session Cleanup false │ │ Enable Session Cleanup false │
│ Enable automatic session cleanup │ │ Enable automatic session cleanup │
│ │ │ │
│ Keep chat history undefined │
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
│ │
│ ▼ │ │ ▼ │
│ │ │ │
│ > Apply To │ │ > Apply To │
@@ -304,6 +304,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'mixed boolean and numb
│ Enable Notifications false │ │ Enable Notifications false │
│ Enable run-event notifications for action-required prompts and session completion. … │ │ Enable run-event notifications for action-required prompts and session completion. … │
│ │ │ │
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
│ │
│ Enable Prompt Completion false │ │ Enable Prompt Completion false │
│ Enable AI-powered prompt completion suggestions while typing. │ │ Enable AI-powered prompt completion suggestions while typing. │
│ │ │ │
@@ -313,9 +316,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'mixed boolean and numb
│ Enable Session Cleanup false │ │ Enable Session Cleanup false │
│ Enable automatic session cleanup │ │ Enable automatic session cleanup │
│ │ │ │
│ Keep chat history undefined │
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
│ │
│ ▼ │ │ ▼ │
│ │ │ │
│ Apply To │ │ Apply To │
@@ -351,6 +351,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'tools and security set
│ Enable Notifications false │ │ Enable Notifications false │
│ Enable run-event notifications for action-required prompts and session completion. … │ │ Enable run-event notifications for action-required prompts and session completion. … │
│ │ │ │
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
│ │
│ Enable Prompt Completion false │ │ Enable Prompt Completion false │
│ Enable AI-powered prompt completion suggestions while typing. │ │ Enable AI-powered prompt completion suggestions while typing. │
│ │ │ │
@@ -360,9 +363,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'tools and security set
│ Enable Session Cleanup false │ │ Enable Session Cleanup false │
│ Enable automatic session cleanup │ │ Enable automatic session cleanup │
│ │ │ │
│ Keep chat history undefined │
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
│ │
│ ▼ │ │ ▼ │
│ │ │ │
│ Apply To │ │ Apply To │
@@ -398,6 +398,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'various boolean settin
│ Enable Notifications false │ │ Enable Notifications false │
│ Enable run-event notifications for action-required prompts and session completion. … │ │ Enable run-event notifications for action-required prompts and session completion. … │
│ │ │ │
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
│ │
│ Enable Prompt Completion true* │ │ Enable Prompt Completion true* │
│ Enable AI-powered prompt completion suggestions while typing. │ │ Enable AI-powered prompt completion suggestions while typing. │
│ │ │ │
@@ -407,9 +410,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'various boolean settin
│ Enable Session Cleanup false │ │ Enable Session Cleanup false │
│ Enable automatic session cleanup │ │ Enable automatic session cleanup │
│ │ │ │
│ Keep chat history undefined │
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
│ │
│ ▼ │ │ ▼ │
│ │ │ │
│ Apply To │ │ Apply To │
+38 -2
View File
@@ -737,6 +737,42 @@ describe('Server Config (config.ts)', () => {
); );
}); });
describe('Plan Settings', () => {
const testCases = [
{
name: 'should pass custom plan directory to storage',
planSettings: { directory: 'custom-plans' },
expected: 'custom-plans',
},
{
name: 'should call setCustomPlansDir with undefined if directory is not provided',
planSettings: {},
expected: undefined,
},
{
name: 'should call setCustomPlansDir with undefined if planSettings is not provided',
planSettings: undefined,
expected: undefined,
},
];
testCases.forEach(({ name, planSettings, expected }) => {
it(`${name}`, () => {
const setCustomPlansDirSpy = vi.spyOn(
Storage.prototype,
'setCustomPlansDir',
);
new Config({
...baseParams,
planSettings,
});
expect(setCustomPlansDirSpy).toHaveBeenCalledWith(expected);
setCustomPlansDirSpy.mockRestore();
});
});
});
describe('Telemetry Settings', () => { describe('Telemetry Settings', () => {
it('should return default telemetry target if not provided', () => { it('should return default telemetry target if not provided', () => {
const params: ConfigParameters = { const params: ConfigParameters = {
@@ -2501,7 +2537,7 @@ describe('Plans Directory Initialization', () => {
await config.initialize(); await config.initialize();
const plansDir = config.storage.getProjectTempPlansDir(); const plansDir = config.storage.getPlansDir();
expect(fs.promises.mkdir).toHaveBeenCalledWith(plansDir, { expect(fs.promises.mkdir).toHaveBeenCalledWith(plansDir, {
recursive: true, recursive: true,
}); });
@@ -2518,7 +2554,7 @@ describe('Plans Directory Initialization', () => {
await config.initialize(); await config.initialize();
const plansDir = config.storage.getProjectTempPlansDir(); const plansDir = config.storage.getPlansDir();
expect(fs.promises.mkdir).not.toHaveBeenCalledWith(plansDir, { expect(fs.promises.mkdir).not.toHaveBeenCalledWith(plansDir, {
recursive: true, recursive: true,
}); });
+7 -1
View File
@@ -141,6 +141,10 @@ export interface SummarizeToolOutputSettings {
tokenBudget?: number; tokenBudget?: number;
} }
export interface PlanSettings {
directory?: string;
}
export interface TelemetrySettings { export interface TelemetrySettings {
enabled?: boolean; enabled?: boolean;
target?: TelemetryTarget; target?: TelemetryTarget;
@@ -483,6 +487,7 @@ export interface ConfigParameters {
toolOutputMasking?: Partial<ToolOutputMaskingConfig>; toolOutputMasking?: Partial<ToolOutputMaskingConfig>;
disableLLMCorrection?: boolean; disableLLMCorrection?: boolean;
plan?: boolean; plan?: boolean;
planSettings?: PlanSettings;
modelSteering?: boolean; modelSteering?: boolean;
onModelChange?: (model: string) => void; onModelChange?: (model: string) => void;
mcpEnabled?: boolean; mcpEnabled?: boolean;
@@ -836,6 +841,7 @@ export class Config {
this.extensionManagement = params.extensionManagement ?? true; this.extensionManagement = params.extensionManagement ?? true;
this.enableExtensionReloading = params.enableExtensionReloading ?? false; this.enableExtensionReloading = params.enableExtensionReloading ?? false;
this.storage = new Storage(this.targetDir, this.sessionId); this.storage = new Storage(this.targetDir, this.sessionId);
this.storage.setCustomPlansDir(params.planSettings?.directory);
this.fakeResponses = params.fakeResponses; this.fakeResponses = params.fakeResponses;
this.recordResponses = params.recordResponses; this.recordResponses = params.recordResponses;
@@ -949,7 +955,7 @@ export class Config {
// Add plans directory to workspace context for plan file storage // Add plans directory to workspace context for plan file storage
if (this.planEnabled) { if (this.planEnabled) {
const plansDir = this.storage.getProjectTempPlansDir(); const plansDir = this.storage.getPlansDir();
await fs.promises.mkdir(plansDir, { recursive: true }); await fs.promises.mkdir(plansDir, { recursive: true });
this.workspaceContext.addDirectory(plansDir); this.workspaceContext.addDirectory(plansDir);
} }
+104 -2
View File
@@ -12,12 +12,14 @@ vi.unmock('./storageMigration.js');
import * as os from 'node:os'; import * as os from 'node:os';
import * as path from 'node:path'; import * as path from 'node:path';
import * as fs from 'node:fs';
vi.mock('fs', async (importOriginal) => { vi.mock('fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('fs')>(); const actual = await importOriginal<typeof import('fs')>();
return { return {
...actual, ...actual,
mkdirSync: vi.fn(), mkdirSync: vi.fn(),
realpathSync: vi.fn(actual.realpathSync),
}; };
}); });
@@ -61,12 +63,11 @@ describe('Storage initialize', () => {
).toHaveBeenCalledWith(projectRoot); ).toHaveBeenCalledWith(projectRoot);
// Verify migration calls // Verify migration calls
const shortId = 'project-slug';
// We can't easily get the hash here without repeating logic, but we can verify it's called twice // We can't easily get the hash here without repeating logic, but we can verify it's called twice
expect(StorageMigration.migrateDirectory).toHaveBeenCalledTimes(2); expect(StorageMigration.migrateDirectory).toHaveBeenCalledTimes(2);
// Verify identifier is set by checking a path // Verify identifier is set by checking a path
expect(storage.getProjectTempDir()).toContain(shortId); expect(storage.getProjectTempDir()).toContain(PROJECT_SLUG);
}); });
}); });
@@ -105,6 +106,12 @@ describe('Storage additional helpers', () => {
const projectRoot = '/tmp/project'; const projectRoot = '/tmp/project';
const storage = new Storage(projectRoot); const storage = new Storage(projectRoot);
beforeEach(() => {
ProjectRegistry.prototype.getShortId = vi
.fn()
.mockReturnValue(PROJECT_SLUG);
});
it('getWorkspaceSettingsPath returns project/.gemini/settings.json', () => { it('getWorkspaceSettingsPath returns project/.gemini/settings.json', () => {
const expected = path.join(projectRoot, GEMINI_DIR, 'settings.json'); const expected = path.join(projectRoot, GEMINI_DIR, 'settings.json');
expect(storage.getWorkspaceSettingsPath()).toBe(expected); expect(storage.getWorkspaceSettingsPath()).toBe(expected);
@@ -172,6 +179,101 @@ describe('Storage additional helpers', () => {
const expected = path.join(tempDir, sessionId, 'plans'); const expected = path.join(tempDir, sessionId, 'plans');
expect(storageWithSession.getProjectTempPlansDir()).toBe(expected); expect(storageWithSession.getProjectTempPlansDir()).toBe(expected);
}); });
describe('getPlansDir', () => {
interface TestCase {
name: string;
customDir: string | undefined;
expected: string | (() => string);
expectedError?: string;
setup?: () => () => void;
}
const testCases: TestCase[] = [
{
name: 'custom relative path',
customDir: '.my-plans',
expected: path.resolve(projectRoot, '.my-plans'),
},
{
name: 'custom absolute path outside throws',
customDir: '/absolute/path/to/plans',
expected: '',
expectedError:
"Custom plans directory '/absolute/path/to/plans' resolves to '/absolute/path/to/plans', which is outside the project root '/tmp/project'.",
},
{
name: 'absolute path that happens to be inside project root',
customDir: path.join(projectRoot, 'internal-plans'),
expected: path.join(projectRoot, 'internal-plans'),
},
{
name: 'relative path that stays within project root',
customDir: 'subdir/../plans',
expected: path.resolve(projectRoot, 'plans'),
},
{
name: 'dot path',
customDir: '.',
expected: projectRoot,
},
{
name: 'default behavior when customDir is undefined',
customDir: undefined,
expected: () => storage.getProjectTempPlansDir(),
},
{
name: 'escaping relative path throws',
customDir: '../escaped-plans',
expected: '',
expectedError:
"Custom plans directory '../escaped-plans' resolves to '/tmp/escaped-plans', which is outside the project root '/tmp/project'.",
},
{
name: 'hidden directory starting with ..',
customDir: '..plans',
expected: path.resolve(projectRoot, '..plans'),
},
{
name: 'security escape via symbolic link throws',
customDir: 'symlink-to-outside',
setup: () => {
vi.mocked(fs.realpathSync).mockImplementation((p: fs.PathLike) => {
if (p.toString().includes('symlink-to-outside')) {
return '/outside/project/root';
}
return p.toString();
});
return () => vi.mocked(fs.realpathSync).mockRestore();
},
expected: '',
expectedError:
"Custom plans directory 'symlink-to-outside' resolves to '/outside/project/root', which is outside the project root '/tmp/project'.",
},
];
testCases.forEach(({ name, customDir, expected, expectedError, setup }) => {
it(`should handle ${name}`, async () => {
const cleanup = setup?.();
try {
if (name.includes('default behavior')) {
await storage.initialize();
}
storage.setCustomPlansDir(customDir);
if (expectedError) {
expect(() => storage.getPlansDir()).toThrow(expectedError);
} else {
const expectedValue =
typeof expected === 'function' ? expected() : expected;
expect(storage.getPlansDir()).toBe(expectedValue);
}
} finally {
cleanup?.();
}
});
});
});
}); });
describe('Storage - System Paths', () => { describe('Storage - System Paths', () => {
+27
View File
@@ -12,6 +12,8 @@ import {
GEMINI_DIR, GEMINI_DIR,
homedir, homedir,
GOOGLE_ACCOUNTS_FILENAME, GOOGLE_ACCOUNTS_FILENAME,
isSubpath,
resolveToRealPath,
} from '../utils/paths.js'; } from '../utils/paths.js';
import { ProjectRegistry } from './projectRegistry.js'; import { ProjectRegistry } from './projectRegistry.js';
import { StorageMigration } from './storageMigration.js'; import { StorageMigration } from './storageMigration.js';
@@ -26,12 +28,17 @@ export class Storage {
private readonly sessionId: string | undefined; private readonly sessionId: string | undefined;
private projectIdentifier: string | undefined; private projectIdentifier: string | undefined;
private initPromise: Promise<void> | undefined; private initPromise: Promise<void> | undefined;
private customPlansDir: string | undefined;
constructor(targetDir: string, sessionId?: string) { constructor(targetDir: string, sessionId?: string) {
this.targetDir = targetDir; this.targetDir = targetDir;
this.sessionId = sessionId; this.sessionId = sessionId;
} }
setCustomPlansDir(dir: string | undefined): void {
this.customPlansDir = dir;
}
static getGlobalGeminiDir(): string { static getGlobalGeminiDir(): string {
const homeDir = homedir(); const homeDir = homedir();
if (!homeDir) { if (!homeDir) {
@@ -253,6 +260,26 @@ export class Storage {
return path.join(this.getProjectTempDir(), 'plans'); return path.join(this.getProjectTempDir(), 'plans');
} }
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;
}
return this.getProjectTempPlansDir();
}
getProjectTempTasksDir(): string { getProjectTempTasksDir(): string {
if (this.sessionId) { if (this.sessionId) {
return path.join(this.getProjectTempDir(), this.sessionId, 'tasks'); return path.join(this.getProjectTempDir(), this.sessionId, 'tasks');
+2 -6
View File
@@ -89,9 +89,7 @@ describe('Core System Prompt (prompts.ts)', () => {
getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true), getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true),
storage: { storage: {
getProjectTempDir: vi.fn().mockReturnValue('/tmp/project-temp'), getProjectTempDir: vi.fn().mockReturnValue('/tmp/project-temp'),
getProjectTempPlansDir: vi getPlansDir: vi.fn().mockReturnValue('/tmp/project-temp/plans'),
.fn()
.mockReturnValue('/tmp/project-temp/plans'),
}, },
isInteractive: vi.fn().mockReturnValue(true), isInteractive: vi.fn().mockReturnValue(true),
isInteractiveShellEnabled: vi.fn().mockReturnValue(true), isInteractiveShellEnabled: vi.fn().mockReturnValue(true),
@@ -509,9 +507,7 @@ describe('Core System Prompt (prompts.ts)', () => {
vi.mocked(mockConfig.getApprovalMode).mockReturnValue( vi.mocked(mockConfig.getApprovalMode).mockReturnValue(
ApprovalMode.PLAN, ApprovalMode.PLAN,
); );
vi.mocked(mockConfig.storage.getProjectTempPlansDir).mockReturnValue( vi.mocked(mockConfig.storage.getPlansDir).mockReturnValue('/tmp/plans');
'/tmp/plans',
);
}); });
it('should include approved plan path when set in config', () => { it('should include approved plan path when set in config', () => {
@@ -38,9 +38,7 @@ describe('PromptProvider', () => {
getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true), getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true),
storage: { storage: {
getProjectTempDir: vi.fn().mockReturnValue('/tmp/project-temp'), getProjectTempDir: vi.fn().mockReturnValue('/tmp/project-temp'),
getProjectTempPlansDir: vi getPlansDir: vi.fn().mockReturnValue('/tmp/project-temp/plans'),
.fn()
.mockReturnValue('/tmp/project-temp/plans'),
}, },
isInteractive: vi.fn().mockReturnValue(true), isInteractive: vi.fn().mockReturnValue(true),
isInteractiveShellEnabled: vi.fn().mockReturnValue(true), isInteractiveShellEnabled: vi.fn().mockReturnValue(true),
+1 -1
View File
@@ -172,7 +172,7 @@ export class PromptProvider {
'planningWorkflow', 'planningWorkflow',
() => ({ () => ({
planModeToolsList, planModeToolsList,
plansDir: config.storage.getProjectTempPlansDir(), plansDir: config.storage.getPlansDir(),
approvedPlanPath: config.getApprovedPlanPath(), approvedPlanPath: config.getApprovedPlanPath(),
}), }),
isPlanMode, isPlanMode,
@@ -24,7 +24,7 @@ describe('EnterPlanModeTool', () => {
mockConfig = { mockConfig = {
setApprovalMode: vi.fn(), setApprovalMode: vi.fn(),
storage: { storage: {
getProjectTempPlansDir: vi.fn().mockReturnValue('/mock/plans/dir'), getPlansDir: vi.fn().mockReturnValue('/mock/plans/dir'),
} as unknown as Config['storage'], } as unknown as Config['storage'],
}; };
tool = new EnterPlanModeTool( tool = new EnterPlanModeTool(
@@ -45,7 +45,7 @@ describe('ExitPlanModeTool', () => {
setApprovalMode: vi.fn(), setApprovalMode: vi.fn(),
setApprovedPlanPath: vi.fn(), setApprovedPlanPath: vi.fn(),
storage: { storage: {
getProjectTempPlansDir: vi.fn().mockReturnValue(mockPlansDir), getPlansDir: vi.fn().mockReturnValue(mockPlansDir),
} as unknown as Config['storage'], } as unknown as Config['storage'],
}; };
tool = new ExitPlanModeTool( tool = new ExitPlanModeTool(
+4 -6
View File
@@ -57,7 +57,7 @@ export class ExitPlanModeTool extends BaseDeclarativeTool<
private config: Config, private config: Config,
messageBus: MessageBus, messageBus: MessageBus,
) { ) {
const plansDir = config.storage.getProjectTempPlansDir(); const plansDir = config.storage.getPlansDir();
const definition = getExitPlanModeDefinition(plansDir); const definition = getExitPlanModeDefinition(plansDir);
super( super(
EXIT_PLAN_MODE_TOOL_NAME, EXIT_PLAN_MODE_TOOL_NAME,
@@ -78,9 +78,7 @@ export class ExitPlanModeTool extends BaseDeclarativeTool<
// Since validateToolParamValues is synchronous, we use a basic synchronous check // Since validateToolParamValues is synchronous, we use a basic synchronous check
// for path traversal safety. High-level async validation is deferred to shouldConfirmExecute. // for path traversal safety. High-level async validation is deferred to shouldConfirmExecute.
const plansDir = resolveToRealPath( const plansDir = resolveToRealPath(this.config.storage.getPlansDir());
this.config.storage.getProjectTempPlansDir(),
);
const resolvedPath = path.resolve( const resolvedPath = path.resolve(
this.config.getTargetDir(), this.config.getTargetDir(),
params.plan_path, params.plan_path,
@@ -111,7 +109,7 @@ export class ExitPlanModeTool extends BaseDeclarativeTool<
} }
override getSchema(modelId?: string) { override getSchema(modelId?: string) {
const plansDir = this.config.storage.getProjectTempPlansDir(); const plansDir = this.config.storage.getPlansDir();
return resolveToolDeclaration(getExitPlanModeDefinition(plansDir), modelId); return resolveToolDeclaration(getExitPlanModeDefinition(plansDir), modelId);
} }
} }
@@ -141,7 +139,7 @@ export class ExitPlanModeInvocation extends BaseToolInvocation<
const pathError = await validatePlanPath( const pathError = await validatePlanPath(
this.params.plan_path, this.params.plan_path,
this.config.storage.getProjectTempPlansDir(), this.config.storage.getPlansDir(),
this.config.getTargetDir(), this.config.getTargetDir(),
); );
if (pathError) { if (pathError) {
+16
View File
@@ -105,6 +105,22 @@
}, },
"additionalProperties": false "additionalProperties": false
}, },
"plan": {
"title": "Plan",
"description": "Planning features configuration.",
"markdownDescription": "Planning features configuration.\n\n- Category: `General`\n- Requires restart: `yes`\n- Default: `{}`",
"default": {},
"type": "object",
"properties": {
"directory": {
"title": "Plan Directory",
"description": "The directory where planning artifacts are stored. If not specified, defaults to the system temporary directory.",
"markdownDescription": "The directory where planning artifacts are stored. If not specified, defaults to the system temporary directory.\n\n- Category: `General`\n- Requires restart: `yes`",
"type": "string"
}
},
"additionalProperties": false
},
"enablePromptCompletion": { "enablePromptCompletion": {
"title": "Enable Prompt Completion", "title": "Enable Prompt Completion",
"description": "Enable AI-powered prompt completion suggestions while typing.", "description": "Enable AI-powered prompt completion suggestions while typing.",