From 9769237d36429388fbde206c3e5c4d34669244ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yi=C4=9Fit=20ERDO=C4=9EAN?= Date: Thu, 13 Aug 2026 22:22:56 +0300 Subject: [PATCH] fix: Lobster cannot create its cache or state directories on Windows (#126) * fix(state): normalize extended-length mkdir paths On Windows fs.mkdir(recursive) reports the first created directory as an extended-length path (\?\C:\...) while the requested directory is a plain drive path. path.resolve keeps the prefix, so the two never compare equal and path.relative between them yields an absolute path. The chain walk then takes "C:" as its next segment and syncs a directory that does not exist, so ensureDirectory throws ENOENT every time it actually creates something. That breaks LLM cache writes, state.set, diff snapshots, and approval index publication on the first Windows run. Strip the prefix before resolving so both ends of the chain share one root form. On POSIX the prefixes never occur and the walk is unchanged. * fix(state): keep device namespaces out of the path normalization Stripping every \?\ prefix also rewrote namespaces that have no plain equivalent, so an explicitly configured LOBSTER_STATE_DIR such as \?\Volume{GUID}\lobster\state became relative and resolved against the current drive. That regressed a form main handles today. Map only the drive-letter and UNC namespaces, which do have a plain equivalent, and return anything else untouched. Cover the mapping directly so the device-namespace case is pinned. * fix(state): recognise the UNC namespace whatever its case Windows compares path namespace components case-insensitively, so `\?\unc\server\share` names the same share as `\?\UNC\server\share`. Matching only the uppercase form left the lowercase spelling extended while the directory it walks toward is plain, so the sync walk could never reach the requested final path on such a setup. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011gD5sJTbh2jn1uvgNCmkLq --------- Co-authored-by: Claude Opus 5 --- src/state/store.ts | 25 +++++++++++++++++++++++-- test/state.test.ts | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/src/state/store.ts b/src/state/store.ts index af442b0..eb5e0cf 100644 --- a/src/state/store.ts +++ b/src/state/store.ts @@ -118,9 +118,30 @@ async function syncDirectory(dir: string) { } } +/** + * On Windows, `fs.mkdir(..., { recursive: true })` reports the first created + * directory as an extended-length path (`\\?\C:\...`). `path.resolve` keeps + * that prefix, so such a path never compares equal to the plain drive path we + * walk toward and `path.relative` between the two yields an absolute path. + * Map the namespaces that have a plain equivalent back to it so both ends of the + * chain share one root form. The UNC marker is matched without regard to case, + * because Windows accepts a lowercase "unc" namespace component just as well. + * + * Device namespaces with no drive-letter or UNC equivalent, such as + * `\\?\Volume{GUID}\...`, are returned unchanged: stripping their prefix would + * leave a relative path and break an explicitly configured state directory. + */ +export function stripExtendedLengthPrefix(target: string) { + if (!target.startsWith("\\\\?\\")) return target; + const rest = target.slice(4); + if (/^UNC\\/i.test(rest)) return `\\\\${rest.slice(4)}`; + if (/^[A-Za-z]:[\\/]/.test(rest)) return rest; + return target; +} + async function syncCreatedDirectoryChain(firstCreated: string, finalDir: string) { - const final = path.resolve(finalDir); - let current = path.resolve(firstCreated); + const final = path.resolve(stripExtendedLengthPrefix(finalDir)); + let current = path.resolve(stripExtendedLengthPrefix(firstCreated)); await syncDirectory(path.dirname(current)); while (current !== final) { diff --git a/test/state.test.ts b/test/state.test.ts index 26f706b..ad6ae73 100644 --- a/test/state.test.ts +++ b/test/state.test.ts @@ -14,6 +14,8 @@ import { diffAndStore, keyToPath, withFileLock, + ensureDirectory, + stripExtendedLengthPrefix, writeStateJson, readStateJsonWithLock as readStateJson, writeFileAtomic, @@ -1026,3 +1028,37 @@ test("SDK writeState removes temp files when replacement fails", async () => { const leftovers = (await fsp.readdir(tmp)).filter((f) => f.includes(".tmp")); assert.deepEqual(leftovers, []); }); + +test("ensureDirectory creates missing parent directories", async () => { + const tmp = mkdtempSync(path.join(os.tmpdir(), "lobster-ensure-dir-")); + const nested = path.join(tmp, "alpha", "beta", "gamma"); + + await ensureDirectory(nested); + assert.equal((await fsp.stat(nested)).isDirectory(), true); + + // Re-running must stay a no-op once the whole chain already exists. + await ensureDirectory(nested); + assert.equal((await fsp.stat(nested)).isDirectory(), true); +}); + +test("stripExtendedLengthPrefix maps only namespaces with a plain equivalent", () => { + assert.equal(stripExtendedLengthPrefix("\\\\?\\C:\\lobster\\state"), "C:\\lobster\\state"); + assert.equal( + stripExtendedLengthPrefix("\\\\?\\UNC\\server\\share\\state"), + "\\\\server\\share\\state", + ); + + // Windows matches the namespace component case-insensitively, so a lowercase + // marker names the same share and must map to the same plain path. + assert.equal( + stripExtendedLengthPrefix("\\\\?\\unc\\server\\share\\state"), + "\\\\server\\share\\state", + ); + + // A device namespace has no drive-letter form, so stripping it would leave a + // relative path and break an explicitly configured state directory. + const volume = "\\\\?\\Volume{6f4c2b1a-0000-0000-0000-000000000000}\\lobster\\state"; + assert.equal(stripExtendedLengthPrefix(volume), volume); + + assert.equal(stripExtendedLengthPrefix("/tmp/lobster/state"), "/tmp/lobster/state"); +});