From fe8aac6b830e2ae307217f4d36b6d2960ff90f24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Mon, 25 May 2026 18:15:42 +0800 Subject: [PATCH] fix: normalize Windows drive-root sessions (#601) Windows folder selection can surface drive roots as D:, which Node treats as drive-relative instead of the absolute D:\ root. Normalize drive-root inputs before resolving launch directories, filesystem access roots, transcript metadata, and workspace paths. Use path.relative containment checks so drive roots and child projects remain distinct in session grouping and file access. Constraint: Windows drive-root inputs may arrive as C:, D:, or any other single-letter drive prefix Rejected: Special-case only D: | every Windows drive letter has the same drive-relative semantics Confidence: high Scope-risk: moderate Directive: Keep Windows drive-root normalization centralized; do not reintroduce string-prefix containment checks for workspace roots Tested: bun test src/server/__tests__/windows-drive-path.test.ts src/server/__tests__/filesystem.test.ts src/server/__tests__/sessions.test.ts src/server/__tests__/workspace-service.test.ts Tested: cd desktop && bun run test -- --run src/components/layout/Sidebar.test.tsx Tested: bun run check:server Tested: cd desktop && bun run lint Not-tested: Real Windows desktop smoke on a physical Windows machine Related: #601 --- .../src/components/layout/Sidebar.test.tsx | 17 +++++++++++ .../__tests__/windows-drive-path.test.ts | 30 +++++++++++++++++++ src/server/api/filesystem.ts | 19 +++++------- src/server/services/filesystemAccessRoots.ts | 15 ++++------ .../services/repositoryLaunchService.ts | 5 ++-- src/server/services/sessionService.ts | 21 ++++++++----- src/server/services/windowsDrivePath.ts | 30 +++++++++++++++++++ src/server/services/workspaceService.ts | 15 ++++------ 8 files changed, 114 insertions(+), 38 deletions(-) create mode 100644 src/server/__tests__/windows-drive-path.test.ts create mode 100644 src/server/services/windowsDrivePath.ts diff --git a/desktop/src/components/layout/Sidebar.test.tsx b/desktop/src/components/layout/Sidebar.test.tsx index e6e41dbf..62960d1e 100644 --- a/desktop/src/components/layout/Sidebar.test.tsx +++ b/desktop/src/components/layout/Sidebar.test.tsx @@ -782,6 +782,23 @@ describe('Sidebar', () => { expect(screen.getAllByText('worktree')).toHaveLength(1) }) + it('keeps a Windows drive root session separate from sessions in child projects', () => { + const now = new Date().toISOString() + useSessionStore.setState({ + sessions: [ + makeSession('drive-root', 'Drive Root Session', 'D:\\', now), + makeSession('drive-project', 'Drive Project Session', 'D:\\SomeProject', now), + ], + }) + + render() + + expect(screen.getByText('D:')).toBeInTheDocument() + expect(screen.getByText('SomeProject')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /Drive Root Session/ })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /Drive Project Session/ })).toBeInTheDocument() + }) + it('right-aligns running status, worktree marker, and update time on session rows', () => { vi.useFakeTimers() vi.setSystemTime(new Date('2026-05-19T12:00:00.000Z')) diff --git a/src/server/__tests__/windows-drive-path.test.ts b/src/server/__tests__/windows-drive-path.test.ts new file mode 100644 index 00000000..b48a9c7c --- /dev/null +++ b/src/server/__tests__/windows-drive-path.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'bun:test' +import { + isSameOrInsidePathForPlatform, + normalizeDriveRootPathForPlatform, +} from '../services/windowsDrivePath.js' +import { SessionService } from '../services/sessionService.js' + +describe('Windows drive root path handling', () => { + it('normalizes drive-relative root inputs to absolute drive roots on Windows', () => { + expect(normalizeDriveRootPathForPlatform('D:', 'win32')).toBe('D:\\') + expect(normalizeDriveRootPathForPlatform('d:', 'win32')).toBe('d:\\') + expect(normalizeDriveRootPathForPlatform('D:\\', 'win32')).toBe('D:\\') + expect(normalizeDriveRootPathForPlatform('D:\\project', 'win32')).toBe('D:\\project') + expect(normalizeDriveRootPathForPlatform('D:', 'darwin')).toBe('D:') + }) + + it('recovers sanitized Windows drive-root transcript directories', () => { + const service = new SessionService() + expect(service.desanitizePath('D--')).toBe('D:\\') + expect(service.desanitizePath('D--project')).toBe('D:\\project') + }) + + it('treats absolute Windows drive-root children as inside the selected root', () => { + expect(isSameOrInsidePathForPlatform('D:\\', 'D:', 'win32')).toBe(true) + expect(isSameOrInsidePathForPlatform('D:\\child', 'D:', 'win32')).toBe(true) + expect(isSameOrInsidePathForPlatform('D:\\child', 'D:\\', 'win32')).toBe(true) + expect(isSameOrInsidePathForPlatform('D:\\project-extra', 'D:\\project', 'win32')).toBe(false) + expect(isSameOrInsidePathForPlatform('E:\\child', 'D:\\', 'win32')).toBe(false) + }) +}) diff --git a/src/server/api/filesystem.ts b/src/server/api/filesystem.ts index 61814227..ab8ef011 100644 --- a/src/server/api/filesystem.ts +++ b/src/server/api/filesystem.ts @@ -13,6 +13,10 @@ import { findGitRoot, gitExe } from '../../utils/git.js' import { ripGrep } from '../../utils/ripgrep.js' import { getInitialSettings } from '../../utils/settings/settings.js' import { isWithinRegisteredFilesystemRoot } from '../services/filesystemAccessRoots.js' +import { + isSameOrInsidePathForPlatform, + normalizeDriveRootPathForPlatform, +} from '../services/windowsDrivePath.js' type FilesystemEntry = { name: string @@ -41,14 +45,7 @@ const IMAGE_MIME_TYPES: Record = { } function isWithinRoot(targetPath: string, rootPath: string): boolean { - const target = normalizeComparablePath(targetPath) - const root = normalizeComparablePath(rootPath) - return target === root || target.startsWith(`${root}${path.sep}`) -} - -function normalizeComparablePath(filePath: string): string { - const resolved = path.resolve(filePath) - return process.platform === 'win32' ? resolved.toLowerCase() : resolved + return isSameOrInsidePathForPlatform(targetPath, rootPath) } function isVcsMetadataDirectoryName(name: string): boolean { @@ -56,7 +53,7 @@ function isVcsMetadataDirectoryName(name: string): boolean { } function isAllowedFilesystemPath(targetPath: string): boolean { - const resolvedPath = path.resolve(targetPath) + const resolvedPath = path.resolve(normalizeDriveRootPathForPlatform(targetPath)) const homeDir = path.resolve(os.homedir()) if (isWithinRoot(resolvedPath, homeDir) || isWithinRoot(resolvedPath, '/tmp')) { @@ -93,7 +90,7 @@ async function handleServeFile(url: URL): Promise { return json({ error: 'Missing path parameter' }, 400) } - const resolvedPath = path.resolve(filePath) + const resolvedPath = path.resolve(normalizeDriveRootPathForPlatform(filePath)) if (!isAllowedFilesystemPath(resolvedPath)) { return json({ error: 'Access denied: path outside allowed directory' }, 403) @@ -132,7 +129,7 @@ async function handleServeFile(url: URL): Promise { async function handleBrowse(url: URL): Promise { const targetPath = url.searchParams.get('path') || os.homedir() || '/' - const resolvedPath = path.resolve(targetPath) + const resolvedPath = path.resolve(normalizeDriveRootPathForPlatform(targetPath)) if (!isAllowedFilesystemPath(resolvedPath)) { return json({ error: 'Access denied: path outside allowed directory' }, 403) diff --git a/src/server/services/filesystemAccessRoots.ts b/src/server/services/filesystemAccessRoots.ts index a77ace6b..0b3aeb5a 100644 --- a/src/server/services/filesystemAccessRoots.ts +++ b/src/server/services/filesystemAccessRoots.ts @@ -1,21 +1,18 @@ import * as path from 'node:path' +import { + isSameOrInsidePathForPlatform, + normalizeDriveRootPathForPlatform, +} from './windowsDrivePath.js' const registeredRoots = new Set() -function normalizeComparablePath(filePath: string): string { - const resolved = path.resolve(filePath) - return process.platform === 'win32' ? resolved.toLowerCase() : resolved -} - function isWithinRoot(targetPath: string, rootPath: string): boolean { - const target = normalizeComparablePath(targetPath) - const root = normalizeComparablePath(rootPath) - return target === root || target.startsWith(`${root}${path.sep}`) + return isSameOrInsidePathForPlatform(targetPath, rootPath) } export function registerFilesystemAccessRoot(rootPath: string | null | undefined): void { if (!rootPath) return - registeredRoots.add(path.resolve(rootPath)) + registeredRoots.add(path.resolve(normalizeDriveRootPathForPlatform(rootPath))) } export function isWithinRegisteredFilesystemRoot(targetPath: string): boolean { diff --git a/src/server/services/repositoryLaunchService.ts b/src/server/services/repositoryLaunchService.ts index 03a546e8..b2487732 100644 --- a/src/server/services/repositoryLaunchService.ts +++ b/src/server/services/repositoryLaunchService.ts @@ -5,6 +5,7 @@ import { promisify } from 'node:util' import { ApiError } from '../middleware/errorHandler.js' import { findCanonicalGitRoot, findGitRoot } from '../../utils/git.js' import { registerFilesystemAccessRoot } from './filesystemAccessRoots.js' +import { normalizeDriveRootPathForPlatform } from './windowsDrivePath.js' import { ensureWorktreesDirExcluded, performPostCreationSetup, @@ -168,7 +169,7 @@ async function runGit( } async function resolveDirectory(workDir: string): Promise { - const resolved = path.resolve(workDir) + const resolved = path.resolve(normalizeDriveRootPathForPlatform(workDir)) let realPath: string try { realPath = await fs.realpath(resolved) @@ -332,7 +333,7 @@ export async function getRepositoryContext(workDir: string): Promise= 0; i--) { const entry = entries[i] if (entry.type === 'session-meta' && typeof (entry as Record).workDir === 'string') { - return (entry as Record).workDir as string + return normalizeDriveRootPathForPlatform((entry as Record).workDir as string) } } for (let i = entries.length - 1; i >= 0; i--) { const cwd = entries[i]?.cwd if (typeof cwd === 'string' && cwd.trim()) { - return cwd + return normalizeDriveRootPathForPlatform(cwd) } } @@ -915,7 +916,7 @@ export class SessionService { // Optionally filter to a specific project if (projectFilter) { - const sanitized = this.sanitizePath(projectFilter) + const sanitized = this.sanitizePath(normalizeDriveRootPathForPlatform(projectFilter)) projectDirs = projectDirs.filter((d) => d === sanitized) } @@ -966,6 +967,11 @@ export class SessionService { return `${windowsDrivePath[1]}:${path.win32.sep}${windowsDrivePath[2].replace(/-/g, path.win32.sep)}` } + const windowsDriveRoot = sanitized.match(/^([a-zA-Z])--$/) + if (windowsDriveRoot) { + return `${windowsDriveRoot[1]}:${path.win32.sep}` + } + // On POSIX the original path starts with '/', so the sanitized form starts with '-'. // UNC-style Windows paths also recover to a leading double separator on Windows. return sanitized.replace(/-/g, path.sep) @@ -1644,7 +1650,7 @@ export class SessionService { async clearSessionTranscript(sessionId: string, fallbackWorkDir?: string): Promise { let found = await this.findSessionFile(sessionId) if (!found && fallbackWorkDir) { - const resolvedPath = path.resolve(fallbackWorkDir) + const resolvedPath = path.resolve(normalizeDriveRootPathForPlatform(fallbackWorkDir)) const absWorkDir = await fs.realpath(resolvedPath).catch(() => resolvedPath) const dirPath = path.join(this.getProjectsDir(), this.sanitizePath(absWorkDir)) await fs.mkdir(dirPath, { recursive: true }) @@ -1710,14 +1716,15 @@ export class SessionService { } } - const targetProjectDir = this.sanitizePath(metadata.workDir) + const normalizedWorkDir = normalizeDriveRootPathForPlatform(metadata.workDir) + const targetProjectDir = this.sanitizePath(normalizedWorkDir) const targetFilePath = path.join(this.getProjectsDir(), targetProjectDir, `${sessionId}.jsonl`) await fs.mkdir(path.dirname(targetFilePath), { recursive: true }) await this.appendJsonlEntry(targetFilePath, { type: 'session-meta', isMeta: true, - workDir: metadata.workDir, + workDir: normalizedWorkDir, repository, timestamp: new Date().toISOString(), }) @@ -1746,7 +1753,7 @@ export class SessionService { throw err } - const keepProjectDir = this.sanitizePath(keepWorkDir) + const keepProjectDir = this.sanitizePath(normalizeDriveRootPathForPlatform(keepWorkDir)) let removed = 0 for (const projectDir of projectDirs) { if (!projectDir.isDirectory()) continue diff --git a/src/server/services/windowsDrivePath.ts b/src/server/services/windowsDrivePath.ts new file mode 100644 index 00000000..82574ce3 --- /dev/null +++ b/src/server/services/windowsDrivePath.ts @@ -0,0 +1,30 @@ +import * as path from 'node:path' + +export function normalizeDriveRootPathForPlatform( + filePath: string, + platform: NodeJS.Platform = process.platform, +): string { + if (platform !== 'win32') return filePath + + const driveRootMatch = filePath.match(/^([a-zA-Z]):$/) + if (!driveRootMatch) return filePath + + return `${driveRootMatch[1]}:\\` +} + +export function isSameOrInsidePathForPlatform( + targetPath: string, + rootPath: string, + platform: NodeJS.Platform = process.platform, +): boolean { + const pathApi = platform === 'win32' ? path.win32 : path + const normalize = (filePath: string) => { + const resolved = pathApi.resolve(normalizeDriveRootPathForPlatform(filePath, platform)) + return platform === 'win32' ? resolved.toLowerCase() : resolved + } + const target = normalize(targetPath) + const root = normalize(rootPath) + const relative = pathApi.relative(root, target) + + return relative === '' || (!!relative && !relative.startsWith('..') && !pathApi.isAbsolute(relative)) +} diff --git a/src/server/services/workspaceService.ts b/src/server/services/workspaceService.ts index 4df47b9b..b9ba02cb 100644 --- a/src/server/services/workspaceService.ts +++ b/src/server/services/workspaceService.ts @@ -6,6 +6,10 @@ import { diffLines } from 'diff' import type { MessageEntry } from './sessionService.js' import type { FileHistorySnapshot } from '../../utils/fileHistory.js' import { getClaudeConfigHomeDir } from '../../utils/envUtils.js' +import { + isSameOrInsidePathForPlatform, + normalizeDriveRootPathForPlatform, +} from './windowsDrivePath.js' const MAX_PREVIEW_BYTES = 1024 * 1024 const MAX_UNTRACKED_STAT_BYTES = 256 * 1024 @@ -1004,7 +1008,7 @@ export class WorkspaceService { if (!workDir) { throw new Error(`Session not found: ${sessionId}`) } - return path.resolve(workDir) + return path.resolve(normalizeDriveRootPathForPlatform(workDir)) } private async getWorkspaceRoot( @@ -1149,14 +1153,7 @@ export class WorkspaceService { } private isWithinRoot(targetPath: string, rootPath: string): boolean { - const target = this.normalizeComparableAbsolutePath(targetPath) - const root = this.normalizeComparableAbsolutePath(rootPath) - return target === root || target.startsWith(`${root}${path.sep}`) - } - - private normalizeComparableAbsolutePath(filePath: string): string { - const resolved = path.resolve(filePath) - return process.platform === 'win32' ? resolved.toLowerCase() : resolved + return isSameOrInsidePathForPlatform(targetPath, rootPath) } private normalizeRelativePath(filePath: string): string {