diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index da30b133775..d01b3448070 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -3116,3 +3116,23 @@ describe('Model Persistence Bug Fix (#19864)', () => { expect(config.getModel()).toBe(PREVIEW_GEMINI_3_1_MODEL); }); }); + +describe('isPathAllowed', () => { + it('should preserve casing when validating paths', () => { + const params: ConfigParameters = { + cwd: '/tmp', + targetDir: '/tmp/target', + model: DEFAULT_GEMINI_MODEL, + sessionId: 'test-session', + debugMode: false, + }; + const config = new Config(params); + const workspaceContext = config.getWorkspaceContext(); + const spy = vi.spyOn(workspaceContext, 'isPathWithinWorkspace'); + + const mixedCasePath = path.join('/tmp/target', 'SubDir', 'File.txt'); + config.isPathAllowed(mixedCasePath); + expect(spy).toHaveBeenCalledWith(expect.stringMatching(/File\.txt$/)); + expect(spy).toHaveBeenCalledWith(mixedCasePath); + }); +}); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index e4c0fef6ebd..7cb5e1411be 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -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 { @@ -2381,7 +2380,7 @@ export class Config implements McpContext { } catch { resolved = path.resolve(p); } - return os.platform() === 'win32' ? resolved.toLowerCase() : resolved; + return resolved; }; const resolvedPath = realpath(absolutePath); diff --git a/packages/core/src/config/projectRegistry.test.ts b/packages/core/src/config/projectRegistry.test.ts index a441de8b3e2..846207a862b 100644 --- a/packages/core/src/config/projectRegistry.test.ts +++ b/packages/core/src/config/projectRegistry.test.ts @@ -107,6 +107,25 @@ describe('ProjectRegistry', () => { expect(id1).toBe(id2); }); + it('preserves path casing on Windows', async () => { + // Regression test: Mixed-case paths MUST be preserved in the registry (not lowercased). + const registry = new ProjectRegistry(registryPath); + await registry.initialize(); + + const mixedCasePath = path.join(tempDir, 'MyProject'); + const id = await registry.getShortId(mixedCasePath); + + // IDs are slugified (lowercase), but path keys in registry must remain case-sensitive. + expect(id).toBe('myproject'); + + // Verify raw registry file data preserves path casing + const data = JSON.parse(fs.readFileSync(registryPath, 'utf8')); + const expectedKey = path.resolve(mixedCasePath); + + expect(Object.keys(data.projects)).toContain(expectedKey); + expect(data.projects[expectedKey]).toBe('myproject'); + }); + it('creates ownership markers in base directories', async () => { const registry = new ProjectRegistry(registryPath, [baseDir1, baseDir2]); await registry.initialize(); diff --git a/packages/core/src/config/projectRegistry.ts b/packages/core/src/config/projectRegistry.ts index 725ea081f94..14b78cd97b3 100644 --- a/packages/core/src/config/projectRegistry.ts +++ b/packages/core/src/config/projectRegistry.ts @@ -6,7 +6,6 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; -import * as os from 'node:os'; import { lock } from 'proper-lockfile'; import { debugLogger } from '../utils/debugLogger.js'; @@ -69,11 +68,7 @@ export class ProjectRegistry { } private normalizePath(projectPath: string): string { - let resolved = path.resolve(projectPath); - if (os.platform() === 'win32') { - resolved = resolved.toLowerCase(); - } - return resolved; + return path.resolve(projectPath); } private async save(data: RegistryData): Promise { diff --git a/packages/core/src/utils/workspaceContext.test.ts b/packages/core/src/utils/workspaceContext.test.ts index 8c29819a79e..c8c7a36cb43 100644 --- a/packages/core/src/utils/workspaceContext.test.ts +++ b/packages/core/src/utils/workspaceContext.test.ts @@ -139,6 +139,48 @@ describe('WorkspaceContext with real filesystem', () => { expect(workspaceContext.isPathWithinWorkspace(invalidPath)).toBe(false); }); + it('should match paths case-insensitively on Windows', () => { + // Force win32 platform for this test + const originalPlatform = os.platform(); + Object.defineProperty(process, 'platform', { value: 'win32' }); + + try { + // Setup: cwd is lowercase-ish, checkPath is MixedCase + // In the real fix, we normalize BOTH to lowercase for comparison. + // Let's assume cwd is '/tmp/project' + const lowerCwd = cwd.toLowerCase(); + // Ensure the directory exists for the test to pass validation + if (!fs.existsSync(lowerCwd)) { + fs.mkdirSync(lowerCwd, { recursive: true }); + } + const workspaceContext = new WorkspaceContext(lowerCwd); + + // A path that matches case-insensitively + const mixedCasePath = path.join(lowerCwd, 'SubDir', 'File.txt'); + expect(workspaceContext.isPathWithinWorkspace(mixedCasePath)).toBe( + true, + ); + + // Case 1: Root is lower, Check is Upper + const upperPath = path.join(lowerCwd.toUpperCase(), 'FILE.TXT'); + expect(workspaceContext.isPathWithinWorkspace(upperPath)).toBe(true); + + // Case 2: Root is Upper, Check is Lower + // Use a mixed case directory to simulate case-insensitivity without hitting root-level EACCES + const mixedRoot = path.join(cwd, 'MixedCaseRoot'); + if (!fs.existsSync(mixedRoot)) { + fs.mkdirSync(mixedRoot); + } + const workspaceContextMixed = new WorkspaceContext(mixedRoot); + const lowerPathValues = path.join(mixedRoot.toLowerCase(), 'file.txt'); + expect( + workspaceContextMixed.isPathWithinWorkspace(lowerPathValues), + ).toBe(true); + } finally { + Object.defineProperty(process, 'platform', { value: originalPlatform }); + } + }); + it('should handle nested directories correctly', () => { const workspaceContext = new WorkspaceContext(cwd, [otherDir]); const nestedPath = path.join(cwd, 'deeply', 'nested', 'path', 'file.txt'); diff --git a/packages/core/src/utils/workspaceContext.ts b/packages/core/src/utils/workspaceContext.ts index dfb47ce3bec..94ccf2d8628 100755 --- a/packages/core/src/utils/workspaceContext.ts +++ b/packages/core/src/utils/workspaceContext.ts @@ -6,6 +6,7 @@ import { isNodeError } from '../utils/errors.js'; import * as fs from 'node:fs'; +import * as os from 'node:os'; import * as path from 'node:path'; import { debugLogger } from './debugLogger.js'; @@ -22,6 +23,7 @@ export interface AddDirectoriesResult { * in a single session. */ export class WorkspaceContext { + readonly targetDir: string; private directories = new Set(); private initialDirectories: Set; private readOnlyPaths = new Set(); @@ -32,10 +34,13 @@ export class WorkspaceContext { * @param targetDir The initial working directory (usually cwd) * @param additionalDirectories Optional array of additional directories to include */ - constructor( - readonly targetDir: string, - additionalDirectories: string[] = [], - ) { + constructor(targetDir: string, additionalDirectories: string[] = []) { + // Ensure targetDir is in original case from filesystem + try { + this.targetDir = fs.realpathSync(targetDir); + } catch { + this.targetDir = targetDir; + } this.addDirectory(targetDir); this.addDirectories(additionalDirectories); this.initialDirectories = new Set(this.directories); @@ -228,7 +233,26 @@ export class WorkspaceContext { */ private fullyResolvedPath(pathToCheck: string): string { try { - return fs.realpathSync(path.resolve(this.targetDir, pathToCheck)); + let resolvedInput = path.resolve(this.targetDir, pathToCheck); + + // On Windows, if pathToCheck is already absolute with incorrect casing, fix it + if (os.platform() === 'win32' && path.isAbsolute(pathToCheck)) { + try { + resolvedInput = fs.realpathSync(pathToCheck); + } catch { + // Normalize the case by matching against targetDir + const normalizedPathToCheck = pathToCheck.toLowerCase(); + const normalizedTargetDir = this.targetDir.toLowerCase(); + + if (normalizedPathToCheck.startsWith(normalizedTargetDir)) { + resolvedInput = + this.targetDir + + pathToCheck.substring(normalizedTargetDir.length); + } + } + } + + return fs.realpathSync(resolvedInput); } catch (e: unknown) { if ( isNodeError(e) && @@ -255,7 +279,12 @@ export class WorkspaceContext { pathToCheck: string, rootDirectory: string, ): boolean { - const relative = path.relative(rootDirectory, pathToCheck); + const normalizedRoot = + os.platform() === 'win32' ? rootDirectory.toLowerCase() : rootDirectory; + const normalizedPath = + os.platform() === 'win32' ? pathToCheck.toLowerCase() : pathToCheck; + + const relative = path.relative(normalizedRoot, normalizedPath); return ( !relative.startsWith(`..${path.sep}`) && relative !== '..' &&