From c57856748896b7cb9ae270d8bbca1fbf0b22cf4a Mon Sep 17 00:00:00 2001 From: Mahima Shanware Date: Mon, 6 Apr 2026 17:41:56 +0000 Subject: [PATCH] fix(core): address extension context stickiness and symlink path resolution This commit addresses two bugs identified during review: 1. Cleared the sticky `activeExtensionContext` when the standard `/plan` command is executed, ensuring subsequent prompts correctly target the default global plan directory. 2. Fixed a path resolution regression in `Storage.getPlansDir()` by constructing the fallback ENOENT path directly against the real project root. This prevents `isSubpath` validation failures and potential traversal vulnerabilities when the project root is a symlink. --- packages/cli/src/test-utils/mockConfig.ts | 2 ++ packages/cli/src/ui/AppContainer.test.tsx | 33 +++++++++++++++++++++++ packages/cli/src/ui/AppContainer.tsx | 10 ++++--- packages/core/src/config/storage.test.ts | 20 ++++++++++++++ packages/core/src/config/storage.ts | 2 ++ 5 files changed, 64 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/test-utils/mockConfig.ts b/packages/cli/src/test-utils/mockConfig.ts index daf109d928..3f85690d45 100644 --- a/packages/cli/src/test-utils/mockConfig.ts +++ b/packages/cli/src/test-utils/mockConfig.ts @@ -23,6 +23,8 @@ export const createMockConfig = (overrides: Partial = {}): Config => isInteractive: vi.fn(() => false), isInitialized: vi.fn(() => true), setTerminalBackground: vi.fn(), + setActiveExtensionContext: vi.fn(), + hasExtensionPlanDir: vi.fn(() => true), storage: { getProjectTempDir: vi.fn().mockReturnValue('/tmp/gemini-test'), initialize: vi.fn().mockResolvedValue(undefined), diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 0e436cc645..051e8834f1 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -3182,6 +3182,39 @@ describe('AppContainer State Management', () => { ); unmount(); }); + + it('clears activeExtensionContext when /plan is explicitly executed', async () => { + const { checkPermissions } = await import( + './hooks/atCommandProcessor.js' + ); + vi.mocked(checkPermissions).mockResolvedValue([]); + + mockedUseSlashCommandProcessor.mockReturnValue({ + handleSlashCommand: vi.fn(), + slashCommands: [{ name: 'plan', description: 'test', action: vi.fn() }], + pendingHistoryItems: [], + commandContext: {}, + shellConfirmationRequest: null, + confirmationRequest: null, + }); + + const spySetActiveExtensionContext = vi.spyOn( + mockConfig, + 'setActiveExtensionContext', + ); + + const { unmount } = await act(async () => renderAppContainer()); + + expect(capturedUIActions).toBeTruthy(); + + await act(async () => + capturedUIActions.handleFinalSubmit('/plan my task'), + ); + + expect(spySetActiveExtensionContext).toHaveBeenCalledWith(undefined); + + unmount(); + }); }); describe('Overflow Hint Handling', () => { diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index ee0d49f2be..2a34029e4d 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -1313,9 +1313,13 @@ Logging in with Google... Restarting Gemini CLI to continue. slashCommands ?? [], ); - if (parsedCommand.extensionContext && config) { - if (config.hasExtensionPlanDir(parsedCommand.extensionContext)) { - config.setActiveExtensionContext(parsedCommand.extensionContext); + if (config) { + if (parsedCommand.extensionContext) { + if (config.hasExtensionPlanDir(parsedCommand.extensionContext)) { + config.setActiveExtensionContext(parsedCommand.extensionContext); + } + } else if (parsedCommand.commandToExecute?.name === 'plan') { + config.setActiveExtensionContext(undefined); } } diff --git a/packages/core/src/config/storage.test.ts b/packages/core/src/config/storage.test.ts index b5b8c26841..e695ae5d9b 100644 --- a/packages/core/src/config/storage.test.ts +++ b/packages/core/src/config/storage.test.ts @@ -358,6 +358,26 @@ describe('Storage – additional helpers', () => { expectedError: "Custom plans directory 'symlink-to-outside' resolves to '/outside/project/root', which is outside the project root '/tmp/project'.", }, + { + name: 'non-existent plan dir in a symlinked project root', + customDir: 'new-plans', + setup: () => { + vi.mocked(fs.realpathSync).mockImplementation((p: fs.PathLike) => { + const pStr = p.toString(); + if (pStr === projectRoot) { + return '/private/tmp/project'; + } + if (pStr.includes('new-plans')) { + const err = new Error('ENOENT') as NodeJS.ErrnoException; + err.code = 'ENOENT'; + throw err; + } + return pStr; + }); + return () => vi.mocked(fs.realpathSync).mockRestore(); + }, + expected: path.resolve(projectRoot, 'new-plans'), + }, ]; testCases.forEach(({ name, customDir, expected, expectedError, setup }) => { diff --git a/packages/core/src/config/storage.ts b/packages/core/src/config/storage.ts index bd7ce303e2..63d3921c58 100644 --- a/packages/core/src/config/storage.ts +++ b/packages/core/src/config/storage.ts @@ -333,6 +333,8 @@ export class Storage { ) { throw e; } + // Construct the fallback path safely against the real project root + realResolvedPath = path.resolve(realProjectRoot, customDir); } if (!isSubpath(realProjectRoot, realResolvedPath)) {