Compare commits

...

5 Commits

18 changed files with 569 additions and 81 deletions
+22 -15
View File
@@ -77,6 +77,7 @@ import type { InjectionSource } from '../config/injectionService.js';
import {
createScopedWorkspaceContext,
runWithScopedWorkspaceContext,
runWithScopedActiveExtension,
} from '../config/scoped-config.js';
import { CompleteTaskTool } from '../tools/complete-task.js';
import {
@@ -523,21 +524,27 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
* @returns A promise that resolves to the agent's final output.
*/
async run(inputs: AgentInputs, signal: AbortSignal): Promise<OutputObject> {
// If the agent definition declares additional workspace directories,
// wrap execution in a scoped workspace context. All calls to
// Config.getWorkspaceContext() within this scope will see the extended
// directories, without mutating the shared Config.
const dirs = this.definition.workspaceDirectories;
if (dirs && dirs.length > 0) {
const scopedCtx = createScopedWorkspaceContext(
this.context.config.getWorkspaceContext(),
dirs,
);
return runWithScopedWorkspaceContext(scopedCtx, () =>
this.runInternal(inputs, signal),
);
}
return this.runInternal(inputs, signal);
// Isolate activeExtensionName for sub-agents to prevent leaking context switches
return runWithScopedActiveExtension(
this.context.config.activeExtensionName ?? null,
() => {
// If the agent definition declares additional workspace directories,
// wrap execution in a scoped workspace context. All calls to
// Config.getWorkspaceContext() within this scope will see the extended
// directories, without mutating the shared Config.
const dirs = this.definition.workspaceDirectories;
if (dirs && dirs.length > 0) {
const scopedCtx = createScopedWorkspaceContext(
this.context.config.getWorkspaceContext(),
dirs,
);
return runWithScopedWorkspaceContext(scopedCtx, () =>
this.runInternal(inputs, signal),
);
}
return this.runInternal(inputs, signal);
},
);
}
private async runInternal(
@@ -26,6 +26,9 @@ export interface AgentLoopContext {
/** The unique ID for the parent session if this is a subagent. */
readonly parentSessionId?: string;
/** The name of the active extension driving this context, if any. */
readonly activeExtensionName?: string;
/** The registry of tools available to the agent in this context. */
readonly toolRegistry: ToolRegistry;
+50
View File
@@ -1490,6 +1490,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',
version: '1.0',
isActive: true,
path: '/ext',
contextFiles: [],
id: 'my-ext',
},
]);
expect(
config.getExtensionSetting('my-ext', 'some.setting'),
).toBeUndefined();
});
it('returns the setting value if it exists', () => {
const config = new Config(baseParams);
vi.spyOn(config, 'getExtensions').mockReturnValue([
{
name: 'my-ext',
version: '1.0',
isActive: true,
path: '/ext',
contextFiles: [],
id: 'my-ext',
resolvedSettings: [
{
name: 'some.setting',
value: 'custom-val',
envVar: 'MY_EXT_SOME_SETTING',
sensitive: false,
},
],
},
]);
expect(config.getExtensionSetting('my-ext', 'some.setting')).toBe(
'custom-val',
);
});
});
describe('getTruncateToolOutputThreshold', () => {
beforeEach(() => {
vi.clearAllMocks();
+46 -13
View File
@@ -133,7 +133,10 @@ import type { GenerateContentParameters } from '@google/genai';
export type { MCPOAuthConfig, AnyToolInvocation, AnyDeclarativeTool };
import type { AnyToolInvocation, AnyDeclarativeTool } from '../tools/tools.js';
import { WorkspaceContext } from '../utils/workspaceContext.js';
import { getWorkspaceContextOverride } from './scoped-config.js';
import {
getWorkspaceContextOverride,
getActiveExtensionOverride,
} from './scoped-config.js';
import { Storage } from './storage.js';
import type { ShellExecutionConfig } from '../services/shellExecutionService.js';
import { FileExclusions } from '../utils/ignorePatterns.js';
@@ -737,6 +740,26 @@ export class Config implements McpContext, AgentLoopContext {
private blockedEnvironmentVariables: string[];
private readonly enableEnvironmentVariableRedaction: boolean;
private _promptRegistry!: PromptRegistry;
private _activeExtensionName?: string;
get activeExtensionName(): string | undefined {
const override = getActiveExtensionOverride();
if (override !== undefined) {
return override.name === null ? undefined : override.name;
}
return (
this._activeExtensionName || process.env['GEMINI_CLI_ACTIVE_EXTENSION']
);
}
setActiveExtensionName(name: string | undefined): void {
const override = getActiveExtensionOverride();
if (override !== undefined) {
override.name = name ?? null;
} else {
this._activeExtensionName = name;
}
}
private _resourceRegistry!: ResourceRegistry;
private agentRegistry!: AgentRegistry;
private readonly acknowledgedAgentsService: AcknowledgedAgentsService;
@@ -2844,6 +2867,26 @@ export class Config implements McpContext, AgentLoopContext {
return this._extensionLoader.getExtensions();
}
/**
* Retrieves a setting value for a specific extension.
*
* @param extensionName - The name of the extension.
* @param settingName - The name of the setting to retrieve.
*/
getExtensionSetting<T>(
extensionName: string,
settingName: string,
): T | undefined {
const ext = this.getExtensions().find((e) => e.name === extensionName);
if (!ext || !ext.resolvedSettings) {
return undefined;
}
const setting = ext.resolvedSettings.find((s) => s.name === settingName);
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return setting?.value as T | undefined;
}
getExtensionLoader(): ExtensionLoader {
return this._extensionLoader;
}
@@ -3340,20 +3383,10 @@ export class Config implements McpContext, AgentLoopContext {
return this.shellExecutionConfig;
}
setShellExecutionConfig(config: ShellExecutionConfig): void {
setShellExecutionConfig(config: Partial<ShellExecutionConfig>): void {
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,
...config,
};
}
getScreenReader(): boolean {
@@ -300,4 +300,36 @@ describe('ProjectRegistry', () => {
'ProjectRegistry must be initialized before use',
);
});
it('retries on EBUSY during save', async () => {
const registry = new ProjectRegistry(registryPath);
await registry.initialize();
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 = new Error('Resource busy or locked');
(err as { code?: string }).code = 'EBUSY';
throw err;
}
return fs.promises
.copyFile(oldPath, newPath)
.then(() => fs.promises.unlink(oldPath));
});
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();
});
});
+35 -3
View File
@@ -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>;
@@ -83,17 +84,48 @@ export class ProjectRegistry {
await fs.promises.mkdir(dir, { recursive: true });
}
const tmpPath = this.registryPath + '.' + randomUUID() + '.tmp';
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);
// 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);
break; // Success
} catch (error: unknown) {
const code = isNodeError(error) ? error.code : '';
if (
(code === 'EBUSY' || code === 'EPERM') &&
attempt < maxRetries - 1
) {
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));
continue;
}
throw error;
}
}
} catch (error) {
debugLogger.error(
`Failed to save project registry to ${this.registryPath}:`,
error,
);
} finally {
// Clean up the temporary file if it was left behind
try {
if (fs.existsSync(tmpPath)) {
await fs.promises.unlink(tmpPath);
}
} catch {
// Ignore errors during cleanup
}
}
}
@@ -12,6 +12,8 @@ import {
createScopedWorkspaceContext,
runWithScopedWorkspaceContext,
getWorkspaceContextOverride,
runWithScopedActiveExtension,
getActiveExtensionOverride,
} from './scoped-config.js';
import { Config } from './config.js';
@@ -204,3 +206,65 @@ describe('runWithScopedWorkspaceContext', () => {
});
});
});
describe('runWithScopedActiveExtension', () => {
let config: Config;
let tempDir: string;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'scoped-run-'));
config = new Config({
targetDir: tempDir,
sessionId: 'test-session',
debugMode: false,
cwd: tempDir,
model: 'test-model',
});
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
it('should override Config.activeExtensionName within scope', () => {
config.setActiveExtensionName('global-ext');
runWithScopedActiveExtension('scoped-ext', () => {
expect(config.activeExtensionName).toBe('scoped-ext');
});
expect(config.activeExtensionName).toBe('global-ext');
});
it('should handle null to mask the global extension', () => {
config.setActiveExtensionName('global-ext');
runWithScopedActiveExtension(null, () => {
expect(config.activeExtensionName).toBeUndefined();
});
expect(config.activeExtensionName).toBe('global-ext');
});
it('should allow mutating the scoped extension using Config.setActiveExtensionName', () => {
config.setActiveExtensionName('global-ext');
runWithScopedActiveExtension('scoped-ext', () => {
config.setActiveExtensionName('mutated-scoped-ext');
expect(config.activeExtensionName).toBe('mutated-scoped-ext');
});
// The global state should remain untouched
expect(config.activeExtensionName).toBe('global-ext');
});
it('should return undefined from getActiveExtensionOverride outside scope', () => {
expect(getActiveExtensionOverride()).toBeUndefined();
});
it('should return the object from getActiveExtensionOverride inside scope', () => {
runWithScopedActiveExtension('scoped-ext', () => {
expect(getActiveExtensionOverride()).toEqual({ name: 'scoped-ext' });
});
});
});
+29
View File
@@ -19,6 +19,9 @@ import { WorkspaceContext } from '../utils/workspaceContext.js';
* This follows the same pattern as `toolCallContext` and `promptIdContext`.
*/
const workspaceContextOverride = new AsyncLocalStorage<WorkspaceContext>();
const activeExtensionOverride = new AsyncLocalStorage<{
name: string | null;
}>();
/**
* Returns the current workspace context override, if any.
@@ -28,6 +31,16 @@ export function getWorkspaceContextOverride(): WorkspaceContext | undefined {
return workspaceContextOverride.getStore();
}
/**
* Returns the current active extension name override, if any.
* Called by `Config.activeExtensionName` getter/setter to check for isolated scoped execution.
*/
export function getActiveExtensionOverride():
| { name: string | null }
| undefined {
return activeExtensionOverride.getStore();
}
/**
* Runs a function with a scoped workspace context override.
* Any calls to `Config.getWorkspaceContext()` within `fn` will return
@@ -44,6 +57,22 @@ export function runWithScopedWorkspaceContext<T>(
return workspaceContextOverride.run(scopedContext, fn);
}
/**
* Runs a function with a scoped active extension context override.
* Any calls to `Config.activeExtensionName` within `fn` will return
* the scoped context instead of the inherited default.
*
* @param scopedExtension The active extension name to use within the scope.
* @param fn The function to run.
* @returns The result of the function.
*/
export function runWithScopedActiveExtension<T>(
scopedExtension: string | null,
fn: () => T,
): T {
return activeExtensionOverride.run({ name: scopedExtension }, fn);
}
/**
* Creates a {@link WorkspaceContext} that extends a parent's directories
* with additional ones.
+37 -3
View File
@@ -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)}'.`,
},
];
+46 -14
View File
@@ -320,22 +320,54 @@ 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());
// We cannot use resolveToRealPath on resolvedPath directly if it doesn't exist yet
// To prevent traversal attacks via symlinks that don't exist yet, we check the un-real resolved path
// against the real project root, assuming the resolved path doesn't contain unresolved symlinks escaping the root.
// However, if it exists, we resolve it.
let realResolvedPath = resolvedPath;
try {
realResolvedPath = resolveToRealPath(resolvedPath);
} catch {
// Path doesn't exist, use the absolute normalized path
realResolvedPath = normalizePath(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();
}
+22 -6
View File
@@ -192,12 +192,28 @@ export class PromptProvider {
),
planningWorkflow: this.withSection(
'planningWorkflow',
() => ({
interactive: interactiveMode,
planModeToolsList,
plansDir: context.config.storage.getPlansDir(),
approvedPlanPath: context.config.getApprovedPlanPath(),
}),
() => {
let plansDir = '';
const activeExt = context.config.activeExtensionName;
let customDir: string | undefined;
if (activeExt) {
customDir = context.config.getExtensionSetting<string>(
activeExt,
'plan.directory',
);
}
try {
plansDir = context.config.storage.getPlansDir(customDir);
} catch {
// ignore
}
return {
interactive: interactiveMode,
planModeToolsList,
plansDir,
approvedPlanPath: context.config.getApprovedPlanPath(),
};
},
isPlanMode,
),
operationalGuidelines: this.withSection(
+16 -4
View File
@@ -466,10 +466,22 @@ class EditToolInvocation
);
if (this.config.isPlanMode()) {
const safeFilename = path.basename(this.params.file_path);
this.resolvedPath = path.join(
this.config.storage.getPlansDir(),
safeFilename,
);
let customDir: string | undefined;
const activeExt = this.config.activeExtensionName;
if (activeExt) {
customDir = this.config.getExtensionSetting<string>(
activeExt,
'plan.directory',
);
}
try {
this.resolvedPath = path.join(
this.config.storage.getPlansDir(customDir),
safeFilename,
);
} catch {
this.resolvedPath = ''; // Handled safely downstream
}
} else if (!path.isAbsolute(this.params.file_path)) {
const result = correctPath(this.params.file_path, this.config);
if (result.success) {
@@ -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,71 @@ 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({});
it('should create custom plan directories for active extensions', async () => {
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);
const invocation = tool.build({});
await invocation.execute({ abortSignal: new AbortController().signal });
expect(fs.mkdirSync).toHaveBeenCalledWith('/mock/plans/dir', {
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 () => {
+38 -8
View File
@@ -125,16 +125,46 @@ 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;
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);
}
}
}
+35 -8
View File
@@ -53,6 +53,18 @@ export class ExitPlanModeTool extends BaseDeclarativeTool<
);
}
private getResolvedPlansDir(): string {
let customDir: string | undefined;
const activeExt = this.config.activeExtensionName;
if (activeExt) {
customDir = this.config.getExtensionSetting<string>(
activeExt,
'plan.directory',
);
}
return this.config.storage.getPlansDir(customDir);
}
protected override validateToolParamValues(
params: ExitPlanModeParams,
): string | null {
@@ -61,11 +73,14 @@ export class ExitPlanModeTool extends BaseDeclarativeTool<
}
const safeFilename = path.basename(params.plan_filename);
const plansDir = resolveToRealPath(this.config.storage.getPlansDir());
const resolvedPath = path.join(
this.config.storage.getPlansDir(),
safeFilename,
);
let plansDir: string;
let resolvedPath: string;
try {
plansDir = resolveToRealPath(this.getResolvedPlansDir());
resolvedPath = path.join(this.getResolvedPlansDir(), safeFilename);
} catch {
return 'Failed to read plan directory: Path traversal attempt detected.';
}
const realPath = resolveToRealPath(resolvedPath);
@@ -114,6 +129,18 @@ export class ExitPlanModeInvocation extends BaseToolInvocation<
super(params, messageBus, toolName, toolDisplayName);
}
private getResolvedPlansDir(): string {
let customDir: string | undefined;
const activeExt = this.config.activeExtensionName;
if (activeExt) {
customDir = this.config.getExtensionSetting<string>(
activeExt,
'plan.directory',
);
}
return this.config.storage.getPlansDir(customDir);
}
override async shouldConfirmExecute(
abortSignal: AbortSignal,
): Promise<ToolExitPlanModeConfirmationDetails | false> {
@@ -121,7 +148,7 @@ export class ExitPlanModeInvocation extends BaseToolInvocation<
const pathError = await validatePlanPath(
this.params.plan_filename,
this.config.storage.getPlansDir(),
this.getResolvedPlansDir(),
);
if (pathError) {
this.planValidationError = pathError;
@@ -171,7 +198,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.getResolvedPlansDir(), this.params.plan_filename)}`;
}
/**
@@ -180,7 +207,7 @@ export class ExitPlanModeInvocation extends BaseToolInvocation<
*/
private getResolvedPlanPath(): string {
const safeFilename = path.basename(this.params.plan_filename);
return path.join(this.config.storage.getPlansDir(), safeFilename);
return path.join(this.getResolvedPlansDir(), safeFilename);
}
async execute({ abortSignal: _signal }: ExecuteOptions): Promise<ToolResult> {
+17 -1
View File
@@ -634,7 +634,23 @@ export class ToolRegistry {
*/
getFunctionDeclarations(modelId?: string): FunctionDeclaration[] {
const isPlanMode = this.config.getApprovalMode() === ApprovalMode.PLAN;
const plansDir = this.config.storage.getPlansDir();
let plansDir: string | undefined;
if (isPlanMode) {
let customDir: string | undefined;
const activeExt = this.config.activeExtensionName;
if (activeExt) {
customDir = this.config.getExtensionSetting<string>(
activeExt,
'plan.directory',
);
}
try {
plansDir = this.config.storage.getPlansDir(customDir);
} catch {
// ignore
}
}
const declarations: FunctionDeclaration[] = [];
const seenNames = new Set<string>();
+1
View File
@@ -38,6 +38,7 @@ export interface ExecuteOptions {
updateOutput?: (output: ToolLiveOutput) => void;
shellExecutionConfig?: ShellExecutionConfig;
setExecutionIdCallback?: (executionId: number) => void;
activeExtensionName?: string;
}
/**
+16 -4
View File
@@ -169,10 +169,22 @@ 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,
);
let customDir: string | undefined;
const activeExt = this.config.activeExtensionName;
if (activeExt) {
customDir = this.config.getExtensionSetting<string>(
activeExt,
'plan.directory',
);
}
try {
this.resolvedPath = path.join(
this.config.storage.getPlansDir(customDir),
safeFilename,
);
} catch {
this.resolvedPath = ''; // handled safely downstream
}
} else {
this.resolvedPath = path.resolve(
this.config.getTargetDir(),