Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions packages/core/src/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
3 changes: 1 addition & 2 deletions packages/core/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down
19 changes: 19 additions & 0 deletions packages/core/src/config/projectRegistry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
7 changes: 1 addition & 6 deletions packages/core/src/config/projectRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<void> {
Expand Down
42 changes: 42 additions & 0 deletions packages/core/src/utils/workspaceContext.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
41 changes: 35 additions & 6 deletions packages/core/src/utils/workspaceContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -22,6 +23,7 @@ export interface AddDirectoriesResult {
* in a single session.
*/
export class WorkspaceContext {
readonly targetDir: string;
private directories = new Set<string>();
private initialDirectories: Set<string>;
private readOnlyPaths = new Set<string>();
Expand All @@ -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);
Expand Down Expand Up @@ -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) &&
Expand All @@ -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 !== '..' &&
Expand Down