fix(file-transfer): keep bounded stderr tail UTF-16 safe end-to-end (#104151)

Co-authored-by: NIO <nocodet@mail.com>
This commit is contained in:
NIO
2026-07-12 01:06:17 +08:00
committed by GitHub
parent ab9a1c9a84
commit a498fc511e
6 changed files with 126 additions and 15 deletions

View File

@@ -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);
});
});

View File

@@ -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));
}

View File

@@ -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])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/.test(
result.reason,
),
).toBe(false);
}
});
});

View File

@@ -6,6 +6,7 @@ import type {
OpenClawPluginNodeInvokePolicyContext,
OpenClawPluginNodeInvokePolicyResult,
} from "openclaw/plugin-sdk/plugin-entry";
import { appendBoundedTextTail, projectBoundedTextTail } from "./append-bounded-text-tail.js";
import { appendFileTransferAudit, type FileTransferAuditOp } from "./audit.js";
import { consumeChildOutput } from "./child-output.js";
import {
@@ -22,6 +23,7 @@ const DIR_FETCH_MAX_ENTRIES = 5000;
const DIR_FETCH_ARCHIVE_LIST_TIMEOUT_MS = 30_000;
const DIR_FETCH_ARCHIVE_LIST_MAX_OUTPUT_BYTES = 32 * 1024 * 1024;
const DIR_FETCH_ARCHIVE_LIST_STDERR_TAIL_CHARS = 4096;
const DIR_FETCH_ARCHIVE_LIST_ERROR_STDERR_CHARS = 200;
type FileTransferCommand = FileTransferNodeInvokeCommand;
@@ -31,11 +33,6 @@ function asRecord(value: unknown): Record<string, unknown> {
: {};
}
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, unknown>): 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;
}

View File

@@ -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<typeof import("node:child_process")>();
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])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/.test(
result.reason,
),
).toBe(false);
}
} finally {
vi.doUnmock("node:child_process");
vi.resetModules();
}
});
});

View File

@@ -5,6 +5,10 @@ import fs from "node:fs/promises";
import path from "node:path";
import type { AnyAgentTool } from "openclaw/plugin-sdk/agent-harness-runtime";
import { saveMediaBuffer } from "openclaw/plugin-sdk/media-store";
import {
appendBoundedTextTail,
projectBoundedTextTail,
} from "../shared/append-bounded-text-tail.js";
import { appendFileTransferAudit } from "../shared/audit.js";
import { consumeChildOutput } from "../shared/child-output.js";
import { IMAGE_MIME_INLINE_SET, mimeFromExtension } from "../shared/mime.js";
@@ -32,6 +36,8 @@ const TAR_UNPACK_TIMEOUT_MS = 60_000;
const TAR_UNPACK_MAX_ENTRIES = 5000;
const TAR_LIST_OUTPUT_MAX_CHARS = 32 * 1024 * 1024;
const TAR_STDERR_TAIL_CHARS = 4096;
const TAR_ERROR_REASON_STDERR_CHARS = 200;
const TAR_UNPACK_ERROR_STDERR_CHARS = 300;
// Hard caps on uncompressed extraction. Defends against decompression-bomb
// archives that compress to <16MB but expand to gigabytes. Both caps are
@@ -40,11 +46,6 @@ const TAR_STDERR_TAIL_CHARS = 4096;
const DIR_FETCH_MAX_UNCOMPRESSED_BYTES = 64 * 1024 * 1024;
const DIR_FETCH_MAX_SINGLE_FILE_BYTES = 16 * 1024 * 1024;
function appendBoundedTextTail(current: string, chunk: Buffer, maxChars: number): string {
const next = current + chunk.toString();
return next.length > maxChars ? next.slice(-maxChars) : next;
}
async function listTarOutputLines<T>(input: {
args: string[];
label: string;
@@ -137,7 +138,10 @@ async function listTarOutputLines<T>(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<void> {
});
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();