Bug #11: Linux Path Corruption + Stale Directory Cache
Status: RESOLVED in v3.14.1 Category: Path Normalization / Cache Correctness Severity: High (silent data loss, incorrect filesystem state) Resolution Date: 2026-02-17
Problem
Section titled “Problem”Two independent bugs triggered together in a common workflow: copying files from a WSL container path to a Windows destination.
Bug A — copy_file corrupts Linux source paths on Windows
Section titled “Bug A — copy_file corrupts Linux source paths on Windows”When copy_file received a pure Linux path (e.g. /tmp/package/dist/css/bootstrap.min.css) as the source while running on Windows, the operation failed with a misleading error:
Error: "source does not exist: \tmp\package\dist\css\bootstrap.min.css"NormalizePath() handled two cases explicitly:
| Input format | Conversion |
|---|---|
/mnt/c/Users/... | → C:\Users\... ✅ |
C:\Users\... | → normalized Windows path ✅ |
But paths starting with / that were not /mnt/<drive>/ (i.e. true Linux/WSL filesystem paths like /tmp/, /home/, /opt/) fell through to filepath.Clean(). On Windows, filepath.Clean("/tmp/package/...") converts forward slashes to backslashes and produces \tmp\package\... — a broken path that looks like a relative-from-root Windows path and doesn’t point to anything real.
/tmp/package/dist/css/bootstrap.min.css ↓ filepath.Clean() on Windows\tmp\package\dist\css\bootstrap.min.css ← NOT a valid Windows pathThe correct Windows-accessible path for a WSL file is the UNC form:
\\wsl.localhost\Ubuntu\tmp\package\dist\css\bootstrap.min.cssBug B — Directory cache not invalidated after external writes
Section titled “Bug B — Directory cache not invalidated after external writes”After bash cp (or any process outside the MCP server) wrote files into a directory, subsequent calls to mcp_list returned a stale cached listing showing the directory as empty:
bash cp /tmp/package/dist/css/bootstrap.min.css \ /mnt/c/Users/DAVID/.../lib/bootstrap/ → success (files exist on disk)
mcp_list C:\Users\DAVID\...\lib\bootstrap\ → shows empty ← BUGbash ls /mnt/c/Users/DAVID/.../lib/bootstrap/ → bootstrap.min.css (232 KB) bootstrap.min.css.map (589 KB)Root cause: SetDirectory() stored only the listing string. There was no way for ListDirectoryContent() to know whether the directory had been modified since the entry was cached. The 3-minute TTL was the only expiry mechanism — external writes were invisible.
Combined Impact
Section titled “Combined Impact”The two bugs typically appeared together:
copy_filewith a Linux source path failed (Bug A) → user fell back tobash cpbash cpsucceeded, butmcp_liststill showed the old (empty) state (Bug B)- The directory appeared empty to the AI even though the files were present on disk
Solution
Section titled “Solution”Bug A — WSL UNC path conversion in NormalizePath()
Section titled “Bug A — WSL UNC path conversion in NormalizePath()”Added a cached WSL distro lookup (getDefaultWSLDistro() in core/path_detector.go) that runs wsl.exe -l --quiet once and stores the default distro name. The distro name is resolved with sync.Once so the exec call happens at most once per server lifetime.
NormalizePath() (core/engine.go) now handles the missing case before the final fallback:
// Handle pure Linux/WSL paths (e.g. /tmp/..., /home/...) when running on Windows.// filepath.Clean() would corrupt them to \tmp\... which is not a valid Windows path.if os.PathSeparator == '\\' && len(path) > 0 && path[0] == '/' { if distro := getDefaultWSLDistro(); distro != "" { // /tmp/package/dist/... → \\wsl.localhost\Ubuntu\tmp\package\dist\... return `\\wsl.localhost\` + distro + filepath.FromSlash(path) } // WSL not available: return unchanged so error messages stay meaningful // ("source does not exist: /tmp/...") instead of the corrupted backslash form. return path}Before:
copy_file source=/tmp/package/dist/css/bootstrap.min.cssNormalizePath() → \tmp\package\dist\css\bootstrap.min.cssos.Stat() → "source does not exist: \tmp\package\dist\css\bootstrap.min.css"After:
copy_file source=/tmp/package/dist/css/bootstrap.min.cssNormalizePath() → \\wsl.localhost\Ubuntu\tmp\package\dist\css\bootstrap.min.cssos.Stat() → file found, copy proceeds ✓If WSL is not installed or no distro is found, the path is returned unchanged with its original forward slashes, giving a meaningful error message instead of a cryptic backslash path.
Bug B — Mtime validation on directory cache reads
Section titled “Bug B — Mtime validation on directory cache reads”The cache entry for directories was changed from a bare string to a struct that also stores the directory’s modification time at cache time:
type dirCacheEntry struct { Listing string Mtime time.Time // directory mtime when the cache was populated}SetDirectory() now accepts the mtime, and GetDirectory() returns it alongside the listing:
SetDirectory(path string, listing string, mtime time.Time)GetDirectory(path string) (listing string, cachedMtime time.Time, hit bool)ListDirectoryContent() (core/engine.go) validates the cache before returning it:
dirInfo, statErr := os.Stat(path)
if cached, cachedMtime, hit := e.cache.GetDirectory(path); hit { // Accept cache only if the directory hasn't changed on disk since it was cached. if statErr == nil && !dirInfo.ModTime().After(cachedMtime) { return cached, nil // cache is fresh } // External write detected — invalidate and re-read from disk. e.cache.InvalidateDirectory(path)}The os.Stat() call adds one syscall (~1 µs) on every cache hit, which is negligible compared to the correctness benefit.
Before:
mcp_list (dir is empty) → cached listing: ""bash cp writes 2 files → cache unawaremcp_list (cache hit) → returns "" ← staleAfter:
mcp_list (dir is empty) → cached listing: "", mtime=T0bash cp writes 2 files → directory mtime=T1 > T0mcp_list (cache hit) → os.Stat() mtime=T1 > T0 → invalidate → re-read → returns 2 files ✓Files Changed
Section titled “Files Changed”| File | Change |
|---|---|
core/path_detector.go | Added getDefaultWSLDistro() with sync.Once caching |
core/engine.go | Added Linux-path branch in NormalizePath(); mtime validation in ListDirectoryContent() |
cache/intelligent.go | dirCacheEntry struct; updated SetDirectory() and GetDirectory() signatures |
Verification
Section titled “Verification”go build ./... → OK (no errors)go test ./tests/... ./core/ → PASS (all suites)Lessons Learned
Section titled “Lessons Learned”filepath.Clean()is not path-format-agnostic: On Windows it converts forward slashes to backslashes, silently transforming a valid Linux path into a broken Windows path. Path format detection must happen before calling anyfilepathfunction.- TTL alone is not enough for external-write scenarios: Any process outside the MCP server can invalidate the cache. Mtime comparison is a cheap, reliable way to detect this without needing a file watcher registration for every cached directory.
sync.Oncefor expensive environment detection: WSL distro lookup involves an exec call — caching it once avoids per-call overhead on a hot path likeNormalizePath().
Related
Section titled “Related”- Bug #9 — Previous path normalization issue
- Path Conversion Reference — WSL ↔ Windows path handling
- Cache Architecture — 3-tier cache design