fix(ui,server): normalize Windows drive letter casing for consistent path resolution (#2154)

* fix(ui,server): normalize Windows drive letter casing for consistent path resolution

Provider settings were failing to persist for specific projects on Windows
because path normalization was inconsistent across the codebase. Some
normalizePath functions uppercased the Windows drive letter (c:\ -> C:\)
and others did not, causing:
  - directoryScoped cache misses (different keys for the same directory)
  - broken model selection in the affected project
  - lost conversation history (sessions could not match their project)
  - false cache hits in resolveConfigDirectory on undefined inputs

This change extracts a single shared normalizePath utility and uses it
from the 5 client sites that were missing the drive letter normalization.
The server-side normalizePathForPersistence is updated to uppercase the
drive letter both before and after safeRealpathSync, so the persisted
path is consistent even when realpath returns a symlink/junction with
a lowercase drive letter on some Windows environments.

Fixes #2109

* test(ui,server): add coverage for Windows path normalization

Address review feedback on #2154:
- Add Windows-platform test for normalizePathForPersistence covering
  drive letter uppercase on input and after realpath resolution
- Add dedicated test suite for the shared normalizePath utility
- Defensive fix: normalizePath now returns null for paths that consist
  only of slashes (\\, ///), matching the documented contract

Refs #2154

* fix(server): scope drive casing normalization to Windows

---------

Co-authored-by: bashrusakh <bashrusakh@users.noreply.github.com>
Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
Leonid
2026-07-13 01:43:35 +03:00
committed by GitHub
co-authored by bashrusakh Bohdan Triapitsyn
parent 9dd389fe8e
commit 9624d4b6f6
9 changed files with 179 additions and 46 deletions
@@ -3,6 +3,9 @@ import type { Session } from '@opencode-ai/sdk/v2';
import { getCurrentIntlLocale } from '@/lib/i18n';
import { formatMessage, useI18nStore } from '@/lib/i18n/store';
import { normalizePath } from '@/lib/pathNormalization';
export { normalizePath };
const t = (key: Parameters<typeof formatMessage>[1], params?: Parameters<typeof formatMessage>[2]) =>
formatMessage(useI18nStore.getState().dictionary, key, params);
@@ -77,14 +80,6 @@ export const formatSessionCompactDateLabel = (updatedMs: number): string => {
return t('common.relative.yearsAgoCompact', { count: Math.floor(diff / year) });
};
export const normalizePath = (value?: string | null) => {
if (!value) {
return null;
}
const normalized = value.replace(/\\/g, '/').replace(/\/+$/, '');
return normalized.length === 0 ? '/' : normalized;
};
export const isPathWithinProject = (directory?: string | null, projectPath?: string | null): boolean => {
const normalizedDirectory = normalizePath(directory);
const normalizedProjectPath = normalizePath(projectPath);
@@ -0,0 +1,99 @@
import { describe, expect, test } from 'bun:test';
import { normalizePath } from './pathNormalization';
describe('normalizePath', () => {
describe('non-string inputs', () => {
test('returns null for null', () => {
expect(normalizePath(null)).toBeNull();
});
test('returns null for undefined', () => {
expect(normalizePath(undefined)).toBeNull();
});
test('returns null for empty string', () => {
expect(normalizePath('')).toBeNull();
});
test('returns null for whitespace-only', () => {
expect(normalizePath(' ')).toBeNull();
});
});
describe('backslashes', () => {
test('converts backslashes to forward slashes', () => {
expect(normalizePath('C:\\Users\\me\\project')).toBe('C:/Users/me/project');
});
});
describe('drive letter casing', () => {
test('uppercases lowercase Windows drive letter', () => {
expect(normalizePath('c:\\Users\\me\\project')).toBe('C:/Users/me/project');
});
test('preserves already-uppercase drive letter', () => {
expect(normalizePath('C:\\Users\\me\\project')).toBe('C:/Users/me/project');
});
test('does not match multi-character tokens before colon', () => {
expect(normalizePath('abc:def')).toBe('abc:def');
});
test('does not touch drive letter in middle of path', () => {
// Only the leading drive letter is touched; a "c:" later in the
// path is left alone (no upper-casing, no backslash conversion of
// the surrounding characters beyond the backslash-to-slash step).
expect(normalizePath('/foo/c:\\bar')).toBe('/foo/c:/bar');
});
});
describe('trailing slashes', () => {
test('strips a single trailing slash', () => {
expect(normalizePath('C:/Users/me/')).toBe('C:/Users/me');
});
test('strips multiple trailing slashes', () => {
expect(normalizePath('C:/Users/me///')).toBe('C:/Users/me');
});
test('preserves root /', () => {
expect(normalizePath('/')).toBe('/');
});
test('preserves single-char after slash strip', () => {
expect(normalizePath('C:/')).toBe('C:');
});
});
describe('degenerate slash-only inputs', () => {
// '///' → stays '///' after backslash replace → trailing-slash strip
// yields '' → null. This is the new defensive behavior.
test('returns null for multiple forward slashes', () => {
expect(normalizePath('///')).toBeNull();
});
// '\\\\' in source = 2 backslash chars → replace to '//' → strip → '' → null.
test('returns null for multiple backslashes', () => {
expect(normalizePath('\\\\')).toBeNull();
});
// A single backslash '\\' is normalized to a single forward slash
// and treated as the filesystem root, returned as '/'. (This is the
// pre-existing behavior; the defensive fix only adds null for
// slash-only inputs that strip down to ''.)
test('normalizes a single backslash to the root "/"', () => {
expect(normalizePath('\\')).toBe('/');
});
});
describe('Unix paths', () => {
test('passes through a Unix path unchanged', () => {
expect(normalizePath('/home/user/project')).toBe('/home/user/project');
});
test('strips trailing slashes from Unix paths', () => {
expect(normalizePath('/home/user/project/')).toBe('/home/user/project');
});
});
});
+29
View File
@@ -0,0 +1,29 @@
/**
* Normalize a directory path for consistent comparison.
*
* Handles Windows-specific path quirks:
* - Converts backslashes to forward slashes
* - Uppercases lowercase Windows drive letters (e.g., "c:\\" → "C:\\")
* - Trims trailing slashes (except for the root "/")
*
* Returns null for non-string inputs, null/undefined, empty strings,
* whitespace-only strings, and paths that consist only of slashes
* (e.g. "\\", "\\\\", "///").
*
* The drive letter regex is anchored (^([a-z]):) and matches only a
* single lowercase letter, so it never affects multi-character tokens
* (e.g., "abc:def"), URLs, or Windows `\\?\` device paths.
*/
export const normalizePath = (value?: string | null): string | null => {
if (typeof value !== "string") return null;
const trimmed = value.trim();
if (!trimmed) return null;
const replaced = trimmed
.replace(/\\/g, "/")
.replace(/^([a-z]):/, (_, letter: string) => letter.toUpperCase() + ":");
if (replaced === "/") return "/";
const stripped = replaced.length > 1 ? replaced.replace(/\/+$/, "") : replaced;
return stripped || null;
};
+2 -8
View File
@@ -1,14 +1,8 @@
import type { ProjectEntry } from "@/lib/api/types";
import type { WorktreeMetadata } from "@/types/worktree";
export const normalizeProjectPath = (value?: string | null): string | null => {
if (typeof value !== "string") return null;
const trimmed = value.trim();
if (!trimmed) return null;
const replaced = trimmed.replace(/\\/g, "/");
if (replaced === "/") return "/";
return replaced.length > 1 ? replaced.replace(/\/+$/, "") : replaced;
};
import { normalizePath } from "@/lib/pathNormalization";
export const normalizeProjectPath = normalizePath;
export const resolveProjectForDirectory = (
projects: ProjectEntry[],
+4 -3
View File
@@ -19,6 +19,7 @@ import { streamDebugEnabled } from "@/stores/utils/streamDebug";
import { parseModelIdentifier } from "@/lib/modelIdentifier";
import { runtimeFetch } from "@/lib/runtime-fetch";
import { markStartupTrace, measureStartupTrace } from "@/lib/startupTrace";
import { normalizePath } from "@/lib/pathNormalization";
import { getSyncConfig, subscribeToSyncConfigChanges } from "@/sync/sync-refs";
const MODELS_DEV_API_URL = "https://models.dev/api.json";
@@ -742,9 +743,9 @@ const rememberWorktreeProject = (worktree: string, project: string): void => {
};
const normalizeConfigPath = (value: string | null | undefined): string | null => {
const trimmed = typeof value === 'string' ? value.trim() : '';
if (!trimmed) return null;
return trimmed.replace(/\\/g, '/').replace(/\/+$/, '') || '/';
const result = normalizePath(value);
if (result === null) return null;
return result || '/';
};
const getKnownProjectDirectories = (): string[] => {
@@ -4,6 +4,7 @@ import { opencodeClient } from '@/lib/opencode/client';
import { listGlobalSessionPages } from '@/stores/globalSessions';
import { getReviewTransferDirection, type ReviewTransferDirection } from '@/lib/reviewFlow';
import { getOriginalSessionID, getReviewSessionID } from '@/lib/sessionReviewMetadata';
import { normalizePath } from '@/lib/pathNormalization';
type GlobalSessionsStatus = 'idle' | 'loading' | 'ready' | 'error';
@@ -37,21 +38,6 @@ let inflightLoad: Promise<LoadResult> | null = null;
// not apply its (stale) snapshot after the reset.
let loadGeneration = 0;
const normalizePath = (value?: string | null): string | null => {
if (typeof value !== 'string') {
return null;
}
const trimmed = value.trim();
if (!trimmed) {
return null;
}
const replaced = trimmed.replace(/\\/g, '/');
if (replaced === '/') {
return '/';
}
return replaced.length > 1 ? replaced.replace(/\/+$/, '') : replaced;
};
export const resolveGlobalSessionDirectory = (session: Session): string | null => {
const record = session as Session & {
directory?: string | null;
+1 -8
View File
@@ -27,6 +27,7 @@ import { useCommandsStore } from "@/stores/useCommandsStore"
import { useSkillsStore } from "@/stores/useSkillsStore"
import { getDeferredSafeStorage } from "@/stores/utils/safeStorage"
import { markPendingUserSendAnimation } from "@/lib/userSendAnimation"
import { normalizePath } from "@/lib/pathNormalization"
import { flattenAssistantTextParts } from "@/lib/messages/messageText"
import { composeForkSessionMessage } from "@/lib/messages/executionMeta"
import { waitForPendingDraftWorktreeRequest } from "@/lib/worktrees/pendingDraftWorktree"
@@ -315,14 +316,6 @@ export type SessionUIState = {
// Helpers
// ---------------------------------------------------------------------------
const normalizePath = (value?: string | null): string | null => {
if (typeof value !== "string") return null
const trimmed = value.trim()
if (!trimmed) return null
const replaced = trimmed.replace(/\\/g, "/")
if (replaced === "/") return "/"
return replaced.length > 1 ? replaced.replace(/\/+$/, "") : replaced
}
const resolveDirectoryKey = (session: Session): string | null => {
const sessionRecord = session as Session & {
@@ -68,13 +68,28 @@ export const createSettingsNormalizationRuntime = (dependencies) => {
return trimmed;
}
const resolved = options.resolveRealpath === false ? trimmed : safeRealpathSync(trimmed);
// Normalize Windows drive letter to uppercase to ensure consistent
// case across all path representations on Windows. NTFS is case-insensitive
// but case-preserving, so a path like "c:\\Users\\..." and "C:\\Users\\..."
// would be stored differently in settings.json across sessions.
const uppercaseDriveLetter = (p) =>
p.replace(/^([a-z]):/, (_, letter) => letter.toUpperCase() + ':');
if (processLike.platform !== 'win32') {
return resolved;
const isWindows = processLike.platform === 'win32';
const caseNormalized = isWindows ? uppercaseDriveLetter(trimmed) : trimmed;
const resolved = options.resolveRealpath === false ? caseNormalized : safeRealpathSync(caseNormalized);
// Re-normalize after realpath — safeRealpathSync may return a
// lowercase drive letter on some Windows environments.
const finalResolved = isWindows && typeof resolved === 'string'
? uppercaseDriveLetter(resolved)
: resolved;
if (!isWindows) {
return finalResolved;
}
return resolved.replace(/\//g, '\\');
return finalResolved.replace(/\//g, '\\');
};
const areStringArraysEqual = (a, b) => {
@@ -52,6 +52,27 @@ describe('settings normalization runtime - symlink resolution', () => {
const result = runtime.normalizePathForPersistence('/some/path');
expect(result).toBe('/some/path');
});
it('preserves lowercase colon-prefixed paths on non-Windows platforms', () => {
const runtime = createTestRuntime({ realpathSync: undefined });
expect(runtime.normalizePathForPersistence('c:project')).toBe('c:project');
});
it('uppercases Windows drive letter before and after realpath resolution', () => {
const runtime = createTestRuntime({
processLike: { platform: 'win32', env: {} },
realpathSync: (p) => {
// Simulate safeRealpathSync returning a lowercase drive letter
if (p === 'C:\\Users\\me\\project') return 'c:\\real\\project';
return p;
},
});
const result = runtime.normalizePathForPersistence('c:\\Users\\me\\project');
// Drive letter uppercased on input AND after realpath
expect(result).toBe('C:\\real\\project');
});
});
describe('sanitizeProjects', () => {