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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011gD5sJTbh2jn1uvgNCmkLq

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Yiğit ERDOĞAN
2026-08-13 12:22:56 -07:00
committed by GitHub
co-authored by Claude Opus 5
parent f14e22d94a
commit 9769237d36
2 changed files with 59 additions and 2 deletions
+23 -2
View File
@@ -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) {
+36
View File
@@ -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");
});