DSH plugin write 工具在 exFAT 盘报 EISDIR?硬链接发布机制排查

故障排查发布于 2026-09-12作者: DeepSeek Plugin 插件市场
DeepSeek HarnessDSH pluginEISDIRexFATEPERM mkdir
write 工具在 exFAT 卷上 100% 失败,却报 EISDIR(目标其实是新文件)?真因是该卷不支持硬链接、被 libuv 误映射成 EISDIR;另有一条跨文件系统的 EPERM mkdir 盘根问题。

如果你在 Windows 上用 write 工具往移动硬盘或 U 盘写文件,报 EISDIR: illegal operation on a directory, link '...tmpdir\test.md.tmp' -> '...test.md',而那个目标其实是个还不存在的新文件——那不是路径写错了,而是该卷不支持硬链接。 write 后端的落盘方式是「同目录临时文件 + link() 硬链接原子发布」,exFAT / FAT32 不支持硬链接,这一步必然失败;EISDIR 是 Node/libuv 运行时对该失败的误映射(Python os.link 对照给出的真实错误是 WinError 1 函数不正确)。另有一条独立缺陷:写盘根目录时因 mkdir 返回 EPERM 而失败,跨文件系统、与 exFAT 无关。

DSH plugin 的两种报错:exFAT 上的 EISDIR 与盘根的 EPERM mkdir

两条报错的触发条件、作用面、修法都不同,先分开。 具体表现:

  1. 报错一:exFAT 卷上写新文件必挂
Error: cannot write "H:\workbuddy\test\test.md": EISDIR: illegal operation on a directory,
link 'H:\workbuddy\test\.test.md.<pid>.<uuid>.tmpdir\test.md.tmp' -> 'H:\workbuddy\test\test.md'

两个问题叠加:① exFAT 卷上必挂——同一操作在 NTFS 卷全部成功,在 exFAT 卷全部失败;② 错误码误导——真实原因是「硬链接操作在该卷上不被支持」,报错却被翻译成 EISDIR(「目标是个目录」),而目标路径当时根本不存在。报告者据此先误查了半天路径,最后靠独立对照实验才定位到 link#5704)。 2. 实测矩阵:按文件系统切分,与内置/外接无关

目标盘卷标文件系统write 写子目录结果
C:OSNTFS✅ 成功
D:softNTFS✅ 成功
F:dataNTFS✅ 成功
E:Backup Plus(USB 移动硬盘)exFATEISDIR
H:sdzyq(USB 移动硬盘)exFATEISDIR

规律完全切分:NTFS 全好,exFAT 全挂#5704)。 3. 根因对照实验(绕开 harness,直接验证 link:在 PowerShell 里用 Python 对各卷执行 os.link(src, dst),结果是 H:(exFAT)→ OSError [WinError 1] 函数不正确D:(NTFS)→ OK。exFAT 文件系统不支持硬链接link 系统调用在该卷上被拒;而后端恰好用 link 做落位,于是必然失败(#5704)。 4. 报错二:写任何盘的根目录都报 EPERM

Error: cannot write "D:\test.md": EPERM: operation not permitted, mkdir 'D:\'

X:\ 根目录下的文件时(C/D/E/F/H 五个盘符全部实测),报 EPERM mkdir 'X:\'。写入前的父目录确保逻辑(ensureParentDir)似乎对「盘根」这个已存在的路径仍然发起了 mkdir。影响面不如 EISDIR 大(日常很少向盘根写文件),但它是跨文件系统的独立缺陷(#5704)。 5. 环境与影响面:报告环境为 Windows 11 家庭中文版(10.0.22631)+ DSH plugin 0.1.2-rc.1(npm 安装),源码级核实基于 d347e7039(v0.1.3-alpha.1,当时 master)。影响面是所有 exFAT / FAT32 卷——移动硬盘、U 盘、部分 NAS 与网盘同步目录——上的文件写入不可用;读取不受影响。因为 exFAT 是移动硬盘出厂常用格式,面向普通用户的 agent 场景(往 U 盘/移动盘写文档)并不罕见,所以这值得修复而非只在文档里声明(#5704)。

DeepSeek Harness 机制:临时文件 + 硬链接原子发布,以及 libuv 的 EISDIR 误映射

链路是三段式的:应用层选策略 → 文件系统层用 link 发布 → 运行时层把失败错报成 EISDIR。 逐层拆解:

  1. write 后端确实是「临时文件 + link 硬链接」packages/fs/tool-fs/src/write.ts 的 write 工具调用 ctx.fs.writeText(target, content, intent, ...)。「单槽决策」在 write.ts:108-110——策略插件产出 createIfAbsent / replaceIfVersion,裸 default 为 undefined。intent.kind === 'createIfAbsent' 时,走 fs-local 的硬链接 no-replace 发布#5704)。
  2. staging 目录与 link 的精确位置packages/fs/fs-local/src/index.ts:188,210——createIfAbsent 且目标不存在时,把 { displayPath } 传给 writeFileAtomicpackages/fs/fs-local/src/fsio.ts:546 的 staging 目录是 `.${basename}.${process.pid}.${randomUUID()}.tmpdir`,与报错里的 H:\workbuddy\test\.test.md.<pid>.<uuid>.tmpdir\test.md.tmp 完全吻合;:580 执行 linkFile(tempPath, absolutePath)(只在 createIfAbsent !== undefined 分支)。这也解释了为什么报告者「从报错签名反推实现」是准确的(#5704)。
  3. EISDIR 来自 Node/libuv 运行时层,不是应用层fsio.ts:44-46errorMessage逐字透传 error.message,不做任何 errno 翻译:
ts
function errorMessage(error: unknown): string {
  return error instanceof Error ? error.message : String(error)
}

所以在 :515 throw new FsError(..., 'FS_IO_ERROR', { cause: error }) 时,报错字符串就是底层 Node/libuv 的 link() 失败消息。也就是说 libuv 把 link() 在 exFAT 上的失败呈现成 EISDIR(与 node -e "require('fs').linkSync(...)" 的复现一致),而真实 errno 是 WinError 1 (函数不正确)这值得单独向 Node.js/libuv 上游反馈uv_fs_link 的 win32 映射里,ERROR_INVALID_FUNCTION 应映射成 ENOSYS/EPERM 而不是 EISDIR#5704)。 4. 不依赖 harness 的最小复现:任何 exFAT 卷都能复现这条边界——Node 报失真错误码,Python 给出真实原因:

powershell
node -e "require('fs').linkSync('X:\\\\a.tmp','X:\\\\b.tmp')"
#    → 报 EISDIR(错误码失真)
python -c "import os; os.link(r'X:\a.tmp', r'X:\b.tmp')"
#    → WinError 1 函数不正确(真实原因)
  1. 应用层其实已经知道 link errno 不稳定fsio.ts:496-497 的注释明说要区分「collision vs missing hard-link support」("Link errno values vary by platform and filesystem.")——说明内部已经意识到不同文件系统上 link 的 errno 不一致,但只对「目标已存在」做了区分,没对「该卷根本不支持硬链接」降级。同一模式还出现在 fs-sandbox / session-persistence-jsonlmaterializePosix(#5432,HarmonyOS hmdfs 无硬链接时 link 失败会全量阻断)(#5704)。
  2. 盘根 EPERM 的机制fsio.ts:543const directory = dirname(absolutePath)(写盘根下的文件时等于 H:\),紧跟 await mkdir(directory, { recursive: true })。这个 directory已存在的盘根mkdir(..., { recursive: true }) 本应容忍「已存在」(EEXIST),但在 Windows 盘根上 mkdir 返回的是 EPERM 而非 EEXIST——recursive: true 只兜 EEXIST,不兜 EPERM,Node/libuv 也不特判盘根。于是任何写到盘根的 write/编辑都会在此直接爆掉,与文件系统无关(五个盘符 + NTFS/exFAT 均复现即是佐证)(#5704)。
  3. 归入一个家族:这两条可以归进「原子发布原语假设通用 FS 能力」这一族——同轴已有 #3577(Windows rename EXDEVstorage-json/writeAtomic)与 #5432(materializePosix 裸 link 无降级),#5704 是第三个成员,且额外暴露了错误码映射误导这一独立问题。三者的修复思路一致:发布原语应识别「该卷/该 FS 不支持此操作」并降级到可用路径,而不是让原生 errno 原样漏出(#5704)。

DSH plugin 参考 diff:link→rename 降级、盘根 mkdir 兜底与上游 nodejs/node#65817

两个修复互相独立、可分开合:A 管盘根 mkdir EPERM(跨文件系统),B 管 exFAT link 降级。 具体如下:

  1. fix B:link 失败时降级为 rename:核心思路是——createIfAbsent 分支里的 link() 在 exFAT 上失败,但目标仍不存在(不是并发创建者引起的 EEXIST 碰撞),说明该文件系统不支持硬链接(libuv 误映射为 EISDIR)。此时降级为 rename 也能把临时文件原子地发布出去,新文件写入即可成功。参考 diff:
diff
@@ packages/fs/fs-local/src/fsio.ts @@
     if (createIfAbsent !== undefined) {
       try {
         await linkFile(tempPath, absolutePath)
       } catch (error: unknown) {
-        await throwGuardedCreateFailure(error, absolutePath, createIfAbsent.displayPath, inspectPublicationTarget)
+        // Some filesystems (exFAT on Windows is the canonical case) do not
+        // support hard links. libuv mis-maps the failed fs.link() as EISDIR
+        // (not EEXIST), so collision detection alone cannot distinguish it.
+        // If the target is still absent — a genuine new file, not a concurrent
+        // creator — a rename still publishes it atomically. Rename does not
+        // clobber on Windows (target-present → EPERM/EEXIST); on POSIX it
+        // would, so the no-replace guarantee degrades to best-effort there.
+        if (await targetAbsent(absolutePath, inspectPublicationTarget)) {
+          await rename(tempPath, absolutePath)
+        } else {
+          await throwGuardedCreateFailure(error, absolutePath, createIfAbsent.displayPath, inspectPublicationTarget)
+        }
       }
     } else if (platform === 'win32' && mode !== undefined) {
       // ...unchanged...
     } else {
       await rename(tempPath, absolutePath)
     }

配合一个辅助函数(放在 isENOENT / isENOTDIR 附近):

ts
/** True iff a lstat probe reports the path absent (no collision to preserve). */
async function targetAbsent(
  absolutePath: string,
  inspectPublicationTarget: (path: string) => Promise<BigIntStats>,
): Promise<boolean> {
  try {
    await inspectPublicationTarget(absolutePath)
    return false
  } catch (error: unknown) {
    return isENOENT(error) || isENOTDIR(error)
  }
}

语义取舍必须说清:renameWindows 上目标已存在时不会覆盖EPERM/EEXIST),所以 no-replace 语义在 Windows 上仍然成立;在 POSIX 上 rename 会覆盖,因此该降级把 no-replace 承诺降为 best-effort(仅在并发创建者恰好出现在 TOCTOU 窗口时)。而且该降级只在「目标缺失」时触发,真正的碰撞(目标存在)仍走 throwGuardedCreateFailureFS_NOT_OBSERVED,语义不变(#5704)。 2. 降级触发条件按 errno 收窄:更严格的写法是只在 errno 属于 ENOTSUP / EPERM / EACCES / EOPNOTSUPP显式排除 EEXIST(保住 no-clobber 并发保护)时降级。排除 EEXIST 是关键,否则并发保护会被顺手拆掉(#5704)。 3. 第二增量(只改善诊断,不修写入):降级之后新的 exFAT 写入不会再命中 EISDIR,但「目标存在 + 硬链接不支持」的残余边界仍会走 FS_IO_ERROR 并透传底层字符串。可选做法是在 throwGuardedCreateFailureFS_IO_ERROR 分支里,对「目标缺失且 code 是 EISDIR / EOPNOTSUPP / EPERM」的情形改写成「file system does not support hard links」,而不是让 EISDIR 原样漏出。这块只改善文案,建议放第二个增量#5704)。 4. 注入式单测(B 的验证方式)packages/fs/fs-local/tests/fsio.spec.ts 已有 linkFile / inspectPublicationTarget 注入挂钩(:766-801createIfAbsent 用例)。可以仿照加一条「硬链接不支持 → 降级 rename 成功」的用例——注入 linkFile{code:'EISDIR'}(模拟 libuv 误映射)、inspectPublicationTargetENOENT,断言写入成功且 staging 被清理:

ts
it('falls back to rename when hard links are unsupported (exFAT EISDIR)', async () => {
  const file = join(dir, 'a.txt')
  const hardlinkUnsupported = Object.assign(new Error('EISDIR'), { code: 'EISDIR' })
  await writeFileAtomic(file, 'ours', undefined, undefined, {
    linkFile: async () => { throw hardlinkUnsupported },
  }, { displayPath: file })
  expect(await readFile(file, 'utf8')).toBe('ours')
  expect((await readdir(dir)).filter(name => name.includes('.tmp'))).toEqual([])
})
  1. fix A:盘根 mkdir 兜底:这是与 exFAT link 可分离的修复,且直接封死「盘根下写文件」这一整类操作。做法是把 fsio.ts:543 的裸 mkdir 换成一个「mkdir 失败但目录已存在则容忍」的辅助:
diff
@@ packages/fs/fs-local/src/fsio.ts @@
-  const directory = dirname(absolutePath)
-  await mkdir(directory, { recursive: true })
+  const directory = dirname(absolutePath)
+  await ensureDirectory(directory)
ts
/** Mkdir the target's parent, tolerating an already-present directory even when
 *  the OS reports it as EPERM/EACCES (Windows drive roots do this — 'H:\' is
 *  an existing directory but mkdir returns EPERM, not EEXIST). */
async function ensureDirectory(directory: string): Promise<void> {
  try {
    await mkdir(directory, { recursive: true })
  } catch (error: unknown) {
    if (!(error instanceof Error) || ('code' in error && error.code !== 'EPERM' && error.code !== 'EACCES')) throw error
    // The parent exists as a directory — recursive:true already did its job;
    // only a drive-root (or ACL) "cannot create" that points at an existing
    // dir should be tolerated.
    let stats
    try {
      stats = await lstat(directory)
    } catch (statError: unknown) {
      throw error // dir vanished or unprobeable: keep the original mkdir failure
    }
    if (!stats.isDirectory()) throw error
  }
}

要点:recursive: true 已兜住 EEXIST这里只放宽「目录确实已存在」的情形,不掩盖其他 mkdir 失败lstat 确认不是目录、或路径已消失,就照旧抛出原始错误)。对应的单测可以注入 mkdirEPERMlstat 报 directory,断言写入成功(#5704)。 6. 上游那条要并行推进EISDIR 误映射是 libuv 侧的独立问题,与 harness 的修复解耦。报告者已把含 fs.linkSync 的最小复现(附 Python 对照 WinError 1 / ERROR_INVALID_FUNCTION)整理成英文 issue 提交到 nodejs/node,即 #65817。两件事一起修,才能让「不支持硬链接」这类失败在报错层面也不再骗人(#5704)。 7. 眼下的规避:对 exFAT / FAT32 卷改用脚本直接写入——普通文件写入不需要硬链接,在 exFAT 上完全正常,已实测可绕过且数据无损;写盘根那条则直接写到子目录里绕开。要批量往移动盘交付文件时,注意这类卷上的 write 工具目前不可用。插件本身的安装/卸载/更新走 DSH Plugin Hub 不受影响,因为那属于另一条写入路径;若你确实需要把工作区放在外接盘上,可先把文件写到内置 NTFS 卷再拷出去(#5704)。 8. 复测分工的建议:报告者有两块 exFAT 移动硬盘(4TB/2TB)+ 全 NTFS 内置盘的实机,愿意按「exFAT 新文件 / 已存在文件 / 盘根 / NTFS 回归」四场景逐项复测。他们明确不愿意手改本机安装,因此计划在官方发布后再升级验证——fix A 只需在 NTFS 盘根写个新文件即可核对,fix B 在同一轮里用那两块 exFAT 盘 sanity check(#5704)。

DSH plugin 排查注意事项

先别顺着 EISDIR 去查目录——目标当时根本不存在,真正要看的是 link 那一跳,而且这条限制按文件系统而非内外置盘切分。 十条要点:

  1. 别顺着 EISDIR 查目录:目标当时不存在,「目录」是误映射;真正要看的是 link 那一跳。
  2. 受影响的是一整类插件写入:只要 DSH插件 与 DeepSeek插件 通过 write 工具落盘,就会撞上这条限制,与插件自身的实现无关。
  3. 按文件系统切分,不看内外置:NTFS 全好、exFAT/FAT32 全挂。
  4. 先查是不是新文件:报告者的目标确实是新文件(走了 createIfAbsent),这也是降级逻辑的触发条件。
  5. EEXIST 必须排除:降级只能在「该卷不支持硬链接」时发生,否则会拆掉 no-replace 的并发保护。
  6. POSIX 上 rename 会覆盖:该降级把 no-replace 降为 best-effort,这是需要 maintainer 明确权衡的取舍。
  7. 盘根 EPERM 是另一个 bug:跨文件系统、与 exFAT 无关;recursive: true 不兜 EPERM
  8. errorMessage 逐字透传:应用层不做 errno 翻译,所以运行时的错码会直接漏给用户。
  9. 上游要单独提:libuv 的 ERROR_INVALID_FUNCTION → EISDIR 映射是独立缺陷(nodejs/node#65817)。
  10. 读取不受影响:只有写入失败,别据此误判为盘坏了。
DSH Plugin Hub 已安装插件列表:插件装卸更新走市场,不受外接盘写入限制影响

来源:Discussion #5704nodejs/node#65817Discussion #5432

常见问题

DSH plugin 报 EISDIR 说目标是目录,但我写的是个新文件,为什么?

EISDIR 在这条链路上是**误报**:DSH plugin 的 write 工具在 exFAT 卷上失败的真实原因是「该卷不支持硬链接」(exFAT / FAT32 都不支持,硬链接仅 NTFS 支持),而 write 后端的落盘方式恰好是「同目录临时文件 + link() 硬链接到最终路径」。这层失败发生在 link() 上,Node/libuv 把 exFAT 的失败呈现成了 EISDIR,而 Python 的 os.link 对照实验给出的真实错误是 WinError 1 函数不正确。所以目标路径当时**根本不存在**,与「目录」毫无关系(来源:Discussion #5704)。

为什么 DeepSeek Harness 只在移动硬盘和 U 盘上失败,内置盘不会?

DeepSeek Harness 的这条失败规律完全按文件系统切分,与盘是内置还是外接无关:**NTFS 全好,exFAT 全挂**。同一台机器上 C/D/F(NTFS)全部成功,E/H(两块 exFAT USB 移动硬盘)全部失败。因为 exFAT 出厂就是移动硬盘与 U 盘的常用格式,而它不支持硬链接——所以「往 U 盘/移动盘写文档」这类对普通用户并不罕见的 agent 场景会 100% 失败,读取则完全不受影响(来源:Discussion #5704)。

DSH plugin 向盘根目录写文件报的 EPERM mkdir 是同一个问题吗?

不是同一个问题:DSH plugin 向盘根写文件的 EPERM mkdir 是**另一个独立缺陷**,而且**跨文件系统**。写 X:\ 根目录下的文件时(C/D/E/F/H 五个盘符全实测过,NTFS 与 exFAT 都复现)报 EPERM: operation not permitted, mkdir 'D:\',原因是 fsio.ts:543mkdir(directory, { recursive: true }) 对**已经存在的盘根**仍然发起 mkdir;recursive: true 只兜 EEXIST,而 Windows 盘根的 mkdir 返回的是 EPERM 而不是 EEXIST,于是任何写盘根的操作都会直接爆掉(来源:Discussion #5704)。

DSH plugin 的这条写入失败,现在能怎么绕过?

对 exFAT / FAT32 卷,绕过 DSH plugin 这条写入路径、改用脚本直接写入即可——**普通文件写入不需要硬链接,在 exFAT 上完全正常**(已实测绕过、数据无损)。写盘根那条则直接写到子目录里绕开。根本修复依赖上游:Discussion #5704 里给出了参考 diff(link 失败降级为 rename、盘根 mkdir 兜底),报告者选择不改本机安装、等官方发布后再实机复测(来源:Discussion #5704)。

相关术语

atomic publication(原子发布)
atomic publication 是「先在同目录写一个临时文件、再通过硬链接或改名发布到最终路径」的落盘方式,使读者永远看不到半成品。write 后端的 `createIfAbsent` 分支用硬链接实现 no-replace 语义,因此在 exFAT 这类不支持硬链接的卷上必然失败。https://github.com/deepseek-ai/deepseek-harness/discussions/5704
libuv errno mis-mapping(libuv errno 误映射)
libuv errno 误映射是指 `uv_fs_link` 在 Windows/exFAT 上的失败被呈现为 `EISDIR`,而真实错误是 `ERROR_INVALID_FUNCTION`(WinError 1)。按预期它应映射为 `ENOSYS` 或 `EPERM` 一类「操作不被支持」,因此这是运行时的独立问题(已提 nodejs/node#65817)。https://github.com/nodejs/node/issues/65817
drive-root mkdir EPERM(盘根 mkdir EPERM)
盘根 mkdir EPERM 是指 Windows 上对已存在的盘根(如 `H:\`)调用 `mkdir(path, { recursive: true })` 会返回 `EPERM` 而非 `EEXIST`,而 `recursive: true` 只容忍 `EEXIST`。这让「写到盘根」成为一整类跨文件系统失败的操作。https://github.com/deepseek-ai/deepseek-harness/discussions/5704

来源