mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-23 00:01:24 -07:00
feat(core): implement JIT plan directory provisioning with path safety
This commit is contained in:
@@ -39,6 +39,8 @@ describe('EnterPlanModeTool', () => {
|
|||||||
|
|
||||||
mockConfig = {
|
mockConfig = {
|
||||||
setApprovalMode: vi.fn(),
|
setApprovalMode: vi.fn(),
|
||||||
|
getExtensions: vi.fn().mockReturnValue([]),
|
||||||
|
getExtensionSetting: vi.fn(),
|
||||||
storage: {
|
storage: {
|
||||||
getPlansDir: vi.fn().mockReturnValue('/mock/plans/dir'),
|
getPlansDir: vi.fn().mockReturnValue('/mock/plans/dir'),
|
||||||
} as unknown as Config['storage'],
|
} as unknown as Config['storage'],
|
||||||
@@ -132,15 +134,71 @@ describe('EnterPlanModeTool', () => {
|
|||||||
expect(result.returnDisplay).toBe('Switching to Plan mode');
|
expect(result.returnDisplay).toBe('Switching to Plan mode');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should create plans directory if it does not exist', async () => {
|
it('should create custom plan directories for active extensions', async () => {
|
||||||
const invocation = tool.build({});
|
vi.mocked(mockConfig.getExtensions!).mockReturnValue([
|
||||||
|
{
|
||||||
|
name: 'ext-a',
|
||||||
|
isActive: true,
|
||||||
|
} as import('../config/config.js').GeminiCLIExtension,
|
||||||
|
{
|
||||||
|
name: 'ext-b',
|
||||||
|
isActive: false,
|
||||||
|
} as import('../config/config.js').GeminiCLIExtension,
|
||||||
|
]);
|
||||||
|
vi.mocked(mockConfig.getExtensionSetting!).mockImplementation(
|
||||||
|
(name, setting) => {
|
||||||
|
if (name === 'ext-a' && setting === 'plan.directory')
|
||||||
|
return '.ext-a-plans';
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
vi.mocked(mockConfig.storage!.getPlansDir).mockImplementation(
|
||||||
|
(customDir?: string) => {
|
||||||
|
if (customDir === '.ext-a-plans') return '/mock/plans/ext-a-plans';
|
||||||
|
return '/mock/plans/dir';
|
||||||
|
},
|
||||||
|
);
|
||||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||||
|
|
||||||
|
const invocation = tool.build({});
|
||||||
await invocation.execute({ abortSignal: new AbortController().signal });
|
await invocation.execute({ abortSignal: new AbortController().signal });
|
||||||
|
|
||||||
expect(fs.mkdirSync).toHaveBeenCalledWith('/mock/plans/dir', {
|
expect(fs.mkdirSync).toHaveBeenCalledWith('/mock/plans/dir', {
|
||||||
recursive: true,
|
recursive: true,
|
||||||
});
|
});
|
||||||
|
expect(fs.mkdirSync).toHaveBeenCalledWith('/mock/plans/ext-a-plans', {
|
||||||
|
recursive: true,
|
||||||
|
});
|
||||||
|
expect(fs.mkdirSync).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should ignore validation failures for extension-specific plan directories', async () => {
|
||||||
|
vi.mocked(mockConfig.getExtensions!).mockReturnValue([
|
||||||
|
{
|
||||||
|
name: 'ext-a',
|
||||||
|
isActive: true,
|
||||||
|
} as import('../config/config.js').GeminiCLIExtension,
|
||||||
|
]);
|
||||||
|
vi.mocked(mockConfig.getExtensionSetting!).mockReturnValue(
|
||||||
|
'../outside-workspace',
|
||||||
|
);
|
||||||
|
vi.mocked(mockConfig.storage!.getPlansDir).mockImplementation(
|
||||||
|
(customDir?: string) => {
|
||||||
|
if (customDir === '../outside-workspace')
|
||||||
|
throw new Error('Path traversal detected');
|
||||||
|
return '/mock/plans/dir';
|
||||||
|
},
|
||||||
|
);
|
||||||
|
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||||
|
|
||||||
|
const invocation = tool.build({});
|
||||||
|
await invocation.execute({ abortSignal: new AbortController().signal });
|
||||||
|
|
||||||
|
// Should only create the default one
|
||||||
|
expect(fs.mkdirSync).toHaveBeenCalledWith('/mock/plans/dir', {
|
||||||
|
recursive: true,
|
||||||
|
});
|
||||||
|
expect(fs.mkdirSync).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should include optional reason in output display but not in llmContent', async () => {
|
it('should include optional reason in output display but not in llmContent', async () => {
|
||||||
|
|||||||
@@ -125,16 +125,46 @@ export class EnterPlanModeInvocation extends BaseToolInvocation<
|
|||||||
|
|
||||||
this.config.setApprovalMode(ApprovalMode.PLAN);
|
this.config.setApprovalMode(ApprovalMode.PLAN);
|
||||||
|
|
||||||
// Ensure plans directory exists so that the agent can write the plan file.
|
// Ensure plans directories exist so that the agent can write plan files.
|
||||||
// In sandboxed environments, the plans directory must exist on the host
|
// In sandboxed environments, the plans directory must exist on the host
|
||||||
// before it can be bound/allowed in the sandbox.
|
// before it can be bound/allowed in the sandbox.
|
||||||
const plansDir = this.config.storage.getPlansDir();
|
const dirsToCreate = new Set<string>();
|
||||||
if (!fs.existsSync(plansDir)) {
|
|
||||||
try {
|
// Always ensure the default plans directory exists
|
||||||
fs.mkdirSync(plansDir, { recursive: true });
|
try {
|
||||||
} catch (e) {
|
dirsToCreate.add(this.config.storage.getPlansDir(undefined));
|
||||||
// Log error but don't fail; write_file will try again later
|
} catch {
|
||||||
debugLogger.error(`Failed to create plans directory: ${plansDir}`, e);
|
// Ignore if default somehow throws (unlikely)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure extension-specific plan directories exist
|
||||||
|
for (const ext of this.config.getExtensions()) {
|
||||||
|
if (!ext.isActive) continue;
|
||||||
|
|
||||||
|
const customDir = this.config.getExtensionSetting<string>(
|
||||||
|
ext.name,
|
||||||
|
'plan.directory',
|
||||||
|
);
|
||||||
|
if (customDir) {
|
||||||
|
try {
|
||||||
|
dirsToCreate.add(this.config.storage.getPlansDir(customDir));
|
||||||
|
} catch (e) {
|
||||||
|
debugLogger.warn(
|
||||||
|
`Invalid custom plan directory '${customDir}' for extension '${ext.name}':`,
|
||||||
|
e,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const dir of dirsToCreate) {
|
||||||
|
if (!fs.existsSync(dir)) {
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
} catch (e) {
|
||||||
|
// Log error but don't fail; write_file will try again later
|
||||||
|
debugLogger.error(`Failed to create plans directory: ${dir}`, e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user