mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-20 17:31:35 +00:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user