Fix EISDIR on exFAT drives in DeepSeek Harness write tool

TroubleshootingPublished 2026-09-12Author: DeepSeek Plugin Market
DeepSeek HarnessDSH pluginEISDIRexFATEPERM mkdir
The DeepSeek Harness write tool fails every time on exFAT volumes with EISDIR even for a new file: the volume has no hard links and libuv mis-maps the failure.

If you are on Windows and the write tool reports EISDIR: illegal operation on a directory, link '...tmpdir\test.md.tmp' -> '...test.md' while writing to an external drive or USB stick, and that target is actually a brand-new file — the path is not wrong; the volume does not support hard links. The write backend publishes files as "temp file in the same directory + link() to the final path", exFAT / FAT32 do not support hard links, so that step always fails; EISDIR is the Node/libuv runtime's mis-mapping of that failure (a Python os.link cross-check gives the real error, WinError 1 (incorrect function)). There is also a separate defect: writing to a drive root fails because mkdir returns EPERM, which is cross-filesystem and unrelated to exFAT.

DSH plugin: two errors — EISDIR on exFAT, and EPERM mkdir at a drive root

The two errors differ in trigger, blast radius, and fix — separate them first. Concretely:

  1. Error one: writing a new file on an exFAT volume always fails
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'

Two problems stack: ① it always fails on exFAT — the same operation succeeds on every NTFS volume and fails on every exFAT volume; ② the error code misleads — the real cause is "hard link operations are unsupported on this volume", but the error is rendered as EISDIR ("the target is a directory") even though the target path does not exist at all. The reporter spent a while chasing paths before an independent control experiment isolated link (#5704). 2. The measured matrix: it splits by filesystem, not by internal vs external

Target driveLabelFilesystemwrite into a subdirectoryResult
C:OSNTFS✅ works
D:softNTFS✅ works
F:dataNTFS✅ works
E:Backup Plus (USB drive)exFATEISDIR
H:sdzyq (USB drive)exFATEISDIR

The split is exact: all NTFS works, all exFAT fails (#5704). 3. The root-cause control experiment (bypassing the harness, testing link directly): running os.link(src, dst) via Python in PowerShell against each volume gives H: (exFAT) → OSError [WinError 1] incorrect function, and D: (NTFS) → OK. exFAT does not support hard links; the link syscall is rejected on that volume, and the backend happens to use link for publication, so it necessarily fails (#5704). 4. Error two: writing to any drive root reports EPERM

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

Writing a file under X:\ (all five drive letters C/D/E/F/H tested) reports EPERM mkdir 'X:\'. The parent-directory-ensuring logic before the write (ensureParentDir) appears to still issue a mkdir for the already existing drive root. Its blast radius is smaller than EISDIR's (writing to a drive root is rare in daily use), but it is a cross-filesystem defect in its own right (#5704). 5. Environment and blast radius: reported on Windows 11 Home (10.0.22631) with DSH plugin 0.1.2-rc.1 (npm install), with source-level verification against d347e7039 (v0.1.3-alpha.1, then master). The blast radius is file writes on all exFAT / FAT32 volumes — external drives, USB sticks, some NAS and cloud-sync directories; reads are unaffected. Because exFAT is the factory format on many external drives and writing documents to a USB or external disk is a common agent scenario, this deserves a fix rather than a documentation note (#5704).

The chain has three stages: the app layer picks a policy, the filesystem layer publishes with link, and the runtime layer mislabels the failure EISDIR. Layer by layer:

  1. The write backend really is "temp file + hard link": the write tool in packages/fs/tool-fs/src/write.ts calls ctx.fs.writeText(target, content, intent, ...). The "single-slot decision" lives at write.ts:108-110 — policy plugins produce createIfAbsent / replaceIfVersion, with a bare default of undefined. When intent.kind === 'createIfAbsent', it takes the fs-local hard-link no-replace publication path (#5704).
  2. Exact locations of the staging directory and the link: in packages/fs/fs-local/src/index.ts:188,210, createIfAbsent with an absent target passes { displayPath } into writeFileAtomic. packages/fs/fs-local/src/fsio.ts:546 builds the staging directory as `.${basename}.${process.pid}.${randomUUID()}.tmpdir`, matching the reported H:\workbuddy\test\.test.md.<pid>.<uuid>.tmpdir\test.md.tmp exactly; :580 calls linkFile(tempPath, absolutePath) (only in the createIfAbsent !== undefined branch). This also confirms the reporter's inference from the error signature was accurate (#5704).
  3. EISDIR comes from the Node/libuv runtime layer, not the app layer: errorMessage at fsio.ts:44-46 passes error.message through verbatim, with no errno translation at all:
ts
function errorMessage(error: unknown): string {
  return error instanceof Error ? error.message : String(error)
}

So when :515 does throw new FsError(..., 'FS_IO_ERROR', { cause: error }), the reported string is simply the underlying Node/libuv link() failure message. That is, libuv surfaces link()'s failure on exFAT as EISDIR (consistent with the node -e "require('fs').linkSync(...)" reproduction), while the real errno is WinError 1 (incorrect function). This deserves a separate report to Node.js/libuv upstream: in the win32 mapping of uv_fs_link, ERROR_INVALID_FUNCTION should map to ENOSYS/EPERM rather than EISDIR (#5704). 4. A minimal reproduction that needs no harness: any exFAT volume demonstrates the boundary — Node reports the distorted code while Python gives the real cause:

powershell
node -e "require('fs').linkSync('X:\\\\a.tmp','X:\\\\b.tmp')"
#    → reports EISDIR (distorted error code)
python -c "import os; os.link(r'X:\a.tmp', r'X:\b.tmp')"
#    → WinError 1 incorrect function (the real cause)
  1. The app layer already knows link errno is unstable: the comment at fsio.ts:496-497 explicitly says it must distinguish "collision vs missing hard-link support" ("Link errno values vary by platform and filesystem.") — so the project is aware that link errno differs across filesystems, but it only distinguishes 'target already exists' and never falls back for 'this volume does not support hard links at all'. The same pattern appears in fs-sandbox / session-persistence-jsonl's materializePosix (#5432, where a link failure on HarmonyOS hmdfs blocks everything wholesale) (#5704).
  2. The mechanism behind drive-root EPERM: at fsio.ts:543, const directory = dirname(absolutePath) (which equals H:\ when writing a file at the drive root), immediately followed by await mkdir(directory, { recursive: true }). That directory is an already existing drive root; mkdir(..., { recursive: true }) should tolerate "already exists" (EEXIST), but on a Windows drive root mkdir returns EPERM instead of EEXISTrecursive: true only covers EEXIST, not EPERM, and Node/libuv does not special-case drive roots. So any write or edit targeting a drive root blows up right there, regardless of filesystem (reproducing on all five drive letters on both NTFS and exFAT is the proof) (#5704).
  3. It belongs to a family: both errors fit the "atomic publication primitives assume universal FS capabilities" family — the same axis already has #3577 (Windows rename EXDEV, storage-json/writeAtomic) and #5432 (materializePosix's bare link with no fallback), with #5704 as the third member, additionally exposing the independent misleading error-code mapping problem. All three share the same fix direction: a publication primitive should recognise "this volume / this FS does not support this operation" and fall back to an available path, instead of leaking the raw errno (#5704).

The two fixes are independent and can land separately: A handles drive-root mkdir EPERM (cross-filesystem), B handles the exFAT link fallback. Specifically:

  1. Fix B: fall back to rename when link fails. The idea: the link() in the createIfAbsent branch fails on exFAT while the target is still absent (not an EEXIST collision caused by a concurrent creator), which means the filesystem does not support hard links (libuv mis-maps it as EISDIR). Falling back to rename still publishes the temp file atomically, so new file writes succeed. Reference 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)
     }

With a helper (placed near 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)
  }
}

The semantic trade-off must be stated plainly: rename does not overwrite when the target exists on Windows (EPERM/EEXIST), so no-replace still holds there; on POSIX rename overwrites, so this fallback degrades the no-replace promise to best-effort (only when a concurrent creator appears in the TOCTOU window). And the fallback fires only when the target is absent; a genuine collision (target present) still goes through throwGuardedCreateFailure's FS_NOT_OBSERVED, leaving the semantics unchanged (#5704). 2. Narrow the fallback by errno: a stricter version fires only when the errno is ENOTSUP / EPERM / EACCES / EOPNOTSUPP and explicitly excludes EEXIST (preserving no-clobber concurrency protection). Excluding EEXIST is the key — otherwise the concurrency guard gets torn down as collateral damage (#5704). 3. Second increment (diagnostics only, does not fix writes): after the fallback, new exFAT writes no longer hit EISDIR, but the residual boundary of "target exists + hard links unsupported" still goes through FS_IO_ERROR and passes the underlying string through. One option is to rewrite the FS_IO_ERROR branch of throwGuardedCreateFailure so that, when the target is absent and the code is EISDIR / EOPNOTSUPP / EPERM, it reports "file system does not support hard links" instead of leaking EISDIR. This only improves the message and belongs in a second increment (#5704). 4. Injection test (how B is verified): packages/fs/fs-local/tests/fsio.spec.ts already has linkFile / inspectPublicationTarget injection hooks (the createIfAbsent case at :766-801). Add a "hard links unsupported → falls back to rename and succeeds" case: inject linkFile throwing {code:'EISDIR'} (simulating the libuv mis-mapping) and inspectPublicationTarget throwing ENOENT, then assert the write succeeds and staging is cleaned up:

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: tolerate drive-root mkdir. This is separable from the exFAT link fix and directly unblocks the whole "write to a drive root" class. Replace the bare mkdir at fsio.ts:543 with a helper that tolerates a failure when the directory already exists:
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
  }
}

Key point: recursive: true already covers EEXIST, so this only relaxes the genuinely-already-exists case and does not mask other mkdir failures (if lstat shows it is not a directory, or the path has vanished, the original error is rethrown). The corresponding test injects mkdir throwing EPERM and lstat reporting a directory, then asserts the write succeeds (#5704). 6. The upstream side should proceed in parallel: the EISDIR mis-mapping is an independent libuv problem, decoupled from the harness fix. The reporter compiled the minimal fs.linkSync reproduction (with the Python cross-check showing WinError 1 / ERROR_INVALID_FUNCTION) into an English issue filed at nodejs/node, namely #65817. Fixing both is what stops "hard links unsupported" failures from lying at the error level as well (#5704). 7. Workarounds for now: on exFAT / FAT32 volumes, write through a script — plain file writes need no hard links and work fine on exFAT, verified as a working detour with no data loss; for the drive-root case, simply write into a subdirectory. When delivering files in bulk to external drives, remember the write tool is currently unusable on such volumes. Installing, removing, and updating plugins through DSH Plugin Hub is unaffected because that is a different write path; if you genuinely need your workspace on an external drive, write files to an internal NTFS volume and copy them across (#5704). 8. A sensible retest split: the reporter has two exFAT external drives (4TB / 2TB) plus all-NTFS internal disks, and is willing to retest the four scenarios — exFAT new file / exFAT existing file / drive root / NTFS regression. They explicitly prefer not to hand-patch their local install, so they plan to upgrade and verify against an official release — fix A is easy to recheck (write a new file at an NTFS drive root) and fix B can be sanity-checked on the two exFAT volumes in the same pass (#5704).

DSH plugin troubleshooting notes

Do not chase directories because of EISDIR — the target did not exist at all, and the hop actually worth inspecting is link; the limit also splits by filesystem rather than by internal versus external drives. Ten points to keep in mind when a DeepSeek Harness plugin writes to a removable volume:

  1. Do not chase directories because of EISDIR: the target did not exist; "directory" is the mis-mapping. The real hop to inspect is link.
  2. The blast radius is a whole class of plugin writes: any DSH plugin that lands files through the write tool hits this limit, regardless of how the plugin itself is implemented.
  3. Split by filesystem, not by internal/external: NTFS all works, exFAT/FAT32 all fails.
  4. Check whether the target is new: the reporter's target was indeed new (taking the createIfAbsent branch), which is also the fallback's trigger condition.
  5. EEXIST must be excluded: the fallback may only fire for "this volume does not support hard links", or it tears down the no-replace concurrency guard.
  6. rename overwrites on POSIX: the fallback degrades no-replace to best-effort, a trade-off maintainers must weigh explicitly.
  7. Drive-root EPERM is a separate bug: cross-filesystem and unrelated to exFAT; recursive: true does not cover EPERM.
  8. errorMessage passes through verbatim: the app layer performs no errno translation, so runtime error codes reach users raw.
  9. Report upstream separately: libuv's ERROR_INVALID_FUNCTION → EISDIR mapping is an independent defect (nodejs/node#65817).
  10. Reads are unaffected: only writes fail, so do not conclude the drive is broken.
DSH Plugin Hub installed plugins: plugin install, removal and updates run through the market, unaffected by external-drive write limits

Sources: Discussion #5704, nodejs/node#65817, Discussion #5432.

FAQ

Why does this DSH plugin write report EISDIR when the target is a brand-new file?

This DSH plugin write failure reports EISDIR as a **false report**. The real cause is that the volume does not support hard links (exFAT and FAT32 do not; only NTFS does), while the write backend happens to publish files as 'temp file in the same directory + link() to the final path'. That failure happens inside link(), and Node/libuv surfaces exFAT's failure as EISDIR, whereas a Python os.link cross-check gives the real error: WinError 1 (incorrect function). The target path did not exist at all and has nothing to do with a directory (Source: Discussion #5704).

Why does DeepSeek Harness fail on external drives and USB sticks, but not internal disks?

DeepSeek Harness splits exactly by filesystem, not by whether a drive is internal or external: **NTFS always works, exFAT always fails**. On one machine C/D/F (NTFS) all succeeded while E/H (two exFAT USB drives) both failed. exFAT ships as the factory format on many external drives and USB sticks and does not support hard links — so 'write a document to a USB or external disk', hardly a rare agent scenario, fails 100% of the time. Reads are completely unaffected (Source: Discussion #5704).

Is the EPERM mkdir when writing to a drive root the same DSH plugin problem?

No — for a DSH plugin this is a **separate defect**, and it is **cross-filesystem**: writing a file under X:\ (all five drive letters C/D/E/F/H were tested, on both NTFS and exFAT) reports EPERM: operation not permitted, mkdir 'D:\'. The cause is fsio.ts:543, where mkdir(directory, { recursive: true }) still issues a mkdir for an **already existing drive root**; recursive: true tolerates only EEXIST, while a Windows drive root returns EPERM instead. Any write to a drive root therefore blows up there (Source: Discussion #5704).

What DSH plugin workaround can I use right now?

On exFAT / FAT32 volumes a DSH plugin user can write through a script instead — **plain file writes need no hard links and work fine on exFAT** (verified as a workaround, with no data loss). For the drive-root case, simply write into a subdirectory. The real fix depends on upstream: Discussion #5704 contains a reference diff (link failure falls back to rename, drive-root mkdir tolerated), and the reporter chose not to patch their local install, preferring to retest against an official release (Source: Discussion #5704).

Related Terms

atomic publication
Atomic publication is writing a temporary file in the same directory and then 'publishing' it to the final path via a hard link or a rename, so readers never observe a half-written file. The write backend's `createIfAbsent` branch uses a hard link to get no-replace semantics, which guarantees failure on volumes such as exFAT that do not support hard links.https://github.com/deepseek-ai/deepseek-harness/discussions/5704
libuv errno mis-mapping
The libuv errno mis-mapping is a failed `uv_fs_link` on Windows/exFAT being surfaced as `EISDIR`, while the real error is `ERROR_INVALID_FUNCTION` (WinError 1); it should map to `ENOSYS` or `EPERM` — an 'operation not supported' class error. This is an independent runtime problem, filed as nodejs/node#65817.https://github.com/nodejs/node/issues/65817
drive-root mkdir EPERM
The drive-root mkdir EPERM is what happens on Windows when `mkdir(path, { recursive: true })` is called on an existing drive root (such as `H:\`): it returns `EPERM` rather than `EEXIST`, and `recursive: true` tolerates only `EEXIST`. This makes 'write to a drive root' an entire class of cross-filesystem failures.https://github.com/deepseek-ai/deepseek-harness/discussions/5704

Sources