Compare commits

...

16 Commits

Author SHA1 Message Date
A.K.M. Adib ca0f0a85f5 fix: resolve symlinks for non-existent paths during validation
The path validation logic in Config.isPathAllowed failed when attempting
to write a new file to a directory that is a symbolic link. This happened
because fs.realpathSync fails on non-existent paths, falling back to an
unresolved path which then mismatches with the resolved project temporary
directory during the isSubpath check.

This commit updates resolveToRealPath to robustly resolve parent
directories even if the leaf file does not exist, and updates
isPathAllowed to use this improved helper.
2026-03-06 18:30:32 -05:00
A.K.M. Adib 53a3fc9776 address feedback from/review frontend 2026-03-06 14:58:37 -05:00
A.K.M. Adib 0a6fb84bec prevent radio button area from getting truncated 2026-03-06 14:40:46 -05:00
A.K.M. Adib 55cc847e8f use question.unconstrainedHeight again 2026-03-06 12:55:04 -05:00
A.K.M. Adib 8cfc314fc0 revert file change 2026-03-06 10:28:28 -05:00
A.K.M. Adib d879da626d test: update snapshots for AskUserDialog and ExitPlanModeDialog 2026-03-05 23:18:51 -05:00
A.K.M. Adib 7490f285da update snapshots 2026-03-05 23:10:50 -05:00
A.K.M. Adib f5c02538b2 plz work 2026-03-05 23:08:41 -05:00
A.K.M. Adib 05b50f45a3 update snapshots 2026-03-05 23:08:40 -05:00
A.K.M. Adib 633ef48c91 update snapshot 2026-03-05 23:07:43 -05:00
A.K.M. Adib c397c7259a fix 2026-03-05 23:07:38 -05:00
A.K.M. Adib e5d97195d9 update snapshot 2026-03-05 23:07:38 -05:00
A.K.M. Adib ec2d0a89d2 update to height based on all available height module fix overflow 2026-03-05 23:03:07 -05:00
A.K.M. Adib 0f88ddb73e address bot comments 2026-03-05 23:02:53 -05:00
A.K.M. Adib 36b06f7333 address bot comment 2026-03-05 23:02:53 -05:00
A.K.M. Adib 128dc99927 fix(cli): prevent plan truncation in approval dialog by supporting unconstrained heights
Fixes #20716
2026-03-05 23:02:53 -05:00
8 changed files with 58 additions and 32 deletions
@@ -786,16 +786,23 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
const TITLE_MARGIN = 1;
const FOOTER_HEIGHT = 2; // DialogFooter + margin
const overhead = HEADER_HEIGHT + TITLE_MARGIN + FOOTER_HEIGHT;
const listHeight = availableHeight
? Math.max(1, availableHeight - overhead)
: undefined;
const questionHeight =
const minListHeight = 6; // Reserve ~6 lines for options list to avoid squishing
const questionHeightLimit =
listHeight && !isAlternateBuffer
? Math.min(15, Math.max(1, listHeight - DIALOG_PADDING))
? question.unconstrainedHeight
? Math.max(1, listHeight - minListHeight)
: Math.min(15, Math.max(1, listHeight - DIALOG_PADDING))
: undefined;
const maxItemsToShow =
listHeight && questionHeight
? Math.max(1, Math.floor((listHeight - questionHeight) / 2))
listHeight && questionHeightLimit
? Math.max(1, Math.floor((listHeight - questionHeightLimit) / 2))
: selectionItems.length;
return (
@@ -803,7 +810,7 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
{progressHeader}
<Box marginBottom={TITLE_MARGIN}>
<MaxSizedBox
maxHeight={questionHeight}
maxHeight={questionHeightLimit}
maxWidth={availableWidth}
overflowDirection="bottom"
>
@@ -247,6 +247,7 @@ export const ExitPlanModeDialog: React.FC<ExitPlanModeDialogProps> = ({
],
placeholder: 'Type your feedback...',
multiSelect: false,
unconstrainedHeight: true,
},
]}
onSubmit={(answers) => {
@@ -282,7 +282,10 @@ describe('ToolConfirmationQueue', () => {
// hideToolIdentity is true for ask_user -> subtracts 4 instead of 6
// availableContentHeight = 19 - 4 = 15
// ToolConfirmationMessage handlesOwnUI=true -> returns full 15
// AskUserDialog uses 15 lines to render its multi-line question and options.
// AskUserDialog allocates questionHeight = availableHeight - overhead - minListHeight.
// listHeight = 15 - overhead (Header:0, Margin:1, Footer:2) = 12.
// maxQuestionHeight = listHeight - 4 = 8.
// 8 lines is enough for the 6-line question.
await waitFor(() => {
expect(lastFrame()).toContain('Line 6');
expect(lastFrame()).not.toContain('lines hidden');
+3 -14
View File
@@ -6,7 +6,6 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { inspect } from 'node:util';
import process from 'node:process';
import {
@@ -146,7 +145,7 @@ import { SkillManager, type SkillDefinition } from '../skills/skillManager.js';
import { startupProfiler } from '../telemetry/startupProfiler.js';
import type { AgentDefinition } from '../agents/types.js';
import { fetchAdminControls } from '../code_assist/admin/admin_controls.js';
import { isSubpath } from '../utils/paths.js';
import { isSubpath, resolveToRealPath } from '../utils/paths.js';
import { UserHintService } from './userHintService.js';
import { WORKSPACE_POLICY_TIER } from '../policy/config.js';
import { loadPoliciesFromToml } from '../policy/toml-loader.js';
@@ -2374,17 +2373,7 @@ export class Config implements McpContext {
* @returns true if the path is allowed, false otherwise.
*/
isPathAllowed(absolutePath: string): boolean {
const realpath = (p: string) => {
let resolved: string;
try {
resolved = fs.realpathSync(p);
} catch {
resolved = path.resolve(p);
}
return os.platform() === 'win32' ? resolved.toLowerCase() : resolved;
};
const resolvedPath = realpath(absolutePath);
const resolvedPath = resolveToRealPath(absolutePath);
const workspaceContext = this.getWorkspaceContext();
if (workspaceContext.isPathWithinWorkspace(resolvedPath)) {
@@ -2392,7 +2381,7 @@ export class Config implements McpContext {
}
const projectTempDir = this.storage.getProjectTempDir();
const resolvedTempDir = realpath(projectTempDir);
const resolvedTempDir = resolveToRealPath(projectTempDir);
return isSubpath(resolvedTempDir, resolvedPath);
}
+3 -5
View File
@@ -24,7 +24,7 @@ vi.mock('fs', async (importOriginal) => {
});
import { Storage } from './storage.js';
import { GEMINI_DIR, homedir } from '../utils/paths.js';
import { GEMINI_DIR, homedir, resolveToRealPath } from '../utils/paths.js';
import { ProjectRegistry } from './projectRegistry.js';
import { StorageMigration } from './storageMigration.js';
@@ -279,8 +279,7 @@ describe('Storage additional helpers', () => {
name: 'custom absolute path outside throws',
customDir: '/absolute/path/to/plans',
expected: '',
expectedError:
"Custom plans directory '/absolute/path/to/plans' resolves to '/absolute/path/to/plans', which is outside the project root '/tmp/project'.",
expectedError: `Custom plans directory '/absolute/path/to/plans' resolves to '/absolute/path/to/plans', which is outside the project root '${resolveToRealPath(projectRoot)}'.`,
},
{
name: 'absolute path that happens to be inside project root',
@@ -306,8 +305,7 @@ describe('Storage additional helpers', () => {
name: 'escaping relative path throws',
customDir: '../escaped-plans',
expected: '',
expectedError:
"Custom plans directory '../escaped-plans' resolves to '/tmp/escaped-plans', which is outside the project root '/tmp/project'.",
expectedError: `Custom plans directory '../escaped-plans' resolves to '${resolveToRealPath(path.resolve(projectRoot, '../escaped-plans'))}', which is outside the project root '${resolveToRealPath(projectRoot)}'.`,
},
{
name: 'hidden directory starting with ..',
@@ -162,6 +162,8 @@ export interface Question {
multiSelect?: boolean;
/** Placeholder hint text. For type='text', shown in the input field. For type='choice', shown in the "Other" custom input. */
placeholder?: string;
/** Allow the question to consume more vertical space instead of being strictly capped. */
unconstrainedHeight?: boolean;
}
export interface AskUserRequest {
+19
View File
@@ -521,6 +521,25 @@ describe('resolveToRealPath', () => {
expect(resolveToRealPath(input)).toBe(expected);
});
it('should recursively resolve symlinks for non-existent child paths', () => {
const parentPath = path.resolve('/some/parent/path');
const resolvedParentPath = path.resolve('/resolved/parent/path');
const childPath = path.resolve(parentPath, 'child', 'file.txt');
const expectedPath = path.resolve(resolvedParentPath, 'child', 'file.txt');
vi.spyOn(fs, 'realpathSync').mockImplementation((p) => {
const pStr = p.toString();
if (pStr === parentPath) {
return resolvedParentPath;
}
const err = new Error('ENOENT') as NodeJS.ErrnoException;
err.code = 'ENOENT';
throw err;
});
expect(resolveToRealPath(childPath)).toBe(expectedPath);
});
});
describe('normalizePath', () => {
+14 -7
View File
@@ -359,8 +359,8 @@ export function isSubpath(parentPath: string, childPath: string): boolean {
* @param pathStr The path string to resolve.
* @returns The resolved real path.
*/
export function resolveToRealPath(path: string): string {
let resolvedPath = path;
export function resolveToRealPath(pathStr: string): string {
let resolvedPath = pathStr;
try {
if (resolvedPath.startsWith('file://')) {
@@ -372,11 +372,18 @@ export function resolveToRealPath(path: string): string {
// Ignore error (e.g. malformed URI), keep path from previous step
}
return robustRealpath(path.resolve(resolvedPath));
}
function robustRealpath(p: string): string {
try {
return fs.realpathSync(resolvedPath);
} catch (_e) {
// If realpathSync fails, it might be because the path doesn't exist.
// In that case, we can fall back to the path processed.
return resolvedPath;
return fs.realpathSync(p);
} catch (e: unknown) {
if (e && typeof e === 'object' && 'code' in e && e.code === 'ENOENT') {
const parent = path.dirname(p);
if (parent === p) return p;
return path.join(robustRealpath(parent), path.basename(p));
}
return p;
}
}