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) 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', () => { it('right-aligns running status, worktree marker, and update time on session rows', () => {
vi.useFakeTimers() vi.useFakeTimers()
vi.setSystemTime(new Date('2026-05-19T12:00:00.000Z')) 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 { ripGrep } from '../../utils/ripgrep.js'
import { getInitialSettings } from '../../utils/settings/settings.js' import { getInitialSettings } from '../../utils/settings/settings.js'
import { isWithinRegisteredFilesystemRoot } from '../services/filesystemAccessRoots.js' import { isWithinRegisteredFilesystemRoot } from '../services/filesystemAccessRoots.js'
import {
isSameOrInsidePathForPlatform,
normalizeDriveRootPathForPlatform,
} from '../services/windowsDrivePath.js'
type FilesystemEntry = { type FilesystemEntry = {
name: string name: string
@ -41,14 +45,7 @@ const IMAGE_MIME_TYPES: Record<string, string> = {
} }
function isWithinRoot(targetPath: string, rootPath: string): boolean { function isWithinRoot(targetPath: string, rootPath: string): boolean {
const target = normalizeComparablePath(targetPath) return isSameOrInsidePathForPlatform(targetPath, rootPath)
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
} }
function isVcsMetadataDirectoryName(name: string): boolean { function isVcsMetadataDirectoryName(name: string): boolean {
@ -56,7 +53,7 @@ function isVcsMetadataDirectoryName(name: string): boolean {
} }
function isAllowedFilesystemPath(targetPath: string): boolean { function isAllowedFilesystemPath(targetPath: string): boolean {
const resolvedPath = path.resolve(targetPath) const resolvedPath = path.resolve(normalizeDriveRootPathForPlatform(targetPath))
const homeDir = path.resolve(os.homedir()) const homeDir = path.resolve(os.homedir())
if (isWithinRoot(resolvedPath, homeDir) || isWithinRoot(resolvedPath, '/tmp')) { 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) return json({ error: 'Missing path parameter' }, 400)
} }
const resolvedPath = path.resolve(filePath) const resolvedPath = path.resolve(normalizeDriveRootPathForPlatform(filePath))
if (!isAllowedFilesystemPath(resolvedPath)) { if (!isAllowedFilesystemPath(resolvedPath)) {
return json({ error: 'Access denied: path outside allowed directory' }, 403) 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> { async function handleBrowse(url: URL): Promise<Response> {
const targetPath = url.searchParams.get('path') || os.homedir() || '/' const targetPath = url.searchParams.get('path') || os.homedir() || '/'
const resolvedPath = path.resolve(targetPath) const resolvedPath = path.resolve(normalizeDriveRootPathForPlatform(targetPath))
if (!isAllowedFilesystemPath(resolvedPath)) { if (!isAllowedFilesystemPath(resolvedPath)) {
return json({ error: 'Access denied: path outside allowed directory' }, 403) return json({ error: 'Access denied: path outside allowed directory' }, 403)

View File

@ -1,21 +1,18 @@
import * as path from 'node:path' import * as path from 'node:path'
import {
isSameOrInsidePathForPlatform,
normalizeDriveRootPathForPlatform,
} from './windowsDrivePath.js'
const registeredRoots = new Set<string>() 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 { function isWithinRoot(targetPath: string, rootPath: string): boolean {
const target = normalizeComparablePath(targetPath) return isSameOrInsidePathForPlatform(targetPath, rootPath)
const root = normalizeComparablePath(rootPath)
return target === root || target.startsWith(`${root}${path.sep}`)
} }
export function registerFilesystemAccessRoot(rootPath: string | null | undefined): void { export function registerFilesystemAccessRoot(rootPath: string | null | undefined): void {
if (!rootPath) return if (!rootPath) return
registeredRoots.add(path.resolve(rootPath)) registeredRoots.add(path.resolve(normalizeDriveRootPathForPlatform(rootPath)))
} }
export function isWithinRegisteredFilesystemRoot(targetPath: string): boolean { export function isWithinRegisteredFilesystemRoot(targetPath: string): boolean {

View File

@ -5,6 +5,7 @@ import { promisify } from 'node:util'
import { ApiError } from '../middleware/errorHandler.js' import { ApiError } from '../middleware/errorHandler.js'
import { findCanonicalGitRoot, findGitRoot } from '../../utils/git.js' import { findCanonicalGitRoot, findGitRoot } from '../../utils/git.js'
import { registerFilesystemAccessRoot } from './filesystemAccessRoots.js' import { registerFilesystemAccessRoot } from './filesystemAccessRoots.js'
import { normalizeDriveRootPathForPlatform } from './windowsDrivePath.js'
import { import {
ensureWorktreesDirExcluded, ensureWorktreesDirExcluded,
performPostCreationSetup, performPostCreationSetup,
@ -168,7 +169,7 @@ async function runGit(
} }
async function resolveDirectory(workDir: string): Promise<string> { async function resolveDirectory(workDir: string): Promise<string> {
const resolved = path.resolve(workDir) const resolved = path.resolve(normalizeDriveRootPathForPlatform(workDir))
let realPath: string let realPath: string
try { try {
realPath = await fs.realpath(resolved) realPath = await fs.realpath(resolved)
@ -332,7 +333,7 @@ export async function getRepositoryContext(workDir: string): Promise<RepositoryC
} catch (error) { } catch (error) {
return { return {
state: 'missing_workdir', state: 'missing_workdir',
workDir: path.resolve(workDir), workDir: path.resolve(normalizeDriveRootPathForPlatform(workDir)),
repoRoot: null, repoRoot: null,
repoName: null, repoName: null,
currentBranch: null, currentBranch: null,

View File

@ -26,6 +26,7 @@ import {
type PreparedSessionWorkspace, type PreparedSessionWorkspace,
} from './repositoryLaunchService.js' } from './repositoryLaunchService.js'
import { registerFilesystemAccessRoot } from './filesystemAccessRoots.js' import { registerFilesystemAccessRoot } from './filesystemAccessRoots.js'
import { normalizeDriveRootPathForPlatform } from './windowsDrivePath.js'
import { cleanSessionTitleSource } from '../../utils/sessionTitleText.js' import { cleanSessionTitleSource } from '../../utils/sessionTitleText.js'
// ============================================================================ // ============================================================================
@ -294,14 +295,14 @@ export class SessionService {
for (let i = entries.length - 1; i >= 0; i--) { for (let i = entries.length - 1; i >= 0; i--) {
const entry = entries[i] const entry = entries[i]
if (entry.type === 'session-meta' && typeof (entry as Record<string, unknown>).workDir === 'string') { 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--) { for (let i = entries.length - 1; i >= 0; i--) {
const cwd = entries[i]?.cwd const cwd = entries[i]?.cwd
if (typeof cwd === 'string' && cwd.trim()) { if (typeof cwd === 'string' && cwd.trim()) {
return cwd return normalizeDriveRootPathForPlatform(cwd)
} }
} }
@ -915,7 +916,7 @@ export class SessionService {
// Optionally filter to a specific project // Optionally filter to a specific project
if (projectFilter) { if (projectFilter) {
const sanitized = this.sanitizePath(projectFilter) const sanitized = this.sanitizePath(normalizeDriveRootPathForPlatform(projectFilter))
projectDirs = projectDirs.filter((d) => d === sanitized) 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)}` 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 '-'. // 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. // UNC-style Windows paths also recover to a leading double separator on Windows.
return sanitized.replace(/-/g, path.sep) return sanitized.replace(/-/g, path.sep)
@ -1644,7 +1650,7 @@ export class SessionService {
async clearSessionTranscript(sessionId: string, fallbackWorkDir?: string): Promise<void> { async clearSessionTranscript(sessionId: string, fallbackWorkDir?: string): Promise<void> {
let found = await this.findSessionFile(sessionId) let found = await this.findSessionFile(sessionId)
if (!found && fallbackWorkDir) { if (!found && fallbackWorkDir) {
const resolvedPath = path.resolve(fallbackWorkDir) const resolvedPath = path.resolve(normalizeDriveRootPathForPlatform(fallbackWorkDir))
const absWorkDir = await fs.realpath(resolvedPath).catch(() => resolvedPath) const absWorkDir = await fs.realpath(resolvedPath).catch(() => resolvedPath)
const dirPath = path.join(this.getProjectsDir(), this.sanitizePath(absWorkDir)) const dirPath = path.join(this.getProjectsDir(), this.sanitizePath(absWorkDir))
await fs.mkdir(dirPath, { recursive: true }) 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`) const targetFilePath = path.join(this.getProjectsDir(), targetProjectDir, `${sessionId}.jsonl`)
await fs.mkdir(path.dirname(targetFilePath), { recursive: true }) await fs.mkdir(path.dirname(targetFilePath), { recursive: true })
await this.appendJsonlEntry(targetFilePath, { await this.appendJsonlEntry(targetFilePath, {
type: 'session-meta', type: 'session-meta',
isMeta: true, isMeta: true,
workDir: metadata.workDir, workDir: normalizedWorkDir,
repository, repository,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}) })
@ -1746,7 +1753,7 @@ export class SessionService {
throw err throw err
} }
const keepProjectDir = this.sanitizePath(keepWorkDir) const keepProjectDir = this.sanitizePath(normalizeDriveRootPathForPlatform(keepWorkDir))
let removed = 0 let removed = 0
for (const projectDir of projectDirs) { for (const projectDir of projectDirs) {
if (!projectDir.isDirectory()) continue 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 { MessageEntry } from './sessionService.js'
import type { FileHistorySnapshot } from '../../utils/fileHistory.js' import type { FileHistorySnapshot } from '../../utils/fileHistory.js'
import { getClaudeConfigHomeDir } from '../../utils/envUtils.js' import { getClaudeConfigHomeDir } from '../../utils/envUtils.js'
import {
isSameOrInsidePathForPlatform,
normalizeDriveRootPathForPlatform,
} from './windowsDrivePath.js'
const MAX_PREVIEW_BYTES = 1024 * 1024 const MAX_PREVIEW_BYTES = 1024 * 1024
const MAX_UNTRACKED_STAT_BYTES = 256 * 1024 const MAX_UNTRACKED_STAT_BYTES = 256 * 1024
@ -1004,7 +1008,7 @@ export class WorkspaceService {
if (!workDir) { if (!workDir) {
throw new Error(`Session not found: ${sessionId}`) throw new Error(`Session not found: ${sessionId}`)
} }
return path.resolve(workDir) return path.resolve(normalizeDriveRootPathForPlatform(workDir))
} }
private async getWorkspaceRoot( private async getWorkspaceRoot(
@ -1149,14 +1153,7 @@ export class WorkspaceService {
} }
private isWithinRoot(targetPath: string, rootPath: string): boolean { private isWithinRoot(targetPath: string, rootPath: string): boolean {
const target = this.normalizeComparableAbsolutePath(targetPath) return isSameOrInsidePathForPlatform(targetPath, rootPath)
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
} }
private normalizeRelativePath(filePath: string): string { private normalizeRelativePath(filePath: string): string {