diff --git a/extensions/file-transfer/src/shared/append-bounded-text-tail.test.ts b/extensions/file-transfer/src/shared/append-bounded-text-tail.test.ts new file mode 100644 index 000000000000..1af4e72c2e04 --- /dev/null +++ b/extensions/file-transfer/src/shared/append-bounded-text-tail.test.ts @@ -0,0 +1,25 @@ +// File Transfer tests cover bounded stderr tail UTF-16 safety. +import { describe, expect, it } from "vitest"; +import { appendBoundedTextTail, projectBoundedTextTail } from "./append-bounded-text-tail.js"; + +const hasUnpairedUtf16Surrogate = (text: string): boolean => + /[\uD800-\uDBFF](?![\uDC00-\uDFFF])/.test(text); + +describe("appendBoundedTextTail", () => { + it("keeps stderr tail UTF-16 safe when the boundary bisects an emoji", () => { + const stderr = appendBoundedTextTail("prefix", Buffer.from("🤖tail"), 5); + + expect(stderr).toBe("tail"); + expect(hasUnpairedUtf16Surrogate(stderr)).toBe(false); + }); +}); + +describe("projectBoundedTextTail", () => { + it("keeps final error projection UTF-16 safe when the boundary bisects an emoji", () => { + const stderr = "p".repeat(5) + "🤖fail"; + const projected = projectBoundedTextTail(stderr, 5); + + expect(projected).toBe("fail"); + expect(hasUnpairedUtf16Surrogate(projected)).toBe(false); + }); +}); diff --git a/extensions/file-transfer/src/shared/append-bounded-text-tail.ts b/extensions/file-transfer/src/shared/append-bounded-text-tail.ts new file mode 100644 index 000000000000..a2e966714ef9 --- /dev/null +++ b/extensions/file-transfer/src/shared/append-bounded-text-tail.ts @@ -0,0 +1,15 @@ +// Bounded stderr tails must stay UTF-16 safe when tar/dir-fetch paths mention emoji filenames. +import { sliceUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; + +export function appendBoundedTextTail(current: string, chunk: Buffer, maxChars: number): string { + const next = current + chunk.toString(); + if (next.length <= maxChars) { + return next; + } + return sliceUtf16Safe(next, Math.max(0, next.length - maxChars)); +} + +// Final error projections reuse the ring-buffer tail; raw slice(-N) can still bisect emoji. +export function projectBoundedTextTail(text: string, maxChars: number): string { + return sliceUtf16Safe(text, Math.max(0, text.length - maxChars)); +} diff --git a/extensions/file-transfer/src/shared/node-invoke-policy.stream-errors.test.ts b/extensions/file-transfer/src/shared/node-invoke-policy.stream-errors.test.ts index 9866c8031e42..0655c535503c 100644 --- a/extensions/file-transfer/src/shared/node-invoke-policy.stream-errors.test.ts +++ b/extensions/file-transfer/src/shared/node-invoke-policy.stream-errors.test.ts @@ -70,4 +70,32 @@ describe("dir.fetch archive policy output lifecycle", () => { }), ).resolves.toEqual({ ok: true, entries: ["ok.txt"] }); }); + + it("surfaces UTF-16 safe tar stderr tail when archive listing fails with emoji at projection boundary", async () => { + const oldNoise = "n".repeat(250); + // Length 201: raw slice(-200) would start on the low surrogate of 🤖. + const recent = "🤖" + "f".repeat(199); + const spawnMock = mockTarSpawn((child) => { + child.stderr.emit("data", Buffer.from(oldNoise)); + child.stderr.emit("data", Buffer.from(recent)); + child.emit("close", 2); + }); + const { testing } = await importWithSpawn(spawnMock); + const { projectBoundedTextTail } = await import("./append-bounded-text-tail.js"); + + const result = await testing.listDirFetchArchiveEntries({ + tarBase64: Buffer.from("archive").toString("base64"), + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.reason).toContain(projectBoundedTextTail(recent, 200)); + expect(result.reason).toContain("f".repeat(199)); + expect(result.reason).not.toContain("🤖"); + expect( + /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? { : {}; } -function appendBoundedTextTail(current: string, chunk: Buffer, maxChars: number): string { - const next = current + chunk.toString(); - return next.length > maxChars ? next.slice(-maxChars) : next; -} - function readPath(params: Record): string { return typeof params.path === "string" ? params.path.trim() : ""; } @@ -424,7 +421,7 @@ async function listDirFetchArchiveEntries( finish({ ok: false, code: "ARCHIVE_ENTRIES_UNREADABLE", - reason: `tar -tzf exited ${code}: ${stderr.slice(-200)}`, + reason: `tar -tzf exited ${code}: ${projectBoundedTextTail(stderr, DIR_FETCH_ARCHIVE_LIST_ERROR_STDERR_CHARS)}`, }); return; } diff --git a/extensions/file-transfer/src/tools/dir-fetch-tool.test.ts b/extensions/file-transfer/src/tools/dir-fetch-tool.test.ts index 21d2940a7625..b6ee115e27af 100644 --- a/extensions/file-transfer/src/tools/dir-fetch-tool.test.ts +++ b/extensions/file-transfer/src/tools/dir-fetch-tool.test.ts @@ -5,6 +5,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { projectBoundedTextTail } from "../shared/append-bounded-text-tail.js"; import { validateTarUncompressedBudget } from "./dir-fetch-tool.js"; let tmpRoot: string; @@ -276,7 +277,7 @@ describe("dir.fetch tar validation", () => { const result = await testing.preValidateTarball(Buffer.from("x")); expect(result.ok).toBe(false); if (!result.ok) { - expect(result.reason).toContain(recent.slice(-200)); + expect(result.reason).toContain(projectBoundedTextTail(recent, 200)); expect(result.reason).not.toContain(oldNoise.slice(0, 40)); } } finally { @@ -284,4 +285,41 @@ describe("dir.fetch tar validation", () => { vi.resetModules(); } }); + + it("surfaces UTF-16 safe tar stderr tail when listing fails with emoji at projection boundary", async () => { + vi.resetModules(); + const oldNoise = "n".repeat(250); + // Length 201: raw slice(-200) would start on the low surrogate of 🤖. + const recent = "🤖" + "f".repeat(199); + vi.doMock("node:child_process", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + spawn: mockTarSpawn((child) => { + child.stderr.emit("data", Buffer.from(oldNoise)); + child.stderr.emit("data", Buffer.from(recent)); + child.emit("close", 2); + }), + }; + }); + + try { + const { testing } = await import("./dir-fetch-tool.js"); + const result = await testing.preValidateTarball(Buffer.from("x")); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.reason).toContain(projectBoundedTextTail(recent, 200)); + expect(result.reason).toContain("f".repeat(199)); + expect(result.reason).not.toContain("🤖"); + expect( + /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? maxChars ? next.slice(-maxChars) : next; -} - async function listTarOutputLines(input: { args: string[]; label: string; @@ -137,7 +138,10 @@ async function listTarOutputLines(input: { return; } if (code !== 0) { - finish({ ok: false, reason: `${input.label} exited ${code}: ${stderr.slice(-200)}` }); + finish({ + ok: false, + reason: `${input.label} exited ${code}: ${projectBoundedTextTail(stderr, TAR_ERROR_REASON_STDERR_CHARS)}`, + }); return; } if (pending) { @@ -350,7 +354,7 @@ export async function validateTarUncompressedBudget( if (code !== 0) { finish({ ok: false, - reason: `tar uncompressed budget validation exited ${code}: ${stderr.slice(-200)}`, + reason: `tar uncompressed budget validation exited ${code}: ${projectBoundedTextTail(stderr, TAR_ERROR_REASON_STDERR_CHARS)}`, }); return; } @@ -455,7 +459,11 @@ async function unpackTar(tarBuffer: Buffer, destDir: string): Promise { }); child.on("close", (code) => { if (code !== 0) { - fail(new Error(`tar unpack exited ${code}: ${stderrOut.slice(-300)}`)); + fail( + new Error( + `tar unpack exited ${code}: ${projectBoundedTextTail(stderrOut, TAR_UNPACK_ERROR_STDERR_CHARS)}`, + ), + ); return; } succeed();