You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
[Bug] edit/write tools silently drop the UTF-8 BOM from files that start with one
Component: @deepseek-ai/dsh-fs-local v0.2.1-rc.3 (local file backend for the edit / write tools)
Platform hit: Windows (PowerShell 5.1 / code page 936) — any other consumer that requires a BOM to recognize UTF-8 is affected the same way.
Summary
Any editText or full-overwrite writeText on a file whose first bytes are the UTF-8 BOM (EF BB BF) silently removes the BOM. Content is otherwise byte-identical. Files that did not start with a BOM are unaffected.
Real-world impact (reproduced on this machine): a Chinese PowerShell script with BOM runs fine under powershell.exe (the only PS 5.1 on Windows). After a single DSH edit call, the BOM is gone, PS 5.1 decodes the file as GBK, and the Chinese lines produce bizarre syntax/runtime errors. This is the default path on Chinese Windows systems, so any workflow that edits BOM-prefixed .ps1/.txt/.ini files through DSH breaks.
Minimal reproduction
Create demo.ps1 as UTF-8 with BOM:
# 演示脚本
$name = "张三"
Write-Host "你好,$name"
Baseline: powershell.exe -File demo.ps1 → prints 你好,张三. OK.
DSH edit: replace 张三 with 李四.
File head bytes are now 23 20 E6 BC 94 (# 演…) — the EF BB BF BOM is gone. powershell.exe -File demo.ps1 now fails with garbled-Chinese errors.
Artifacts on this machine: bom_repro/demo.ps1 (broken state), bom_repro/e2e_demo.ps1 (repaired, runs clean under PS 5.1), bom_repro/cases/A~E.
Root cause lib/index.js: editText is a read-modify-write. readForEdit decodes with TextDecoder, which strips a leading BOM from its output; the decoded string is written back as-is, so the BOM is deterministically lost. readForEdit does not even report that a BOM was present, so callers cannot compensate. writeText (full overwrite of an existing file) has the same problem: the caller-supplied string is written verbatim, and the BOM of the pre-existing file is discarded.
No prompt-side rule can work around this: the tool must preserve the BOM itself.
Patch (verified)
Additive, single file, no API changes. Four hunks:
Add UTF8_BOM constant, hasUtf8BomBytes(buffer), and a cheap 3-byte probeUtf8Bom(path, signal). readForEdit returns hasBom from the raw buffer. editText re-emits the BOM when the original had one. writeText probes the existing file's first 3 bytes and re-emits the BOM unless the new content already starts with one (no double BOM).
Verification against the patched module (bundled node, direct import): 7/7 PASS editText on BOM file: BOM preserved (EF BB BF), edit applied, CRLF preserved writeText overwrite of existing BOM file: BOM preserved editText on non-BOM file: no BOM added writeText create: no BOM added writeText with content already containing a BOM: no double BOM
Plus end-to-end: editText on a BOM-prefixed .ps1 through the patched backend, then powershell.exe -NoProfile -File under PS 5.1 → Chinese output correct, no errors (bom_repro/e2e_demo.ps1 → 你好,李四).
Test: bom_repro/test_patch.cjs (skips the win32 atomic-publish branch only because koffi is absent in the test workspace; the branch is orthogonal to BOM handling).
Diff
--- index.js (npm 0.2.1-rc.3, unpatched)+++ index.js (patched)@@ -344,6 +344,38 @@
/* v8 ignore next 2 -- TextDecoder({fatal}) only throws TypeError on invalid bytes; any other throw is an unreachable runtime fault. */
if (!(error instanceof TypeError)) throw error;
throw notTextError(verb, displayPath);
+ }+}+/**+* The UTF-8 BOM marker. TextDecoder strips a leading BOM from decoded output, so a+* file's BOM is tracked from the raw bytes and re-emitted on write-back; without this+* every edit or full rewrite of a BOM file silently drops its BOM.+*/+const UTF8_BOM = "\uFEFF";+function hasUtf8BomBytes(buffer) {+ return buffer.length >= 3 && buffer[0] === 0xef && buffer[1] === 0xbb && buffer[2] === 0xbf;+}+/**+* Cheap leading-BOM probe for an existing file: reads at most three bytes and never+* buffers the rest of the content.+* @param absolutePath - the file to probe.+* @param signal - aborts the probe (`FS_ABORTED`).+* @returns true when the file starts with the UTF-8 BOM.+*/+async function probeUtf8Bom(absolutePath, signal) {+ throwIfAborted(signal, "write");+ try {+ const handle = await open(absolutePath);+ try {+ const head = new Uint8Array(3);+ const { bytesRead } = await handle.read(head, 0, 3);+ return hasUtf8BomBytes(head.subarray(0, bytesRead));+ } finally {+ await handle.close();+ }+ } catch (error) {+ if (isAbortError(error)) throw new FsError("write aborted", "FS_ABORTED");+ throw error;
}
}
async function statRegularFile(target, verb, signal) {
@@ -626,7 +658,8 @@
const raw = decodeUtf8(buffer, "edit", displayPath);
return {
content: normalizeLineEndings(raw),
- lineEndings: detectLineEndings(raw)+ lineEndings: detectLineEndings(raw),+ hasBom: hasUtf8BomBytes(buffer)
};
}
/**
@@ -870,7 +903,9 @@
if (existing.version !== expected.version) throw new FsError(`cannot write "${target.displayPath}": file changed since it was read`, "FS_STALE_VERSION");
} else if (expected?.kind === "createIfAbsent" && existing) throw new FsError(`cannot overwrite existing "${target.displayPath}" without reading it first`, "FS_NOT_OBSERVED");
const before = existing !== null && Buffer.byteLength(content, "utf8") < this.config.diffBasisMaxBytes ? await readTextForDiff(target.targetKey, this.config.diffBasisMaxBytes, signal) : null;
- await writeFileAtomic(target.targetKey, content, existing?.mode, signal, this.internals, expected?.kind === "createIfAbsent" ? { displayPath: target.displayPath } : void 0);+ // Preserve the UTF-8 BOM an existing file carries (decoder output never includes one).+ const published = existing !== null && !content.startsWith(UTF8_BOM) && (await probeUtf8Bom(target.targetKey, signal)) ? UTF8_BOM + content : content;+ await writeFileAtomic(target.targetKey, published, existing?.mode, signal, this.internals, expected?.kind === "createIfAbsent" ? { displayPath: target.displayPath } : void 0);
const after = await probe(target.targetKey);
return {
operation: existing ? "update" : "create",
@@ -888,7 +923,9 @@
if (expected && existing.version !== expected.version) throw new FsError(`cannot edit "${target.displayPath}": file changed since it was read`, "FS_STALE_VERSION");
const original = await readForEdit(target.targetKey, target.displayPath, signal);
const edited = applyLiteralEdit(original.content, edit.oldString, edit.newString, edit.replaceAll, target.displayPath);
- const content = restoreLineEndings(edited.content, original.lineEndings);+ const restored = restoreLineEndings(edited.content, original.lineEndings);+ // Re-emit the UTF-8 BOM the original carried (the decoder strips it).+ const content = original.hasBom ? UTF8_BOM + restored : restored;
await writeFileAtomic(target.targetKey, content, existing.mode, signal, this.internals);
const after = await probe(target.targetKey);
return {
reacted with thumbs up emoji reacted with thumbs down emoji reacted with laugh emoji reacted with hooray emoji reacted with confused emoji reacted with heart emoji reacted with rocket emoji reacted with eyes emoji
Uh oh!
There was an error while loading. Please reload this page.
[Bug] edit/write tools silently drop the UTF-8 BOM from files that start with one
Component:
@deepseek-ai/dsh-fs-localv0.2.1-rc.3 (local file backend for theedit/writetools)Platform hit: Windows (PowerShell 5.1 / code page 936) — any other consumer that requires a BOM to recognize UTF-8 is affected the same way.
Summary
Any
editTextor full-overwritewriteTexton a file whose first bytes are the UTF-8 BOM (EF BB BF) silently removes the BOM. Content is otherwise byte-identical. Files that did not start with a BOM are unaffected.Real-world impact (reproduced on this machine): a Chinese PowerShell script with BOM runs fine under
powershell.exe(the only PS 5.1 on Windows). After a single DSHeditcall, the BOM is gone, PS 5.1 decodes the file as GBK, and the Chinese lines produce bizarre syntax/runtime errors. This is the default path on Chinese Windows systems, so any workflow that edits BOM-prefixed.ps1/.txt/.inifiles through DSH breaks.Minimal reproduction
Create
demo.ps1as UTF-8 with BOM:Baseline:
powershell.exe -File demo.ps1→ prints你好,张三. OK.DSH
edit: replace张三with李四.File head bytes are now
23 20 E6 BC 94(# 演…) — theEF BB BFBOM is gone.powershell.exe -File demo.ps1now fails with garbled-Chinese errors.Artifacts on this machine:
bom_repro/demo.ps1(broken state),bom_repro/e2e_demo.ps1(repaired, runs clean under PS 5.1),bom_repro/cases/A~E.Root cause
lib/index.js:editTextis a read-modify-write.readForEditdecodes withTextDecoder, which strips a leading BOM from its output; the decoded string is written back as-is, so the BOM is deterministically lost.readForEditdoes not even report that a BOM was present, so callers cannot compensate.writeText(full overwrite of an existing file) has the same problem: the caller-supplied string is written verbatim, and the BOM of the pre-existing file is discarded.No prompt-side rule can work around this: the tool must preserve the BOM itself.
Patch (verified)
Additive, single file, no API changes. Four hunks:
Add
UTF8_BOMconstant,hasUtf8BomBytes(buffer), and a cheap 3-byteprobeUtf8Bom(path, signal).readForEditreturnshasBomfrom the raw buffer.editTextre-emits the BOM when the original had one.writeTextprobes the existing file's first 3 bytes and re-emits the BOM unless the new content already starts with one (no double BOM).Verification against the patched module (bundled node, direct import): 7/7 PASS
editTexton BOM file: BOM preserved (EF BB BF), edit applied, CRLF preservedwriteTextoverwrite of existing BOM file: BOM preservededitTexton non-BOM file: no BOM addedwriteTextcreate: no BOM addedwriteTextwith content already containing a BOM: no double BOMPlus end-to-end:
editTexton a BOM-prefixed.ps1through the patched backend, thenpowershell.exe -NoProfile -Fileunder PS 5.1 → Chinese output correct, no errors (bom_repro/e2e_demo.ps1→你好,李四).Test:
bom_repro/test_patch.cjs(skips the win32 atomic-publish branch only becausekoffiis absent in the test workspace; the branch is orthogonal to BOM handling).Diff
All reactions