mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-08-03 05:31:02 -07:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 41d20d40a6 | |||
| b9d3ede51d | |||
| d43af03598 | |||
| af332c614a | |||
| 922c6af4df |
@@ -79,6 +79,7 @@ function usePlanContent(planPath: string, config: Config): PlanContentState {
|
||||
useEffect(() => {
|
||||
let ignore = false;
|
||||
setState({ status: PlanStatus.Loading });
|
||||
debugLogger.debug('usePlanContent loading plan:', planPath);
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
@@ -126,6 +127,10 @@ function usePlanContent(planPath: string, config: Config): PlanContentState {
|
||||
setState({ status: PlanStatus.Error, error: 'Plan file is empty.' });
|
||||
return;
|
||||
}
|
||||
debugLogger.debug(
|
||||
'usePlanContent loaded successfully, length:',
|
||||
content.length,
|
||||
);
|
||||
setState({ status: PlanStatus.Loaded, content });
|
||||
} catch (err: unknown) {
|
||||
if (ignore) return;
|
||||
|
||||
@@ -813,6 +813,33 @@ describe('policy.ts', () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should map ProceedAlways to ProceedOnce in Plan Mode', async () => {
|
||||
const mockConfig = {
|
||||
getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.PLAN),
|
||||
setApprovalMode: vi.fn(),
|
||||
getSessionId: vi.fn().mockReturnValue('test-session-id'),
|
||||
} as unknown as Mocked<Config>;
|
||||
(mockConfig as unknown as { config: Config }).config =
|
||||
mockConfig as Config;
|
||||
const mockMessageBus = {
|
||||
publish: vi.fn(),
|
||||
} as unknown as Mocked<MessageBus>;
|
||||
(mockConfig as unknown as { messageBus: MessageBus }).messageBus =
|
||||
mockMessageBus;
|
||||
const tool = { name: 'replace' } as AnyDeclarativeTool;
|
||||
|
||||
await updatePolicy(
|
||||
tool,
|
||||
ToolConfirmationOutcome.ProceedAlways,
|
||||
undefined,
|
||||
mockConfig,
|
||||
mockMessageBus,
|
||||
);
|
||||
|
||||
expect(mockConfig.setApprovalMode).not.toHaveBeenCalled();
|
||||
expect(mockMessageBus.publish).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPolicyDenialError', () => {
|
||||
|
||||
@@ -121,6 +121,14 @@ export async function updatePolicy(
|
||||
): Promise<void> {
|
||||
const currentMode = context.config.getApprovalMode();
|
||||
|
||||
// If in Plan Mode, map 'Proceed Always' (Allow for this session) to 'Proceed Once' (Allow once)
|
||||
// to prevent transitioning to AUTO_EDIT mode and updating policy.
|
||||
if (
|
||||
currentMode === ApprovalMode.PLAN &&
|
||||
outcome === ToolConfirmationOutcome.ProceedAlways
|
||||
) {
|
||||
outcome = ToolConfirmationOutcome.ProceedOnce;
|
||||
}
|
||||
// Mode Transitions (AUTO_EDIT)
|
||||
if (isAutoEditTransition(tool, outcome)) {
|
||||
context.config.setApprovalMode(ApprovalMode.AUTO_EDIT);
|
||||
|
||||
@@ -1371,6 +1371,14 @@ function doIt() {
|
||||
});
|
||||
|
||||
describe('plan mode', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(mockConfig.isPlanMode).mockReturnValue(true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.mocked(mockConfig.isPlanMode).mockReturnValue(false);
|
||||
});
|
||||
|
||||
it('should allow edits to plans directory when isPlanMode is true', async () => {
|
||||
const mockProjectTempDir = path.join(tempDir, 'project');
|
||||
fs.mkdirSync(mockProjectTempDir);
|
||||
@@ -1380,8 +1388,6 @@ function doIt() {
|
||||
|
||||
const plansDir = path.join(mockProjectTempDir, 'plans');
|
||||
fs.mkdirSync(plansDir);
|
||||
|
||||
vi.mocked(mockConfig.isPlanMode).mockReturnValue(true);
|
||||
vi.mocked(mockConfig.storage.getPlansDir).mockReturnValue(plansDir);
|
||||
|
||||
const filePath = 'test-file.txt';
|
||||
@@ -1408,5 +1414,77 @@ function doIt() {
|
||||
|
||||
fs.rmSync(plansDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should preserve nested directory structure within the plans directory in Plan Mode', async () => {
|
||||
const mockProjectTempDir = path.join(tempDir, 'project');
|
||||
fs.mkdirSync(mockProjectTempDir);
|
||||
vi.mocked(mockConfig.storage.getProjectTempDir).mockReturnValue(
|
||||
mockProjectTempDir,
|
||||
);
|
||||
|
||||
const plansDir = path.join(mockProjectTempDir, 'plans');
|
||||
fs.mkdirSync(plansDir);
|
||||
vi.mocked(mockConfig.storage.getPlansDir).mockReturnValue(plansDir);
|
||||
|
||||
const nestedDir = path.join(plansDir, 'tracks', 'fibsqrt_20260519');
|
||||
fs.mkdirSync(nestedDir, { recursive: true });
|
||||
|
||||
const planFilePath = path.join(nestedDir, 'spec.md');
|
||||
const initialContent = 'some initial content';
|
||||
fs.writeFileSync(planFilePath, initialContent, 'utf8');
|
||||
|
||||
const params: EditToolParams = {
|
||||
file_path: 'tracks/fibsqrt_20260519/spec.md',
|
||||
instruction: 'Replace initial with new',
|
||||
old_string: 'initial',
|
||||
new_string: 'new',
|
||||
};
|
||||
|
||||
const invocation = tool.build(params);
|
||||
const result = await invocation.execute({
|
||||
abortSignal: new AbortController().signal,
|
||||
});
|
||||
|
||||
expect(result.llmContent).toMatch(/Successfully modified file/);
|
||||
expect(fs.readFileSync(planFilePath, 'utf8')).toBe('some new content');
|
||||
|
||||
fs.rmSync(plansDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should strip the leading plansDir folder name segment if present in path', async () => {
|
||||
const mockProjectTempDir = path.join(tempDir, 'project');
|
||||
fs.mkdirSync(mockProjectTempDir);
|
||||
vi.mocked(mockConfig.storage.getProjectTempDir).mockReturnValue(
|
||||
mockProjectTempDir,
|
||||
);
|
||||
|
||||
const plansDir = path.join(mockProjectTempDir, 'plans');
|
||||
fs.mkdirSync(plansDir);
|
||||
vi.mocked(mockConfig.storage.getPlansDir).mockReturnValue(plansDir);
|
||||
|
||||
const nestedDir = path.join(plansDir, 'tracks', 'fibsqrt_20260519');
|
||||
fs.mkdirSync(nestedDir, { recursive: true });
|
||||
|
||||
const planFilePath = path.join(nestedDir, 'spec.md');
|
||||
const initialContent = 'some initial content';
|
||||
fs.writeFileSync(planFilePath, initialContent, 'utf8');
|
||||
|
||||
const params: EditToolParams = {
|
||||
file_path: 'plans/tracks/fibsqrt_20260519/spec.md',
|
||||
instruction: 'Replace initial with new',
|
||||
old_string: 'initial',
|
||||
new_string: 'new',
|
||||
};
|
||||
|
||||
const invocation = tool.build(params);
|
||||
const result = await invocation.execute({
|
||||
abortSignal: new AbortController().signal,
|
||||
});
|
||||
|
||||
expect(result.llmContent).toMatch(/Successfully modified file/);
|
||||
expect(fs.readFileSync(planFilePath, 'utf8')).toBe('some new content');
|
||||
|
||||
fs.rmSync(plansDir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -515,5 +515,28 @@ Ask the user for specific feedback on how to improve the plan.`,
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should accept nested valid path within plans directory', () => {
|
||||
const nestedDir = path.join(mockPlansDir, 'tracks', 'fibsqrt_20260519');
|
||||
fs.mkdirSync(nestedDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(nestedDir, 'spec.md'), '# Content');
|
||||
|
||||
const result = tool.validateToolParams({
|
||||
plan_filename: 'tracks/fibsqrt_20260519/spec.md',
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should strip the leading plansDir folder name segment if present in path', () => {
|
||||
const plansDirName = path.basename(mockPlansDir);
|
||||
const nestedDir = path.join(mockPlansDir, 'tracks', 'fibsqrt_20260519');
|
||||
fs.mkdirSync(nestedDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(nestedDir, 'spec.md'), '# Content');
|
||||
|
||||
const result = tool.validateToolParams({
|
||||
plan_filename: `${plansDirName}/tracks/fibsqrt_20260519/spec.md`,
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -226,28 +226,19 @@ export class ExitPlanModeInvocation extends BaseToolInvocation<
|
||||
const exitMessage = getPlanModeExitMessage(newMode);
|
||||
|
||||
return {
|
||||
llmContent: `${exitMessage}
|
||||
|
||||
The approved implementation plan is stored at: ${resolvedPlanPath}
|
||||
Read and follow the plan strictly during implementation.`,
|
||||
llmContent: `${exitMessage}\n\nThe approved implementation plan is stored at: ${resolvedPlanPath}\nRead and follow the plan strictly during implementation.`,
|
||||
returnDisplay: `Plan approved: ${resolvedPlanPath}`,
|
||||
};
|
||||
} else {
|
||||
const feedback = payload?.feedback?.trim();
|
||||
if (feedback) {
|
||||
return {
|
||||
llmContent: `Plan rejected. User feedback: ${feedback}
|
||||
|
||||
The plan is stored at: ${resolvedPlanPath}
|
||||
Revise the plan based on the feedback.`,
|
||||
llmContent: `Plan rejected. User feedback: ${feedback}\n\nThe plan is stored at: ${resolvedPlanPath}\nRevise the plan based on the feedback.`,
|
||||
returnDisplay: `Feedback: ${feedback}`,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
llmContent: `Plan rejected. No feedback provided.
|
||||
|
||||
The plan is stored at: ${resolvedPlanPath}
|
||||
Ask the user for specific feedback on how to improve the plan.`,
|
||||
llmContent: `Plan rejected. No feedback provided.\n\nThe plan is stored at: ${resolvedPlanPath}\nAsk the user for specific feedback on how to improve the plan.`,
|
||||
returnDisplay: 'Rejected (no feedback)',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -110,6 +110,7 @@ const mockConfigInternal = {
|
||||
getActiveModel: () => 'test-model',
|
||||
storage: {
|
||||
getProjectTempDir: vi.fn().mockReturnValue('/tmp/project'),
|
||||
getPlansDir: vi.fn().mockReturnValue('/tmp/plans'),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -148,6 +149,7 @@ describe('WriteFileTool', () => {
|
||||
const workspaceContext = new WorkspaceContext(rootDir, [plansDir]);
|
||||
const mockStorage = {
|
||||
getProjectTempDir: vi.fn().mockReturnValue('/tmp/project'),
|
||||
getPlansDir: vi.fn().mockReturnValue(plansDir),
|
||||
};
|
||||
|
||||
mockConfig = {
|
||||
@@ -1146,4 +1148,38 @@ describe('WriteFileTool', () => {
|
||||
expect(fs.readFileSync(expectedWritePath, 'utf8')).toBe('nested content');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Plan Mode path resolution', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(mockConfigInternal.isPlanMode).mockReturnValue(true);
|
||||
vi.mocked(mockConfigInternal.storage.getPlansDir).mockReturnValue(
|
||||
plansDir,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.mocked(mockConfigInternal.isPlanMode).mockReturnValue(false);
|
||||
});
|
||||
|
||||
it('should preserve nested directory structure within the plans directory', () => {
|
||||
const planFilePath = 'tracks/fibsqrt_20260519/spec.md';
|
||||
const params = { file_path: planFilePath, content: '# Spec' };
|
||||
const invocation = tool.build(params);
|
||||
|
||||
expect(
|
||||
(invocation as unknown as { resolvedPath: string }).resolvedPath,
|
||||
).toBe(path.resolve(plansDir, 'tracks/fibsqrt_20260519/spec.md'));
|
||||
});
|
||||
|
||||
it('should strip the leading plansDir folder name segment if present in path', () => {
|
||||
const plansDirName = path.basename(plansDir);
|
||||
const planFilePath = `${plansDirName}/tracks/fibsqrt_20260519/spec.md`;
|
||||
const params = { file_path: planFilePath, content: '# Spec' };
|
||||
const invocation = tool.build(params);
|
||||
|
||||
expect(
|
||||
(invocation as unknown as { resolvedPath: string }).resolvedPath,
|
||||
).toBe(path.resolve(plansDir, 'tracks/fibsqrt_20260519/spec.md'));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
@@ -63,6 +67,56 @@ describe('planUtils', () => {
|
||||
);
|
||||
expect(result).toContain('Access denied');
|
||||
});
|
||||
|
||||
it('should validate a nested path within the plans directory', async () => {
|
||||
const nestedDir = path.join(plansDir, 'tracks', 'fibsqrt_20260519');
|
||||
fs.mkdirSync(nestedDir, { recursive: true });
|
||||
const planPath = path.join('tracks', 'fibsqrt_20260519', 'spec.md');
|
||||
const fullPath = path.join(plansDir, planPath);
|
||||
fs.writeFileSync(fullPath, '# Nested Spec');
|
||||
|
||||
const result = await validatePlanPath(planPath, plansDir, tempRootDir);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveAndValidatePlanPath', () => {
|
||||
it('should resolve simple filenames relative to plansDir', () => {
|
||||
const result = resolveAndValidatePlanPath(
|
||||
'implementation_plan.md',
|
||||
plansDir,
|
||||
tempRootDir,
|
||||
);
|
||||
expect(result).toBe(path.join(plansDir, 'implementation_plan.md'));
|
||||
});
|
||||
|
||||
it('should preserve subdirectories if already inside plansDir', () => {
|
||||
const planPath = path.join(
|
||||
'plans',
|
||||
'tracks',
|
||||
'fibsqrt_20260519',
|
||||
'spec.md',
|
||||
);
|
||||
const result = resolveAndValidatePlanPath(planPath, plansDir, tempRootDir);
|
||||
expect(result).toBe(
|
||||
path.join(plansDir, 'tracks', 'fibsqrt_20260519', 'spec.md'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should resolve paths relative to plansDir if they contain subdirectories', () => {
|
||||
const planPath = path.join('tracks', 'fibsqrt_20260519', 'spec.md');
|
||||
const result = resolveAndValidatePlanPath(planPath, plansDir, tempRootDir);
|
||||
expect(result).toBe(
|
||||
path.join(plansDir, 'tracks', 'fibsqrt_20260519', 'spec.md'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw access denied when escaping', () => {
|
||||
const planPath = '../../escaped.md';
|
||||
expect(() =>
|
||||
resolveAndValidatePlanPath(planPath, plansDir, tempRootDir),
|
||||
).toThrow(/Access denied/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validatePlanContent', () => {
|
||||
|
||||
@@ -40,38 +40,59 @@ export function resolveAndValidatePlanPath(
|
||||
throw new Error('Plan file path must be non-empty.');
|
||||
}
|
||||
|
||||
// 1. Handle case where agent provided an absolute path
|
||||
if (path.isAbsolute(trimmedPath)) {
|
||||
if (
|
||||
isSubpath(resolveToRealPath(plansDir), resolveToRealPath(trimmedPath))
|
||||
) {
|
||||
return trimmedPath;
|
||||
const realPlansDir = resolveToRealPath(plansDir);
|
||||
const plansDirName = path.basename(plansDir);
|
||||
|
||||
let normalizedPlanPath = trimmedPath;
|
||||
if (!path.isAbsolute(trimmedPath)) {
|
||||
const segments = trimmedPath.split(/[\\/]+/);
|
||||
if (segments.length > 1 && segments[0] === plansDirName) {
|
||||
normalizedPlanPath = segments.slice(1).join(path.sep);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Handle case where agent provided a path relative to the project root
|
||||
const resolvedFromProjectRoot = path.resolve(projectRoot, trimmedPath);
|
||||
if (
|
||||
isSubpath(
|
||||
resolveToRealPath(plansDir),
|
||||
resolveToRealPath(resolvedFromProjectRoot),
|
||||
)
|
||||
) {
|
||||
return resolvedFromProjectRoot;
|
||||
// 1. Handle case where agent provided an absolute path
|
||||
if (path.isAbsolute(normalizedPlanPath)) {
|
||||
try {
|
||||
const realResolved = resolveToRealPath(normalizedPlanPath);
|
||||
if (isSubpath(realPlansDir, realResolved)) {
|
||||
return normalizedPlanPath;
|
||||
}
|
||||
} catch {
|
||||
// Fall through if resolveToRealPath fails
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Handle default case where agent provided a path relative to the plans directory
|
||||
const resolvedPath = path.resolve(plansDir, trimmedPath);
|
||||
const realPath = resolveToRealPath(resolvedPath);
|
||||
const realPlansDir = resolveToRealPath(plansDir);
|
||||
|
||||
if (!isSubpath(realPlansDir, realPath)) {
|
||||
throw new Error(
|
||||
PlanErrorMessages.PATH_ACCESS_DENIED(trimmedPath, plansDir),
|
||||
);
|
||||
// 2. Try resolving relative to project root
|
||||
const resolvedFromProjectRoot = path.resolve(projectRoot, normalizedPlanPath);
|
||||
try {
|
||||
const realResolved = resolveToRealPath(resolvedFromProjectRoot);
|
||||
if (isSubpath(realPlansDir, realResolved)) {
|
||||
return resolvedFromProjectRoot;
|
||||
}
|
||||
} catch {
|
||||
const directResolved = path.resolve(resolvedFromProjectRoot);
|
||||
if (isSubpath(realPlansDir, directResolved)) {
|
||||
return resolvedFromProjectRoot;
|
||||
}
|
||||
}
|
||||
|
||||
return resolvedPath;
|
||||
// 3. Try resolving relative to plansDir
|
||||
const resolvedFromPlansDir = path.resolve(plansDir, normalizedPlanPath);
|
||||
try {
|
||||
const realResolved = resolveToRealPath(resolvedFromPlansDir);
|
||||
if (isSubpath(realPlansDir, realResolved)) {
|
||||
return resolvedFromPlansDir;
|
||||
}
|
||||
} catch {
|
||||
const directResolved = path.resolve(resolvedFromPlansDir);
|
||||
if (isSubpath(realPlansDir, directResolved)) {
|
||||
return resolvedFromPlansDir;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback boundary check: if still not a subpath, throw PATH_ACCESS_DENIED
|
||||
throw new Error(PlanErrorMessages.PATH_ACCESS_DENIED(trimmedPath, plansDir));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -48,6 +48,9 @@ if (packageName === 'core') {
|
||||
const docsSource = join(process.cwd(), '..', '..', 'docs');
|
||||
const docsTarget = join(process.cwd(), 'dist', 'docs');
|
||||
if (existsSync(docsSource)) {
|
||||
if (existsSync(docsTarget)) {
|
||||
execSync(`rm -rf "${docsTarget}"`);
|
||||
}
|
||||
cpSync(docsSource, docsTarget, { recursive: true, dereference: true });
|
||||
console.log('Copied documentation to dist/docs');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user