* 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>
468 lines
14 KiB
JavaScript
468 lines
14 KiB
JavaScript
export const createSettingsNormalizationRuntime = (dependencies) => {
|
|
const {
|
|
os,
|
|
path,
|
|
processLike,
|
|
realpathSync,
|
|
tunnelBootstrapTtlDefaultMs,
|
|
tunnelBootstrapTtlMinMs,
|
|
tunnelBootstrapTtlMaxMs,
|
|
tunnelSessionTtlDefaultMs,
|
|
tunnelSessionTtlMinMs,
|
|
tunnelSessionTtlMaxMs,
|
|
} = dependencies;
|
|
|
|
const normalizeDirectoryPath = (value) => {
|
|
if (typeof value !== 'string') {
|
|
return value;
|
|
}
|
|
|
|
let trimmed = value.trim();
|
|
// Paths pasted from Windows "Copy as path" (or quoted shell snippets)
|
|
// arrive wrapped in quotes — a literal quote character can never be part
|
|
// of a real path, and it breaks every fs.stat/executable check.
|
|
if (trimmed.length >= 2
|
|
&& ((trimmed.startsWith('"') && trimmed.endsWith('"'))
|
|
|| (trimmed.startsWith("'") && trimmed.endsWith("'")))) {
|
|
trimmed = trimmed.slice(1, -1).trim();
|
|
}
|
|
if (!trimmed) {
|
|
return trimmed;
|
|
}
|
|
|
|
if (trimmed === '~') {
|
|
return os.homedir();
|
|
}
|
|
|
|
if (trimmed.startsWith('~/') || trimmed.startsWith('~\\')) {
|
|
return path.join(os.homedir(), trimmed.slice(2));
|
|
}
|
|
|
|
return trimmed;
|
|
};
|
|
|
|
// Resolve symlinks, falling back to the original value on failure.
|
|
const safeRealpathSync = (value) => {
|
|
if (!realpathSync || typeof value !== 'string' || !value) {
|
|
return value;
|
|
}
|
|
try {
|
|
return realpathSync(value);
|
|
} catch {
|
|
return value;
|
|
}
|
|
};
|
|
|
|
const normalizePathForPersistence = (value, options = {}) => {
|
|
if (typeof value !== 'string') {
|
|
return value;
|
|
}
|
|
|
|
const normalized = normalizeDirectoryPath(value);
|
|
if (typeof normalized !== 'string') {
|
|
return normalized;
|
|
}
|
|
|
|
const trimmed = normalized.trim();
|
|
if (!trimmed) {
|
|
return 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() + ':');
|
|
|
|
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 finalResolved.replace(/\//g, '\\');
|
|
};
|
|
|
|
const areStringArraysEqual = (a, b) => {
|
|
if (!Array.isArray(a) || !Array.isArray(b)) {
|
|
return false;
|
|
}
|
|
if (a.length !== b.length) {
|
|
return false;
|
|
}
|
|
for (let i = 0; i < a.length; i += 1) {
|
|
if (a[i] !== b[i]) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
};
|
|
|
|
const normalizeStringArray = (input) => {
|
|
if (!Array.isArray(input)) {
|
|
return [];
|
|
}
|
|
return Array.from(
|
|
new Set(
|
|
input.filter((entry) => typeof entry === 'string' && entry.length > 0)
|
|
)
|
|
);
|
|
};
|
|
|
|
const sanitizeProjects = (input) => {
|
|
if (!Array.isArray(input)) {
|
|
return undefined;
|
|
}
|
|
|
|
const hexColorPattern = /^#(?:[\da-fA-F]{3}|[\da-fA-F]{6})$/;
|
|
const normalizeIconBackground = (value) => {
|
|
if (typeof value !== 'string') {
|
|
return null;
|
|
}
|
|
const trimmed = value.trim();
|
|
if (!trimmed) {
|
|
return null;
|
|
}
|
|
return hexColorPattern.test(trimmed) ? trimmed.toLowerCase() : null;
|
|
};
|
|
|
|
const result = [];
|
|
const seenIds = new Set();
|
|
const seenPaths = new Set();
|
|
|
|
for (const entry of input) {
|
|
if (!entry || typeof entry !== 'object') continue;
|
|
|
|
const candidate = entry;
|
|
const id = typeof candidate.id === 'string' ? candidate.id.trim() : '';
|
|
const rawPath = typeof candidate.path === 'string' ? candidate.path.trim() : '';
|
|
const resolvedPath = rawPath ? safeRealpathSync(path.resolve(normalizeDirectoryPath(rawPath))) : '';
|
|
const normalizedPath = resolvedPath ? normalizePathForPersistence(resolvedPath, { resolveRealpath: false }) : '';
|
|
const label = typeof candidate.label === 'string' ? candidate.label.trim() : '';
|
|
const icon = typeof candidate.icon === 'string' ? candidate.icon.trim() : '';
|
|
const iconImage = candidate.iconImage && typeof candidate.iconImage === 'object'
|
|
? candidate.iconImage
|
|
: null;
|
|
const iconBackground = normalizeIconBackground(candidate.iconBackground);
|
|
const color = typeof candidate.color === 'string' ? candidate.color.trim() : '';
|
|
const defaultModel = typeof candidate.defaultModel === 'string' ? candidate.defaultModel.trim() : '';
|
|
const addedAt = Number.isFinite(candidate.addedAt) ? Number(candidate.addedAt) : null;
|
|
const lastOpenedAt = Number.isFinite(candidate.lastOpenedAt)
|
|
? Number(candidate.lastOpenedAt)
|
|
: null;
|
|
|
|
if (!id || !normalizedPath) continue;
|
|
if (seenIds.has(id)) continue;
|
|
if (seenPaths.has(normalizedPath)) continue;
|
|
|
|
seenIds.add(id);
|
|
seenPaths.add(normalizedPath);
|
|
|
|
const project = {
|
|
id,
|
|
path: normalizedPath,
|
|
...(label ? { label } : {}),
|
|
...(icon ? { icon } : {}),
|
|
...(iconBackground ? { iconBackground } : {}),
|
|
...(color ? { color } : {}),
|
|
...(defaultModel && defaultModel.includes('/') ? { defaultModel } : {}),
|
|
...(Number.isFinite(addedAt) && addedAt >= 0 ? { addedAt } : {}),
|
|
...(Number.isFinite(lastOpenedAt) && lastOpenedAt >= 0 ? { lastOpenedAt } : {}),
|
|
};
|
|
|
|
if (candidate.iconImage === null) {
|
|
project.iconImage = null;
|
|
} else if (iconImage) {
|
|
const mime = typeof iconImage.mime === 'string' ? iconImage.mime.trim() : '';
|
|
const updatedAt = typeof iconImage.updatedAt === 'number' && Number.isFinite(iconImage.updatedAt)
|
|
? Math.max(0, Math.round(iconImage.updatedAt))
|
|
: 0;
|
|
const source = iconImage.source === 'custom' || iconImage.source === 'auto'
|
|
? iconImage.source
|
|
: null;
|
|
if (mime && updatedAt > 0 && source) {
|
|
project.iconImage = { mime, updatedAt, source };
|
|
}
|
|
}
|
|
|
|
if (candidate.iconBackground === null) {
|
|
project.iconBackground = null;
|
|
}
|
|
|
|
if (typeof candidate.sidebarCollapsed === 'boolean') {
|
|
project.sidebarCollapsed = candidate.sidebarCollapsed;
|
|
}
|
|
|
|
result.push(project);
|
|
}
|
|
|
|
return result;
|
|
};
|
|
|
|
const normalizeSettingsPaths = (input) => {
|
|
const settings = input && typeof input === 'object' ? input : {};
|
|
let next = settings;
|
|
let changed = false;
|
|
|
|
const ensureNext = () => {
|
|
if (next === settings) {
|
|
next = { ...settings };
|
|
}
|
|
};
|
|
|
|
const normalizePathField = (key) => {
|
|
if (typeof settings[key] !== 'string' || settings[key].length === 0) {
|
|
return;
|
|
}
|
|
const normalized = normalizePathForPersistence(settings[key]);
|
|
if (normalized !== settings[key]) {
|
|
ensureNext();
|
|
next[key] = normalized;
|
|
changed = true;
|
|
}
|
|
};
|
|
|
|
const normalizePathArrayField = (key) => {
|
|
if (!Array.isArray(settings[key])) {
|
|
return;
|
|
}
|
|
|
|
const normalized = normalizeStringArray(
|
|
settings[key]
|
|
.map((entry) => (typeof entry === 'string' ? normalizePathForPersistence(entry) : entry))
|
|
.filter((entry) => typeof entry === 'string' && entry.length > 0)
|
|
);
|
|
|
|
if (!areStringArraysEqual(normalized, settings[key])) {
|
|
ensureNext();
|
|
next[key] = normalized;
|
|
changed = true;
|
|
}
|
|
};
|
|
|
|
normalizePathField('lastDirectory');
|
|
normalizePathField('homeDirectory');
|
|
normalizePathArrayField('pinnedDirectories');
|
|
|
|
if (Array.isArray(settings.projects)) {
|
|
const normalizedProjects = sanitizeProjects(settings.projects) || [];
|
|
if (JSON.stringify(normalizedProjects) !== JSON.stringify(settings.projects)) {
|
|
ensureNext();
|
|
next.projects = normalizedProjects;
|
|
changed = true;
|
|
}
|
|
}
|
|
|
|
return { settings: next, changed };
|
|
};
|
|
|
|
const clampNumber = (value, min, max) => Math.max(min, Math.min(max, value));
|
|
|
|
const normalizeTunnelBootstrapTtlMs = (value) => {
|
|
if (value === null) {
|
|
return null;
|
|
}
|
|
if (!Number.isFinite(value)) {
|
|
return tunnelBootstrapTtlDefaultMs;
|
|
}
|
|
return clampNumber(Math.round(value), tunnelBootstrapTtlMinMs, tunnelBootstrapTtlMaxMs);
|
|
};
|
|
|
|
const normalizeTunnelSessionTtlMs = (value) => {
|
|
if (!Number.isFinite(value)) {
|
|
return tunnelSessionTtlDefaultMs;
|
|
}
|
|
return clampNumber(Math.round(value), tunnelSessionTtlMinMs, tunnelSessionTtlMaxMs);
|
|
};
|
|
|
|
const normalizeManagedRemoteTunnelHostname = (value) => {
|
|
if (typeof value !== 'string') {
|
|
return undefined;
|
|
}
|
|
const trimmed = value.trim();
|
|
if (!trimmed) {
|
|
return undefined;
|
|
}
|
|
|
|
const parsed = (() => {
|
|
try {
|
|
if (trimmed.includes('://')) {
|
|
return new URL(trimmed);
|
|
}
|
|
return new URL(`https://${trimmed}`);
|
|
} catch {
|
|
return null;
|
|
}
|
|
})();
|
|
|
|
const hostname = parsed?.hostname?.trim().toLowerCase() || '';
|
|
if (!hostname) {
|
|
return undefined;
|
|
}
|
|
return hostname;
|
|
};
|
|
|
|
const normalizeManagedRemoteTunnelPresets = (value) => {
|
|
if (!Array.isArray(value)) {
|
|
return undefined;
|
|
}
|
|
|
|
const result = [];
|
|
const seenIds = new Set();
|
|
const seenHostnames = new Set();
|
|
|
|
for (const entry of value) {
|
|
if (!entry || typeof entry !== 'object') continue;
|
|
const candidate = entry;
|
|
const id = typeof candidate.id === 'string' ? candidate.id.trim() : '';
|
|
const name = typeof candidate.name === 'string' ? candidate.name.trim() : '';
|
|
const hostname = normalizeManagedRemoteTunnelHostname(candidate.hostname);
|
|
if (!id || !name || !hostname) continue;
|
|
if (seenIds.has(id) || seenHostnames.has(hostname)) continue;
|
|
seenIds.add(id);
|
|
seenHostnames.add(hostname);
|
|
result.push({ id, name, hostname });
|
|
}
|
|
|
|
return result;
|
|
};
|
|
|
|
const normalizeManagedRemoteTunnelPresetTokens = (value) => {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
return undefined;
|
|
}
|
|
|
|
const result = {};
|
|
for (const [rawId, rawToken] of Object.entries(value)) {
|
|
const id = typeof rawId === 'string' ? rawId.trim() : '';
|
|
const token = typeof rawToken === 'string' ? rawToken.trim() : '';
|
|
if (!id || !token) {
|
|
continue;
|
|
}
|
|
result[id] = token;
|
|
}
|
|
|
|
return Object.keys(result).length > 0 ? result : undefined;
|
|
};
|
|
|
|
const isUnsafeSkillRelativePath = (value) => {
|
|
if (typeof value !== 'string' || value.length === 0) {
|
|
return true;
|
|
}
|
|
|
|
const normalized = value.replace(/\\/g, '/');
|
|
if (path.posix.isAbsolute(normalized)) {
|
|
return true;
|
|
}
|
|
|
|
return normalized.split('/').some((segment) => segment === '..');
|
|
};
|
|
|
|
const sanitizeTypographySizesPartial = (input) => {
|
|
if (!input || typeof input !== 'object') {
|
|
return undefined;
|
|
}
|
|
const candidate = input;
|
|
const result = {};
|
|
let populated = false;
|
|
|
|
const assign = (key) => {
|
|
if (typeof candidate[key] === 'string' && candidate[key].length > 0) {
|
|
result[key] = candidate[key];
|
|
populated = true;
|
|
}
|
|
};
|
|
|
|
assign('markdown');
|
|
assign('code');
|
|
assign('uiHeader');
|
|
assign('uiLabel');
|
|
assign('meta');
|
|
assign('micro');
|
|
|
|
return populated ? result : undefined;
|
|
};
|
|
|
|
const sanitizeModelRefs = (input, limit) => {
|
|
if (!Array.isArray(input)) {
|
|
return undefined;
|
|
}
|
|
|
|
const result = [];
|
|
const seen = new Set();
|
|
|
|
for (const entry of input) {
|
|
if (!entry || typeof entry !== 'object') continue;
|
|
const providerID = typeof entry.providerID === 'string' ? entry.providerID.trim() : '';
|
|
const modelID = typeof entry.modelID === 'string' ? entry.modelID.trim() : '';
|
|
if (!providerID || !modelID) continue;
|
|
const key = `${providerID}/${modelID}`;
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
result.push({ providerID, modelID });
|
|
if (result.length >= limit) break;
|
|
}
|
|
|
|
return result;
|
|
};
|
|
|
|
const sanitizeSkillCatalogs = (input) => {
|
|
if (!Array.isArray(input)) {
|
|
return undefined;
|
|
}
|
|
|
|
const result = [];
|
|
const seen = new Set();
|
|
|
|
for (const entry of input) {
|
|
if (!entry || typeof entry !== 'object') continue;
|
|
|
|
const id = typeof entry.id === 'string' ? entry.id.trim() : '';
|
|
const label = typeof entry.label === 'string' ? entry.label.trim() : '';
|
|
const source = typeof entry.source === 'string' ? entry.source.trim() : '';
|
|
const subpath = typeof entry.subpath === 'string' ? entry.subpath.trim() : '';
|
|
const gitIdentityId = typeof entry.gitIdentityId === 'string' ? entry.gitIdentityId.trim() : '';
|
|
|
|
if (!id || !label || !source) continue;
|
|
if (seen.has(id)) continue;
|
|
seen.add(id);
|
|
|
|
result.push({
|
|
id,
|
|
label,
|
|
source,
|
|
...(subpath ? { subpath } : {}),
|
|
...(gitIdentityId ? { gitIdentityId } : {}),
|
|
});
|
|
}
|
|
|
|
return result;
|
|
};
|
|
|
|
return {
|
|
normalizeDirectoryPath,
|
|
normalizePathForPersistence,
|
|
normalizeSettingsPaths,
|
|
normalizeTunnelBootstrapTtlMs,
|
|
normalizeTunnelSessionTtlMs,
|
|
normalizeManagedRemoteTunnelHostname,
|
|
normalizeManagedRemoteTunnelPresets,
|
|
normalizeManagedRemoteTunnelPresetTokens,
|
|
isUnsafeSkillRelativePath,
|
|
sanitizeTypographySizesPartial,
|
|
normalizeStringArray,
|
|
sanitizeModelRefs,
|
|
sanitizeSkillCatalogs,
|
|
sanitizeProjects,
|
|
};
|
|
};
|