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:
xingzhou
2026-07-15 15:59:59 -07:00
committed by GitHub
co-authored by Peter Steinberger
parent 94b2774098
commit 3b51889c3b
2 changed files with 67 additions and 2 deletions
+38
View File
@@ -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>();
+29 -2
View File
@@ -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");
}