Skip to content

Commit c3f594d

Browse files
committed
feat(files): detect + round-trip UTF-16 encodings
We only handled UTF-8 BOM, so a Windows-authored UTF-16LE file was flagged binary by the null-byte heuristic and rejected outright — the agent couldn't read or edit it. Now stripBOM detects UTF-8 (EF BB BF), UTF-16LE (FF FE), and UTF-16BE (FE FF), decodes accordingly (BE is byte-swapped to LE for Node), and returns the encoding. isLikelyBinary checks for a UTF-16 BOM first so the expected null bytes don't false-positive. atomicWrite re-encodes with the detected encoding + matching BOM, so read → edit → write preserves the original format. The encoding threads through FileSnapshot and all three write tools (write_file, edit_file, multi_edit). 10 tests cover BOM detection, the binary-heuristic exemption, and a full UTF-16LE read→edit→write round-trip.
1 parent 551abb3 commit c3f594d

7 files changed

Lines changed: 163 additions & 11 deletions

File tree

src/tools/edit-file.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ export function createEditFile(ctx: ToolContext): AgentTool<typeof Params, EditF
6868
const { mtimeMs, size } = atomicWrite(absPath, nextContent, {
6969
hasBOM: snap.hasBOM,
7070
eol: snap.eol,
71+
encoding: snap.encoding,
7172
mode,
7273
});
7374

@@ -77,6 +78,7 @@ export function createEditFile(ctx: ToolContext): AgentTool<typeof Params, EditF
7778
mtimeMs,
7879
size,
7980
hasBOM: snap.hasBOM,
81+
encoding: snap.encoding,
8082
eol: snap.eol,
8183
isPartialView: false,
8284
storedAt: Date.now(),

src/tools/file-ops.test.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
2+
import { tmpdir } from "node:os";
3+
import { join } from "node:path";
4+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
5+
import { atomicWrite, isLikelyBinary, stripBOM } from "./file-ops.js";
6+
7+
describe("stripBOM", () => {
8+
it("decodes plain UTF-8 with no BOM", () => {
9+
const out = stripBOM(Buffer.from("hello", "utf8"));
10+
expect(out).toEqual({ content: "hello", hasBOM: false, encoding: "utf8" });
11+
});
12+
13+
it("strips a UTF-8 BOM", () => {
14+
const buf = Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), Buffer.from("hi", "utf8")]);
15+
const out = stripBOM(buf);
16+
expect(out.content).toBe("hi");
17+
expect(out.hasBOM).toBe(true);
18+
expect(out.encoding).toBe("utf8");
19+
});
20+
21+
it("decodes UTF-16LE with BOM", () => {
22+
const buf = Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from("héllo", "utf16le")]);
23+
const out = stripBOM(buf);
24+
expect(out.content).toBe("héllo");
25+
expect(out.hasBOM).toBe(true);
26+
expect(out.encoding).toBe("utf16le");
27+
});
28+
29+
it("decodes UTF-16BE with BOM (byte-swapped)", () => {
30+
const le = Buffer.from("wörld", "utf16le");
31+
const be = Buffer.from(le);
32+
be.swap16();
33+
const buf = Buffer.concat([Buffer.from([0xfe, 0xff]), be]);
34+
const out = stripBOM(buf);
35+
expect(out.content).toBe("wörld");
36+
expect(out.hasBOM).toBe(true);
37+
expect(out.encoding).toBe("utf16be");
38+
});
39+
});
40+
41+
describe("isLikelyBinary", () => {
42+
it("flags a buffer with embedded null bytes", () => {
43+
expect(isLikelyBinary(Buffer.from([0x68, 0x00, 0x69]))).toBe(true);
44+
});
45+
46+
it("does NOT flag a UTF-16LE text file as binary despite its null bytes", () => {
47+
const buf = Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from("plain ascii text", "utf16le")]);
48+
expect(isLikelyBinary(buf)).toBe(false);
49+
});
50+
51+
it("does NOT flag a UTF-16BE text file as binary", () => {
52+
const le = Buffer.from("plain ascii text", "utf16le");
53+
const be = Buffer.from(le);
54+
be.swap16();
55+
const buf = Buffer.concat([Buffer.from([0xfe, 0xff]), be]);
56+
expect(isLikelyBinary(buf)).toBe(false);
57+
});
58+
59+
it("treats plain UTF-8 as text", () => {
60+
expect(isLikelyBinary(Buffer.from("just text", "utf8"))).toBe(false);
61+
});
62+
});
63+
64+
describe("atomicWrite encoding round-trip", () => {
65+
let dir: string;
66+
beforeEach(() => {
67+
dir = mkdtempSync(join(tmpdir(), "fileops-"));
68+
});
69+
afterEach(() => {
70+
rmSync(dir, { recursive: true, force: true });
71+
});
72+
73+
it("round-trips a UTF-16LE file: read → edit → write preserves encoding + BOM", () => {
74+
const path = join(dir, "win.txt");
75+
// Author a UTF-16LE file with BOM, like a Windows editor would.
76+
const original = Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from("line one\nline two", "utf16le")]);
77+
require("node:fs").writeFileSync(path, original);
78+
79+
const decoded = stripBOM(readFileSync(path));
80+
expect(decoded.encoding).toBe("utf16le");
81+
82+
// Simulate an edit + write-back preserving the detected encoding.
83+
const edited = decoded.content.replace("two", "TWO");
84+
atomicWrite(path, edited, { hasBOM: decoded.hasBOM, encoding: decoded.encoding });
85+
86+
// Re-read raw: BOM intact, still UTF-16LE, content updated.
87+
const reread = readFileSync(path);
88+
expect(reread[0]).toBe(0xff);
89+
expect(reread[1]).toBe(0xfe);
90+
const redecoded = stripBOM(reread);
91+
expect(redecoded.encoding).toBe("utf16le");
92+
expect(redecoded.content).toBe("line one\nline TWO");
93+
});
94+
95+
it("writes plain UTF-8 by default", () => {
96+
const path = join(dir, "u8.txt");
97+
atomicWrite(path, "hello world", {});
98+
const buf = readFileSync(path);
99+
expect(buf[0]).not.toBe(0xff);
100+
expect(buf.toString("utf8")).toBe("hello world");
101+
});
102+
});

src/tools/file-ops.ts

Lines changed: 48 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -41,16 +41,45 @@ export function detectEol(content: string): "\n" | "\r\n" | "" {
4141
return idx > 0 && content[idx - 1] === "\r" ? "\r\n" : "\n";
4242
}
4343

44-
/** Strip a UTF-8 BOM if present and return the decoded string. */
45-
export function stripBOM(buf: Buffer): { content: string; hasBOM: boolean } {
44+
/** Text encodings we detect via BOM and round-trip on write. */
45+
export type FileEncoding = "utf8" | "utf16le" | "utf16be";
46+
47+
/**
48+
* Decode a file buffer, detecting + stripping a leading BOM. Handles
49+
* UTF-8 (EF BB BF), UTF-16LE (FF FE), and UTF-16BE (FE FF). UTF-16BE
50+
* has no native Node decoder, so we byte-swap to LE first. The detected
51+
* encoding is returned so the write path can re-encode identically — a
52+
* Windows-authored UTF-16 file stays UTF-16 after an edit.
53+
*/
54+
export function stripBOM(buf: Buffer): { content: string; hasBOM: boolean; encoding: FileEncoding } {
4655
if (buf.length >= 3 && buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf) {
47-
return { content: buf.subarray(3).toString("utf8"), hasBOM: true };
56+
return { content: buf.subarray(3).toString("utf8"), hasBOM: true, encoding: "utf8" };
57+
}
58+
if (buf.length >= 2 && buf[0] === 0xff && buf[1] === 0xfe) {
59+
return { content: buf.subarray(2).toString("utf16le"), hasBOM: true, encoding: "utf16le" };
4860
}
49-
return { content: buf.toString("utf8"), hasBOM: false };
61+
if (buf.length >= 2 && buf[0] === 0xfe && buf[1] === 0xff) {
62+
// UTF-16BE: swap byte pairs to LE so Node can decode.
63+
const body = buf.subarray(2);
64+
const swapped = Buffer.from(body);
65+
swapped.swap16();
66+
return { content: swapped.toString("utf16le"), hasBOM: true, encoding: "utf16be" };
67+
}
68+
return { content: buf.toString("utf8"), hasBOM: false, encoding: "utf8" };
5069
}
5170

52-
/** Heuristic null-byte scan over the first 8 KB. */
71+
/**
72+
* Heuristic null-byte scan over the first 8 KB. A UTF-16 BOM is checked
73+
* first — UTF-16 text is full of legitimate null bytes (every ASCII
74+
* char is `XX 00`), so the raw scan would false-positive every
75+
* Windows-authored UTF-16 file as "binary."
76+
*/
5377
export function isLikelyBinary(buf: Buffer): boolean {
78+
if (buf.length >= 2) {
79+
const b0 = buf[0];
80+
const b1 = buf[1];
81+
if ((b0 === 0xff && b1 === 0xfe) || (b0 === 0xfe && b1 === 0xff)) return false;
82+
}
5483
const slice = buf.subarray(0, Math.min(buf.length, 8192));
5584
for (let i = 0; i < slice.length; i++) {
5685
if (slice[i] === 0) return true;
@@ -165,6 +194,8 @@ export interface WriteOptions {
165194
hasBOM?: boolean;
166195
eol?: "\n" | "\r\n" | "";
167196
mode?: number;
197+
/** Re-encode with this encoding + matching BOM. Default "utf8". */
198+
encoding?: FileEncoding;
168199
}
169200

170201
/**
@@ -181,9 +212,18 @@ export function atomicWrite(
181212
const normalized =
182213
eol === "\r\n" ? content.replace(/\r?\n/g, "\r\n") : eol === "\n" ? content.replace(/\r\n/g, "\n") : content;
183214

184-
let buf = Buffer.from(normalized, "utf8");
185-
if (options.hasBOM) {
186-
buf = Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), buf]);
215+
const encoding = options.encoding ?? "utf8";
216+
let buf: Buffer;
217+
if (encoding === "utf16le") {
218+
const body = Buffer.from(normalized, "utf16le");
219+
buf = options.hasBOM ? Buffer.concat([Buffer.from([0xff, 0xfe]), body]) : body;
220+
} else if (encoding === "utf16be") {
221+
const le = Buffer.from(normalized, "utf16le");
222+
le.swap16(); // LE → BE
223+
buf = options.hasBOM ? Buffer.concat([Buffer.from([0xfe, 0xff]), le]) : le;
224+
} else {
225+
const body = Buffer.from(normalized, "utf8");
226+
buf = options.hasBOM ? Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), body]) : body;
187227
}
188228

189229
mkdirSync(dirname(absPath), { recursive: true });

src/tools/file-state-cache.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,10 @@ export interface FileSnapshot {
1818
mtimeMs: number;
1919
/** File size in bytes when we read it. */
2020
size: number;
21-
/** Whether the original bytes started with a UTF-8 BOM. */
21+
/** Whether the original bytes started with a BOM. */
2222
hasBOM: boolean;
23+
/** Text encoding detected on read; round-tripped on write. Default "utf8". */
24+
encoding?: "utf8" | "utf16le" | "utf16be";
2325
/** Detected newline: "\n" (LF), "\r\n" (CRLF), or "" (none). */
2426
eol: "\n" | "\r\n" | "";
2527
/** True if read used offset/limit; partial views can't be edited safely. */

src/tools/multi-edit.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ export function createMultiEdit(ctx: ToolContext): AgentTool<typeof Params, Mult
8080
const { mtimeMs, size } = atomicWrite(absPath, content, {
8181
hasBOM: snap.hasBOM,
8282
eol: snap.eol,
83+
encoding: snap.encoding,
8384
mode,
8485
});
8586

@@ -89,6 +90,7 @@ export function createMultiEdit(ctx: ToolContext): AgentTool<typeof Params, Mult
8990
mtimeMs,
9091
size,
9192
hasBOM: snap.hasBOM,
93+
encoding: snap.encoding,
9294
eol: snap.eol,
9395
isPartialView: false,
9496
storedAt: Date.now(),

src/tools/read-file.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ export function createReadFile(ctx: ToolContext): AgentTool<typeof Params, ReadF
116116
throw new BinaryFileError(params.path);
117117
}
118118

119-
const { content, hasBOM } = stripBOM(raw);
119+
const { content, hasBOM, encoding } = stripBOM(raw);
120120
const eol = detectEol(content);
121121
const allLines = content.split(/\r\n|\n/);
122122
// Trailing-newline files split into an extra empty trailing element; trim it for line counts.
@@ -144,6 +144,7 @@ export function createReadFile(ctx: ToolContext): AgentTool<typeof Params, ReadF
144144
mtimeMs: stat.mtimeMs,
145145
size: stat.size,
146146
hasBOM,
147+
encoding,
147148
eol,
148149
isPartialView,
149150
range: isPartialView ? { startLine: startIdx + 1, endLine: endIdx } : undefined,

src/tools/write-file.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,11 +48,13 @@ export function createWriteFile(ctx: ToolContext): AgentTool<typeof Params, Writ
4848
let hasBOM = false;
4949
let eol: "\n" | "\r\n" | "" = detectEol(params.content);
5050
let mode = 0o644;
51+
let encoding: "utf8" | "utf16le" | "utf16be" = "utf8";
5152

5253
if (exists) {
5354
const snap = validateForOverwrite(absPath, ctx.fileStateCache);
5455
hasBOM = snap.hasBOM;
5556
eol = snap.eol;
57+
encoding = snap.encoding ?? "utf8";
5658
mode = statSync(absPath).mode & 0o777;
5759
} else {
5860
// Bare-bytes hint: a new file's content may already start with BOM characters.
@@ -61,14 +63,15 @@ export function createWriteFile(ctx: ToolContext): AgentTool<typeof Params, Writ
6163
}
6264
}
6365

64-
const written = atomicWrite(absPath, params.content, { hasBOM, eol, mode });
66+
const written = atomicWrite(absPath, params.content, { hasBOM, eol, encoding, mode });
6567

6668
ctx.fileStateCache.record({
6769
path: absPath,
6870
content: params.content.charCodeAt(0) === 0xfeff ? params.content.slice(1) : params.content,
6971
mtimeMs: written.mtimeMs,
7072
size: written.size,
7173
hasBOM,
74+
encoding,
7275
eol,
7376
isPartialView: false,
7477
storedAt: Date.now(),

0 commit comments

Comments
 (0)