mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
fix(extensions): prevent corrupted UTF-8 in api.exec output (#108365)
* fix(extensions): preserve split UTF-8 in api.exec * test(agents): cover incomplete exec UTF-8 at EOF Co-authored-by: zhang-guiping <zhang.guiping@xydigit.com> --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
co-authored by
Peter Steinberger
parent
94b2774098
commit
3b51889c3b
@@ -152,6 +152,44 @@ describe("execCommand", () => {
|
||||
expect(result.stderrTruncatedChars).toBe(3);
|
||||
});
|
||||
|
||||
it("preserves UTF-8 characters split across stdout and stderr chunks", async () => {
|
||||
const child = createStubChild();
|
||||
const wait = createDeferred<number | null>();
|
||||
spawnMock.mockReturnValue(child);
|
||||
completionMock.mockReturnValue(wait.promise);
|
||||
const { execCommand } = await import("./exec.js");
|
||||
|
||||
const resultPromise = execCommand("cmd", [], "/tmp");
|
||||
const stdout = Buffer.from("stdout-😀-complete", "utf8");
|
||||
const stderr = Buffer.from("stderr-😀-complete", "utf8");
|
||||
child.stdout.emit("data", stdout.subarray(0, 9));
|
||||
child.stderr.emit("data", stderr.subarray(0, 9));
|
||||
child.stdout.emit("data", stdout.subarray(9));
|
||||
child.stderr.emit("data", stderr.subarray(9));
|
||||
wait.resolve(0);
|
||||
|
||||
const result = await resultPromise;
|
||||
expect(result.stdout).toBe("stdout-😀-complete");
|
||||
expect(result.stderr).toBe("stderr-😀-complete");
|
||||
});
|
||||
|
||||
it("flushes incomplete UTF-8 sequences when the process exits", async () => {
|
||||
const child = createStubChild();
|
||||
const wait = createDeferred<number | null>();
|
||||
spawnMock.mockReturnValue(child);
|
||||
completionMock.mockReturnValue(wait.promise);
|
||||
const { execCommand } = await import("./exec.js");
|
||||
|
||||
const resultPromise = execCommand("cmd", [], "/tmp");
|
||||
child.stdout.emit("data", Buffer.from([0xe2, 0x82]));
|
||||
child.stderr.emit("data", Buffer.from([0xf0, 0x9f, 0x98]));
|
||||
wait.resolve(0);
|
||||
|
||||
const result = await resultPromise;
|
||||
expect(result.stdout).toBe("�");
|
||||
expect(result.stderr).toBe("�");
|
||||
});
|
||||
|
||||
it("fails instead of silently truncating default exec output", async () => {
|
||||
const child = createStubChild();
|
||||
const wait = createDeferred<number | null>();
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* Shared command execution utilities for extensions and custom tools.
|
||||
*/
|
||||
|
||||
import { StringDecoder } from "node:string_decoder";
|
||||
import { sliceUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
|
||||
import { releaseChildProcessOutputAfterExit } from "../../process/child-process.js";
|
||||
import { spawnCommand } from "../../process/exec.js";
|
||||
@@ -42,6 +43,10 @@ type OutputCapture = {
|
||||
truncatedChars: number;
|
||||
};
|
||||
|
||||
function decodeCapturedOutput(decoder: StringDecoder, chunk: Buffer | string): string {
|
||||
return Buffer.isBuffer(chunk) ? decoder.write(chunk) : `${decoder.end()}${chunk}`;
|
||||
}
|
||||
|
||||
function clampMaxOutputChars(value: number | undefined): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
|
||||
return DEFAULT_OUTPUT_LIMIT_CHARS;
|
||||
@@ -95,6 +100,8 @@ export async function execCommand(
|
||||
|
||||
let stdout: OutputCapture = { text: "", truncatedChars: 0 };
|
||||
let stderr: OutputCapture = { text: "", truncatedChars: 0 };
|
||||
const stdoutDecoder = new StringDecoder("utf8");
|
||||
const stderrDecoder = new StringDecoder("utf8");
|
||||
let killed = false;
|
||||
let timeoutId: NodeJS.Timeout | undefined;
|
||||
let forceKillTimer: NodeJS.Timeout | undefined;
|
||||
@@ -122,6 +129,16 @@ export async function execCommand(
|
||||
if (options?.signal) {
|
||||
options.signal.removeEventListener("abort", killProcess);
|
||||
}
|
||||
const stdoutBeforeFlush = stdout.truncatedChars;
|
||||
stdout = appendCapturedOutput(stdout, stdoutDecoder.end(), maxOutputChars, truncateOutput);
|
||||
if (!truncateOutput && stdout.truncatedChars > stdoutBeforeFlush && !outputLimitExceeded) {
|
||||
outputLimitExceeded = "stdout";
|
||||
}
|
||||
const stderrBeforeFlush = stderr.truncatedChars;
|
||||
stderr = appendCapturedOutput(stderr, stderrDecoder.end(), maxOutputChars, truncateOutput);
|
||||
if (!truncateOutput && stderr.truncatedChars > stderrBeforeFlush && !outputLimitExceeded) {
|
||||
outputLimitExceeded = "stderr";
|
||||
}
|
||||
if (outputLimitExceeded) {
|
||||
stderr = appendCapturedOutput(
|
||||
stderr,
|
||||
@@ -184,7 +201,12 @@ export async function execCommand(
|
||||
|
||||
proc.stdout?.on("data", (data) => {
|
||||
const before = stdout.truncatedChars;
|
||||
stdout = appendCapturedOutput(stdout, data, maxOutputChars, truncateOutput);
|
||||
stdout = appendCapturedOutput(
|
||||
stdout,
|
||||
decodeCapturedOutput(stdoutDecoder, data),
|
||||
maxOutputChars,
|
||||
truncateOutput,
|
||||
);
|
||||
if (stdout.truncatedChars > before) {
|
||||
markOutputLimitExceeded("stdout");
|
||||
}
|
||||
@@ -192,7 +214,12 @@ export async function execCommand(
|
||||
|
||||
proc.stderr?.on("data", (data) => {
|
||||
const before = stderr.truncatedChars;
|
||||
stderr = appendCapturedOutput(stderr, data, maxOutputChars, truncateOutput);
|
||||
stderr = appendCapturedOutput(
|
||||
stderr,
|
||||
decodeCapturedOutput(stderrDecoder, data),
|
||||
maxOutputChars,
|
||||
truncateOutput,
|
||||
);
|
||||
if (stderr.truncatedChars > before) {
|
||||
markOutputLimitExceeded("stderr");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user