fix(serve/fs): cross-platform ENOTDIR detection (#4250 CI fix)

Windows CI test failure on a81ada43f surfaced a real cross-platform
bug in `findExistingAncestor`. POSIX returns `ENOTDIR` when
`fs.stat` traverses through a non-directory in a path component
(e.g. `${ws}/file.txt/leaf` where `file.txt` is a regular file).
**Windows returns `ENOENT` for the same case.** The errno-based
guard added in a81ada43f only branched on `ENOTDIR`, so the
Windows path silently fell through to the ancestor walk and the
boundary returned a "canonical" the eventual write could not
honor — `WorkspaceFileSystem - audit always emits on body errors >
rejects ENOTDIR ancestor walk with parse_error rather than
passing boundary` failed with `expected false to be true` on the
windows-latest runner.

Fix: switch from errno-based detection (platform-divergent) to
dirent-kind detection. After `fs.stat` succeeds during the
walk-up, if the existing ancestor is NOT a directory AND there
are unresolved tail components, throw `parse_error`. Both `ENOENT`
and `ENOTDIR` from `fs.stat` are now treated as "the *current*
path doesn't resolve, keep walking" — the post-walk kind check
fires regardless of which errno surfaced. Cross-platform-safe.

The local 110/110 fs tests still pass on macOS/Linux; the Windows
case will exercise the kind-check branch on next CI run.

macOS CI failures on the same workflow run (`InputPrompt.test.tsx`
placeholder reuse, `SettingsDialog.test.tsx` 5s timeout) are pre-
existing flaky UI tests, NOT touched by this PR.

🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)
This commit is contained in:
doudouOUC 2026-05-18 00:40:32 +08:00
parent efd7a46115
commit b38e821576

View file

@ -202,32 +202,45 @@ async function findExistingAncestor(
let current = absolute;
const tailParts: string[] = [];
for (let i = 0; i < MAX_ANCESTOR_HOPS; i++) {
let stat: Awaited<ReturnType<typeof fsp.stat>> | null = null;
try {
await fsp.stat(current);
return { ancestor: current, tail: tailParts.join(path.sep) };
stat = await fsp.stat(current);
} catch (err) {
const code = (err as NodeJS.ErrnoException)?.code;
if (code === 'ENOENT') {
// ancestor doesn't exist yet; keep walking up
} else if (code === 'ENOTDIR') {
// A regular file sits where we expected a directory (e.g.
// write target `${ws}/file.txt/child`). Walking up would
// happily realpath the file's parent and return a
// "canonical" the eventual write cannot use. Reject up-front
// so the orchestrator emits an `fs.denied` for the actual
// shape of the user error rather than silently passing
// boundary inspection and 500-ing later at write time.
if (code === 'ENOENT' || code === 'ENOTDIR') {
// POSIX returns `ENOTDIR` when a regular file occupies a
// path segment we tried to traverse through; Windows
// returns `ENOENT` for the same case (CI failure on
// commit a81ada43f flagged the divergence). Either errno
// means "the *current* path doesn't resolve" — keep
// walking up and let the post-walk dirent-kind check
// below decide whether to accept the ancestor.
} else {
throw err;
}
}
if (stat) {
// A regular file sits where the request expected a directory
// (e.g. write target `${ws}/file.txt/child`, where
// `file.txt` is a regular file). Without this check the
// walk would happily return the file's parent as the
// canonical ancestor and let the eventual write surface a
// confusing late failure. Reject up-front with
// `parse_error`. `fsp.stat` follows symlinks, so
// `stat.isDirectory()` reflects the symlink target's kind —
// exactly what we want. Cross-platform: works the same on
// POSIX and Windows because the kind check fires regardless
// of which errno surfaced during the walk-up.
if (tailParts.length > 0 && !stat.isDirectory()) {
throw new FsError(
'parse_error',
`path component is not a directory: ${absolute}`,
{
cause: err,
hint: 'a non-directory file occupies a path segment',
},
);
} else {
throw err;
}
return { ancestor: current, tail: tailParts.join(path.sep) };
}
const parent = path.dirname(current);
if (parent === current) {