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
This commit is contained in:
程序员阿江(Relakkes) 2026-05-25 18:15:42 +08:00
parent 1b2bded1a2
commit fe8aac6b83
8 changed files with 114 additions and 38 deletions

View File

@ -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(<Sidebar />)
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'))

View File

@ -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)
})
})

View File

@ -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<string, string> = {
}
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<Response> {
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<Response> {
async function handleBrowse(url: URL): Promise<Response> {
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)

View File

@ -1,21 +1,18 @@
import * as path from 'node:path'
import {
isSameOrInsidePathForPlatform,
normalizeDriveRootPathForPlatform,
} from './windowsDrivePath.js'
const registeredRoots = new Set<string>()
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 {

View File

@ -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<string> {
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<RepositoryC
} catch (error) {
return {
state: 'missing_workdir',
workDir: path.resolve(workDir),
workDir: path.resolve(normalizeDriveRootPathForPlatform(workDir)),
repoRoot: null,
repoName: null,
currentBranch: null,

View File

@ -26,6 +26,7 @@ import {
type PreparedSessionWorkspace,
} from './repositoryLaunchService.js'
import { registerFilesystemAccessRoot } from './filesystemAccessRoots.js'
import { normalizeDriveRootPathForPlatform } from './windowsDrivePath.js'
import { cleanSessionTitleSource } from '../../utils/sessionTitleText.js'
// ============================================================================
@ -294,14 +295,14 @@ export class SessionService {
for (let i = entries.length - 1; i >= 0; i--) {
const entry = entries[i]
if (entry.type === 'session-meta' && typeof (entry as Record<string, unknown>).workDir === 'string') {
return (entry as Record<string, unknown>).workDir as string
return normalizeDriveRootPathForPlatform((entry as Record<string, unknown>).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<void> {
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

View File

@ -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))
}

View File

@ -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 {