mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-08-03 21:51:11 -07:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 128b25b50e | |||
| aad3e4b148 | |||
| e32ef3cacc | |||
| 6d9dbac846 | |||
| 23977a3621 | |||
| b288542e37 |
@@ -349,6 +349,27 @@ describe('Server Config (config.ts)', () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should ignore properties that are explicitly undefined and preserve existing values', () => {
|
||||
const config = new Config(baseParams);
|
||||
|
||||
config.setShellExecutionConfig({
|
||||
terminalWidth: 80,
|
||||
showColor: true,
|
||||
});
|
||||
|
||||
expect(config.getShellExecutionConfig().terminalWidth).toBe(80);
|
||||
expect(config.getShellExecutionConfig().showColor).toBe(true);
|
||||
|
||||
// Provide undefined for terminalWidth, which should be ignored
|
||||
config.setShellExecutionConfig({
|
||||
terminalWidth: undefined,
|
||||
showColor: false,
|
||||
});
|
||||
|
||||
expect(config.getShellExecutionConfig().terminalWidth).toBe(80); // Should still be 80, not undefined
|
||||
expect(config.getShellExecutionConfig().showColor).toBe(false); // Should be updated
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -1490,6 +1511,56 @@ describe('Server Config (config.ts)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getExtensionSetting', () => {
|
||||
it('returns undefined if the extension does not exist', () => {
|
||||
const config = new Config(baseParams);
|
||||
vi.spyOn(config, 'getExtensions').mockReturnValue([]);
|
||||
expect(config.getExtensionSetting('foo', 'bar')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined if the extension has no resolvedSettings', () => {
|
||||
const config = new Config(baseParams);
|
||||
vi.spyOn(config, 'getExtensions').mockReturnValue([
|
||||
{
|
||||
name: 'my-ext-name',
|
||||
version: '1.0',
|
||||
isActive: true,
|
||||
path: '/ext',
|
||||
contextFiles: [],
|
||||
id: 'my-ext-id',
|
||||
},
|
||||
]);
|
||||
expect(
|
||||
config.getExtensionSetting('my-ext-id', 'some.setting'),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns the setting value if it exists', () => {
|
||||
const config = new Config(baseParams);
|
||||
vi.spyOn(config, 'getExtensions').mockReturnValue([
|
||||
{
|
||||
name: 'my-ext-name',
|
||||
version: '1.0',
|
||||
isActive: true,
|
||||
path: '/ext',
|
||||
contextFiles: [],
|
||||
id: 'my-ext-id',
|
||||
resolvedSettings: [
|
||||
{
|
||||
name: 'some.setting',
|
||||
value: 'custom-val',
|
||||
envVar: 'MY_EXT_SOME_SETTING',
|
||||
sensitive: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
expect(config.getExtensionSetting('my-ext-id', 'some.setting')).toBe(
|
||||
'custom-val',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTruncateToolOutputThreshold', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
@@ -2844,6 +2844,27 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
return this._extensionLoader.getExtensions();
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a setting value for a specific extension.
|
||||
*
|
||||
* @param extensionId - The ID of the extension.
|
||||
* @param settingName - The name of the setting to retrieve.
|
||||
*/
|
||||
getExtensionSetting(
|
||||
extensionId: string,
|
||||
settingName: string,
|
||||
): string | undefined {
|
||||
const ext = this.getExtensions().find(
|
||||
(e) => e.id === extensionId && e.isActive,
|
||||
);
|
||||
if (!ext || !ext.resolvedSettings) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const setting = ext.resolvedSettings.find((s) => s.name === settingName);
|
||||
return setting?.value;
|
||||
}
|
||||
|
||||
getExtensionLoader(): ExtensionLoader {
|
||||
return this._extensionLoader;
|
||||
}
|
||||
@@ -3340,20 +3361,23 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
return this.shellExecutionConfig;
|
||||
}
|
||||
|
||||
setShellExecutionConfig(config: ShellExecutionConfig): void {
|
||||
setShellExecutionConfig(config: Partial<ShellExecutionConfig>): void {
|
||||
const definedConfig: Partial<ShellExecutionConfig> = {};
|
||||
for (const [k, v] of Object.entries(config)) {
|
||||
// Only merge properties explicitly provided with a concrete value.
|
||||
// Filtering out `null` and `undefined` ensures existing system defaults
|
||||
// are preserved when an extension doesn't want to override them.
|
||||
if (v != null) {
|
||||
Object.assign(definedConfig, { [k]: v });
|
||||
}
|
||||
}
|
||||
|
||||
// Note: This performs a shallow merge. If the incoming config provides a nested
|
||||
// object (e.g., sandboxConfig), it will completely overwrite the existing
|
||||
// nested object rather than merging its individual properties.
|
||||
this.shellExecutionConfig = {
|
||||
...this.shellExecutionConfig,
|
||||
terminalWidth:
|
||||
config.terminalWidth ?? this.shellExecutionConfig.terminalWidth,
|
||||
terminalHeight:
|
||||
config.terminalHeight ?? this.shellExecutionConfig.terminalHeight,
|
||||
showColor: config.showColor ?? this.shellExecutionConfig.showColor,
|
||||
pager: config.pager ?? this.shellExecutionConfig.pager,
|
||||
sanitizationConfig:
|
||||
config.sanitizationConfig ??
|
||||
this.shellExecutionConfig.sanitizationConfig,
|
||||
sandboxManager:
|
||||
config.sandboxManager ?? this.shellExecutionConfig.sandboxManager,
|
||||
...definedConfig,
|
||||
};
|
||||
}
|
||||
getScreenReader(): boolean {
|
||||
|
||||
@@ -300,4 +300,78 @@ describe('ProjectRegistry', () => {
|
||||
'ProjectRegistry must be initialized before use',
|
||||
);
|
||||
});
|
||||
|
||||
it('retries on EBUSY during save', async () => {
|
||||
const registry = new ProjectRegistry(registryPath);
|
||||
await registry.initialize();
|
||||
|
||||
const originalRename = fs.promises.rename;
|
||||
const renameSpy = vi.spyOn(fs.promises, 'rename');
|
||||
let ebusyCount = 0;
|
||||
|
||||
renameSpy.mockImplementation(async (oldPath, newPath) => {
|
||||
// Only throw for the specific temporary file generated by save()
|
||||
if (oldPath.toString().includes('.tmp') && ebusyCount < 2) {
|
||||
ebusyCount++;
|
||||
const err = Object.assign(new Error('Resource busy or locked'), {
|
||||
code: 'EBUSY',
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
// On success, call the original native rename implementation
|
||||
return originalRename(oldPath, newPath);
|
||||
});
|
||||
|
||||
const projectPath = path.join(tempDir, 'ebusy-project');
|
||||
const shortId = await registry.getShortId(projectPath);
|
||||
expect(shortId).toBe('ebusy-project');
|
||||
expect(ebusyCount).toBe(2);
|
||||
|
||||
// Verify it actually saved properly after retries
|
||||
const data = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
|
||||
expect(data.projects[normalizePath(projectPath)]).toBe('ebusy-project');
|
||||
|
||||
renameSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('re-throws error if save ultimately fails after retries', async () => {
|
||||
const registry = new ProjectRegistry(registryPath);
|
||||
await registry.initialize();
|
||||
|
||||
const renameSpy = vi.spyOn(fs.promises, 'rename');
|
||||
const expectedError = Object.assign(new Error('Persistent EBUSY'), {
|
||||
code: 'EBUSY',
|
||||
});
|
||||
|
||||
// Mock rename to ALWAYS fail
|
||||
renameSpy.mockRejectedValue(expectedError);
|
||||
|
||||
const projectPath = path.join(tempDir, 'failing-project');
|
||||
await expect(registry.getShortId(projectPath)).rejects.toThrow(
|
||||
'Persistent EBUSY',
|
||||
);
|
||||
|
||||
renameSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('protects against data destruction by throwing on EACCES instead of resetting', async () => {
|
||||
// 1. Write valid registry data
|
||||
fs.writeFileSync(
|
||||
registryPath,
|
||||
JSON.stringify({ projects: { '/foo': 'bar' } }),
|
||||
);
|
||||
|
||||
const registry = new ProjectRegistry(registryPath);
|
||||
|
||||
// 2. Mock readFile to throw a permissions error
|
||||
const readFileSpy = vi.spyOn(fs.promises, 'readFile');
|
||||
readFileSpy.mockRejectedValue(
|
||||
Object.assign(new Error('Permission denied'), { code: 'EACCES' }),
|
||||
);
|
||||
|
||||
// 3. Initialization should NOT swallow the error
|
||||
await expect(registry.initialize()).rejects.toThrow('Permission denied');
|
||||
|
||||
readFileSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 } from '../utils/errors.js';
|
||||
|
||||
export interface RegistryData {
|
||||
projects: Record<string, string>;
|
||||
@@ -54,18 +55,27 @@ export class ProjectRegistry {
|
||||
}
|
||||
|
||||
private async loadData(): Promise<RegistryData> {
|
||||
if (!fs.existsSync(this.registryPath)) {
|
||||
return { projects: {} };
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await fs.promises.readFile(this.registryPath, 'utf8');
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
return JSON.parse(content);
|
||||
} catch (e) {
|
||||
debugLogger.debug('Failed to load registry: ', e);
|
||||
// If the registry is corrupted, we'll start fresh to avoid blocking the CLI
|
||||
return { projects: {} };
|
||||
} catch (error: unknown) {
|
||||
if (isNodeError(error) && error.code === 'ENOENT') {
|
||||
return { projects: {} }; // Normal first run
|
||||
}
|
||||
if (error instanceof SyntaxError) {
|
||||
debugLogger.warn(
|
||||
'Failed to load registry (JSON corrupted), resetting to empty: ',
|
||||
error,
|
||||
);
|
||||
// Ownership markers on disk will allow self-healing when short IDs are requested.
|
||||
return { projects: {} };
|
||||
}
|
||||
|
||||
// If it's a real filesystem error (e.g. EACCES permission denied), DO NOT swallow it.
|
||||
// Swallowing read errors and overwriting the file would permanently destroy user data.
|
||||
debugLogger.error('Critical failure reading project registry:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,18 +92,54 @@ export class ProjectRegistry {
|
||||
if (!fs.existsSync(dir)) {
|
||||
await fs.promises.mkdir(dir, { recursive: true });
|
||||
}
|
||||
// Use a randomized tmp path to avoid ENOENT crashes when save() is called concurrently
|
||||
const tmpPath = this.registryPath + '.' + randomUUID() + '.tmp';
|
||||
let savedSuccessfully = false;
|
||||
|
||||
try {
|
||||
// Unconditionally ensure the directory exists; recursive ignores EEXIST.
|
||||
await fs.promises.mkdir(dir, { recursive: true });
|
||||
|
||||
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);
|
||||
|
||||
// Exponential backoff for OS-level file locks (EBUSY/EPERM) during rename
|
||||
const maxRetries = 5;
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
try {
|
||||
await fs.promises.rename(tmpPath, this.registryPath);
|
||||
savedSuccessfully = true;
|
||||
break; // Success, exit the retry loop
|
||||
} catch (error: unknown) {
|
||||
const code = isNodeError(error) ? error.code : '';
|
||||
const isRetryable = code === 'EBUSY' || code === 'EPERM';
|
||||
|
||||
if (!isRetryable || attempt === maxRetries - 1) {
|
||||
throw error; // Throw immediately on fatal error or final attempt
|
||||
}
|
||||
|
||||
const delayMs = Math.pow(2, attempt) * 50;
|
||||
debugLogger.debug(
|
||||
`Rename failed with ${code}, retrying in ${delayMs}ms (attempt ${attempt + 1}/${maxRetries})...`,
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
debugLogger.error(
|
||||
`Failed to save project registry to ${this.registryPath}:`,
|
||||
error,
|
||||
);
|
||||
throw error;
|
||||
} finally {
|
||||
// Clean up the temporary file if it was left behind (e.g. if writeFile or rename failed)
|
||||
if (!savedSuccessfully) {
|
||||
try {
|
||||
await fs.promises.unlink(tmpPath);
|
||||
} catch {
|
||||
// Ignore errors during cleanup
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,7 +203,13 @@ export class ProjectRegistry {
|
||||
await this.save(currentData);
|
||||
return shortId;
|
||||
} finally {
|
||||
await release();
|
||||
try {
|
||||
await release();
|
||||
} catch (e) {
|
||||
// Prevent proper-lockfile errors (e.g. if the lock dir was externally deleted)
|
||||
// from masking the original error thrown inside the try block.
|
||||
debugLogger.error('Failed to release project registry lock:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,20 +223,19 @@ export class ProjectRegistry {
|
||||
|
||||
for (const baseDir of this.baseDirs) {
|
||||
const markerPath = path.join(baseDir, slug, PROJECT_ROOT_FILE);
|
||||
if (fs.existsSync(markerPath)) {
|
||||
try {
|
||||
const owner = (await fs.promises.readFile(markerPath, 'utf8')).trim();
|
||||
if (this.normalizePath(owner) !== this.normalizePath(projectPath)) {
|
||||
return false;
|
||||
}
|
||||
} catch (e) {
|
||||
debugLogger.debug(
|
||||
`Failed to read ownership marker ${markerPath}:`,
|
||||
e,
|
||||
);
|
||||
// If we can't read it, assume it's not ours or corrupted.
|
||||
try {
|
||||
const owner = (await fs.promises.readFile(markerPath, 'utf8')).trim();
|
||||
if (this.normalizePath(owner) !== this.normalizePath(projectPath)) {
|
||||
return false;
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
if (isNodeError(e) && e.code === 'ENOENT') {
|
||||
// Marker doesn't exist, this is fine, we just won't fail verification
|
||||
continue;
|
||||
}
|
||||
debugLogger.debug(`Failed to read ownership marker ${markerPath}:`, e);
|
||||
// If we can't read it for other reasons (perms, corrupted), assume not ours.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
|
||||
@@ -291,6 +291,40 @@ describe('Storage – additional helpers', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveWorkspaceRelativePath', () => {
|
||||
it('resolves a relative path correctly', () => {
|
||||
expect(storage.resolveWorkspaceRelativePath('foo/bar')).toBe(
|
||||
path.join(projectRoot, 'foo/bar'),
|
||||
);
|
||||
});
|
||||
|
||||
it('throws if homedir path escapes workspace', () => {
|
||||
// In this test, projectRoot is /tmp/project, and homedir is likely outside.
|
||||
// We expect this to throw an error about escaping the project root.
|
||||
expect(() => storage.resolveWorkspaceRelativePath('~/foo')).toThrow(
|
||||
/outside the project root/,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws if path escapes workspace', () => {
|
||||
expect(() => storage.resolveWorkspaceRelativePath('../outside')).toThrow(
|
||||
/outside the project root/,
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves an absolute path within workspace', () => {
|
||||
expect(
|
||||
storage.resolveWorkspaceRelativePath(path.join(projectRoot, 'inner')),
|
||||
).toBe(path.join(projectRoot, 'inner'));
|
||||
});
|
||||
|
||||
it('throws for an absolute path outside workspace', () => {
|
||||
expect(() => storage.resolveWorkspaceRelativePath('/tmp/foo')).toThrow(
|
||||
/outside the project root/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPlansDir', () => {
|
||||
interface TestCase {
|
||||
name: string;
|
||||
@@ -310,7 +344,7 @@ describe('Storage – additional helpers', () => {
|
||||
name: 'custom absolute path outside throws',
|
||||
customDir: path.resolve('/absolute/path/to/plans'),
|
||||
expected: '',
|
||||
expectedError: `Custom plans directory '${path.resolve('/absolute/path/to/plans')}' resolves to '${path.resolve('/absolute/path/to/plans')}', which is outside the project root '${resolveToRealPath(projectRoot)}'.`,
|
||||
expectedError: `Path '${path.resolve('/absolute/path/to/plans')}' resolves to '${path.resolve('/absolute/path/to/plans')}', which is outside the project root '${resolveToRealPath(projectRoot)}'.`,
|
||||
},
|
||||
{
|
||||
name: 'absolute path that happens to be inside project root',
|
||||
@@ -336,7 +370,7 @@ describe('Storage – additional helpers', () => {
|
||||
name: 'escaping relative path throws',
|
||||
customDir: '../escaped-plans',
|
||||
expected: '',
|
||||
expectedError: `Custom plans directory '../escaped-plans' resolves to '${resolveToRealPath(path.resolve(projectRoot, '../escaped-plans'))}', which is outside the project root '${resolveToRealPath(projectRoot)}'.`,
|
||||
expectedError: `Path '../escaped-plans' resolves to '${resolveToRealPath(path.resolve(projectRoot, '../escaped-plans'))}', which is outside the project root '${resolveToRealPath(projectRoot)}'.`,
|
||||
},
|
||||
{
|
||||
name: 'hidden directory starting with ..',
|
||||
@@ -356,7 +390,7 @@ describe('Storage – additional helpers', () => {
|
||||
return () => vi.mocked(fs.realpathSync).mockRestore();
|
||||
},
|
||||
expected: '',
|
||||
expectedError: `Custom plans directory 'symlink-to-outside' resolves to '${path.resolve('/outside/project/root')}', which is outside the project root '${resolveToRealPath(projectRoot)}'.`,
|
||||
expectedError: `Path 'symlink-to-outside' resolves to '${path.resolve('/outside/project/root')}', which is outside the project root '${resolveToRealPath(projectRoot)}'.`,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -320,22 +320,47 @@ export class Storage {
|
||||
return path.join(this.getProjectTempDir(), 'tracker');
|
||||
}
|
||||
|
||||
getPlansDir(): string {
|
||||
if (this.customPlansDir) {
|
||||
const resolvedPath = path.resolve(
|
||||
this.getProjectRoot(),
|
||||
this.customPlansDir,
|
||||
);
|
||||
const realProjectRoot = resolveToRealPath(this.getProjectRoot());
|
||||
const realResolvedPath = resolveToRealPath(resolvedPath);
|
||||
|
||||
if (!isSubpath(realProjectRoot, realResolvedPath)) {
|
||||
throw new Error(
|
||||
`Custom plans directory '${this.customPlansDir}' resolves to '${realResolvedPath}', which is outside the project root '${realProjectRoot}'.`,
|
||||
);
|
||||
/**
|
||||
* Resolves a path securely relative to the project root.
|
||||
* Throws if the path attempts to escape the workspace (e.g. via ../).
|
||||
*/
|
||||
resolveWorkspaceRelativePath(customPath: string): string {
|
||||
const isWindows = os.platform() === 'win32';
|
||||
// Normalize tilde to homedir
|
||||
let expandedPath = customPath;
|
||||
if (
|
||||
expandedPath.startsWith('~/') ||
|
||||
(isWindows && expandedPath.startsWith('~\\'))
|
||||
) {
|
||||
const home = homedir();
|
||||
if (home) {
|
||||
expandedPath = path.join(home, expandedPath.slice(2));
|
||||
}
|
||||
} else if (expandedPath === '~') {
|
||||
expandedPath = homedir() || expandedPath;
|
||||
}
|
||||
|
||||
return resolvedPath;
|
||||
const resolvedPath = path.resolve(this.getProjectRoot(), expandedPath);
|
||||
const realProjectRoot = resolveToRealPath(this.getProjectRoot());
|
||||
|
||||
// By enforcing resolveToRealPath, we guarantee symlinks are evaluated.
|
||||
// If the path doesn't exist, this will throw an error, strictly preventing
|
||||
// traversal vulnerabilities via missing symlinks or permission gaps.
|
||||
const realResolvedPath = resolveToRealPath(resolvedPath);
|
||||
|
||||
if (!isSubpath(realProjectRoot, realResolvedPath)) {
|
||||
throw new Error(
|
||||
`Path '${customPath}' resolves to '${realResolvedPath}', which is outside the project root '${realProjectRoot}'.`,
|
||||
);
|
||||
}
|
||||
|
||||
return resolvedPath;
|
||||
}
|
||||
|
||||
getPlansDir(customDir?: string): string {
|
||||
const dirToResolve = customDir ?? this.customPlansDir;
|
||||
if (dirToResolve) {
|
||||
return this.resolveWorkspaceRelativePath(dirToResolve);
|
||||
}
|
||||
return this.getProjectTempPlansDir();
|
||||
}
|
||||
|
||||
@@ -39,6 +39,8 @@ describe('EnterPlanModeTool', () => {
|
||||
|
||||
mockConfig = {
|
||||
setApprovalMode: vi.fn(),
|
||||
getExtensions: vi.fn().mockReturnValue([]),
|
||||
getExtensionSetting: vi.fn(),
|
||||
storage: {
|
||||
getPlansDir: vi.fn().mockReturnValue('/mock/plans/dir'),
|
||||
} as unknown as Config['storage'],
|
||||
@@ -132,15 +134,118 @@ 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);
|
||||
it('should create custom plan directories for active extensions, handling overrides and fallbacks', async () => {
|
||||
vi.mocked(mockConfig.getExtensions!).mockReturnValue([
|
||||
{
|
||||
id: 'ext-user-override-id',
|
||||
name: 'ext-user-override',
|
||||
isActive: true,
|
||||
plan: { directory: '.manifest-dir' }, // Manifest default exists
|
||||
} as import('../config/config.js').GeminiCLIExtension,
|
||||
{
|
||||
id: 'ext-manifest-fallback-id',
|
||||
name: 'ext-manifest-fallback',
|
||||
isActive: true,
|
||||
plan: { directory: '.manifest-dir' }, // Only manifest default
|
||||
} as import('../config/config.js').GeminiCLIExtension,
|
||||
{
|
||||
id: 'ext-no-custom-id',
|
||||
name: 'ext-no-custom',
|
||||
isActive: true,
|
||||
} as import('../config/config.js').GeminiCLIExtension,
|
||||
{
|
||||
id: 'ext-inactive-id',
|
||||
name: 'ext-inactive',
|
||||
isActive: false,
|
||||
plan: { directory: '.inactive-dir' },
|
||||
} as import('../config/config.js').GeminiCLIExtension,
|
||||
]);
|
||||
|
||||
vi.mocked(mockConfig.getExtensionSetting!).mockImplementation(
|
||||
(id, setting) => {
|
||||
if (id === 'ext-user-override-id' && setting === 'plan.directory') {
|
||||
return '.user-override-dir'; // User setting wins
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
);
|
||||
|
||||
vi.mocked(mockConfig.storage!.getPlansDir).mockImplementation(
|
||||
(customDir?: string) => {
|
||||
if (customDir === '.user-override-dir')
|
||||
return '/mock/plans/user-override';
|
||||
if (customDir === '.manifest-dir')
|
||||
return '/mock/plans/manifest-default';
|
||||
return '/mock/plans/global-default';
|
||||
},
|
||||
);
|
||||
|
||||
const invocation = tool.build({});
|
||||
await invocation.execute({ abortSignal: new AbortController().signal });
|
||||
|
||||
expect(fs.mkdirSync).toHaveBeenCalledWith('/mock/plans/dir', {
|
||||
// 1. Global default should be created
|
||||
expect(fs.mkdirSync).toHaveBeenCalledWith('/mock/plans/global-default', {
|
||||
recursive: true,
|
||||
});
|
||||
// 2. User override should be created for ext-user-override
|
||||
expect(fs.mkdirSync).toHaveBeenCalledWith('/mock/plans/user-override', {
|
||||
recursive: true,
|
||||
});
|
||||
// 3. Manifest default should be created for ext-manifest-fallback
|
||||
expect(fs.mkdirSync).toHaveBeenCalledWith(
|
||||
'/mock/plans/manifest-default',
|
||||
{
|
||||
recursive: true,
|
||||
},
|
||||
);
|
||||
// 4. No folder should be created for ext-no-custom
|
||||
// 5. No folder should be created for ext-inactive
|
||||
expect(fs.mkdirSync).not.toHaveBeenCalledWith(
|
||||
'/mock/plans/inactive-dir',
|
||||
{
|
||||
recursive: true,
|
||||
},
|
||||
);
|
||||
|
||||
expect(fs.mkdirSync).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('should ignore validation failures for extension-specific plan directories and continue', async () => {
|
||||
vi.mocked(mockConfig.getExtensions!).mockReturnValue([
|
||||
{
|
||||
name: 'ext-invalid',
|
||||
isActive: true,
|
||||
plan: { directory: '../illegal' },
|
||||
} as import('../config/config.js').GeminiCLIExtension,
|
||||
{
|
||||
name: 'ext-valid',
|
||||
isActive: true,
|
||||
plan: { directory: '.valid' },
|
||||
} as import('../config/config.js').GeminiCLIExtension,
|
||||
]);
|
||||
|
||||
vi.mocked(mockConfig.storage!.getPlansDir).mockImplementation(
|
||||
(customDir?: string) => {
|
||||
if (customDir === '../illegal')
|
||||
throw new Error('Path traversal detected');
|
||||
if (customDir === '.valid') return '/mock/plans/valid';
|
||||
return '/mock/plans/global-default';
|
||||
},
|
||||
);
|
||||
|
||||
const invocation = tool.build({});
|
||||
await invocation.execute({ abortSignal: new AbortController().signal });
|
||||
|
||||
// Should create global default
|
||||
expect(fs.mkdirSync).toHaveBeenCalledWith('/mock/plans/global-default', {
|
||||
recursive: true,
|
||||
});
|
||||
// Should create valid extension dir
|
||||
expect(fs.mkdirSync).toHaveBeenCalledWith('/mock/plans/valid', {
|
||||
recursive: true,
|
||||
});
|
||||
// Should NOT have crashed on illegal dir
|
||||
expect(fs.mkdirSync).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should include optional reason in output display but not in llmContent', async () => {
|
||||
|
||||
@@ -125,16 +125,48 @@ export class EnterPlanModeInvocation extends BaseToolInvocation<
|
||||
|
||||
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
|
||||
// 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);
|
||||
const dirsToCreate = new Set<string>();
|
||||
|
||||
// Always ensure the default plans directory exists
|
||||
try {
|
||||
dirsToCreate.add(this.config.storage.getPlansDir(undefined));
|
||||
} catch {
|
||||
// Ignore if default somehow throws (unlikely)
|
||||
}
|
||||
|
||||
// Ensure extension-specific plan directories exist
|
||||
for (const ext of this.config.getExtensions()) {
|
||||
if (!ext.isActive) continue;
|
||||
|
||||
// Check for user-defined custom plan directory setting first.
|
||||
// If not set, fallback to the default directory defined in the extension's manifest.
|
||||
const customDir =
|
||||
this.config.getExtensionSetting(ext.id, 'plan.directory') ??
|
||||
ext.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