fix(scripts): keep cross-os command output tails UTF-8 safe (#109152)

* fix(scripts): keep cross-os command output tails UTF-8 safe

* fix(scripts): avoid possibly-undefined buffer index in cross-os UTF-8 tail skip

Buffer bracket indexing returns number | undefined under strict
TypeScript. Use .at() with an explicit undefined guard to satisfy
check-test-types.

* fix: add curly braces to single-line if statements (lint)

ESLint curly rule requires { } around all if/while/for bodies.

* fix(scripts): preserve streamed UTF-8 command output tails

Co-authored-by: 陈志强0668000989 <chen.zhiqiang1@xydigit.com>

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
wings1029
2026-07-30 00:51:13 +08:00
committed by GitHub
parent 030dbe9a7e
commit a4aaa1cd74
2 changed files with 141 additions and 8 deletions

View File

@@ -13,6 +13,7 @@ import {
type Socket,
} from "node:net";
import { dirname } from "node:path";
import { StringDecoder } from "node:string_decoder";
import { buildCmdExeCommandLine, resolveWindowsCmdExePath } from "../../windows-cmd-helpers.mjs";
import { resolveWindowsTaskkillPath } from "../windows-taskkill.mjs";
import type {
@@ -235,10 +236,23 @@ function resolveCommandCaptureLimit(options: CommandOptions) {
return Math.max(1, Math.floor(value));
}
function decodeBoundedUtf8Tail(buffer: Buffer, maxBytes: number): string {
const tail = buffer.subarray(Math.max(0, buffer.byteLength - maxBytes));
let start = 0;
while (start < tail.byteLength) {
const byte = tail[start];
if (byte === undefined || (byte & 0xc0) !== 0x80) {
break;
}
start += 1;
}
return tail.subarray(start).toString("utf8");
}
function appendBoundedCommandOutput(current: string, chunk: Uint8Array | string, maxBytes: number) {
const chunkBuffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
if (chunkBuffer.byteLength >= maxBytes) {
return chunkBuffer.subarray(chunkBuffer.byteLength - maxBytes).toString("utf8");
return decodeBoundedUtf8Tail(chunkBuffer, maxBytes);
}
const currentBuffer = Buffer.from(current);
@@ -249,7 +263,7 @@ function appendBoundedCommandOutput(current: string, chunk: Uint8Array | string,
const currentTailBytes = maxBytes - chunkBuffer.byteLength;
const currentTail = currentBuffer.subarray(currentBuffer.byteLength - currentTailBytes);
return Buffer.concat([currentTail, chunkBuffer], maxBytes).toString("utf8");
return decodeBoundedUtf8Tail(Buffer.concat([currentTail, chunkBuffer]), maxBytes);
}
export async function runCommand(
@@ -282,6 +296,8 @@ export async function runCommandInvocation(
});
const activeChildTree = registerActiveChildProcessTree(child);
const logStream = createWriteStream(options.logPath, { flags: "a" });
const stdoutDecoder = new StringDecoder("utf8");
const stderrDecoder = new StringDecoder("utf8");
let stdout = "";
let stderr = "";
let timedOut = false;
@@ -387,14 +403,20 @@ export async function runCommandInvocation(
logStream.write(`${new Date().toISOString()} start command=${commandLabel}\n`);
child.stdout?.on("data", (chunk) => {
const text = chunk.toString();
stdout = appendBoundedCommandOutput(stdout, chunk, maxCapturedOutputBytes);
logStream.write(text);
stdout = appendBoundedCommandOutput(
stdout,
stdoutDecoder.write(chunk),
maxCapturedOutputBytes,
);
logStream.write(chunk);
});
child.stderr?.on("data", (chunk) => {
const text = chunk.toString();
stderr = appendBoundedCommandOutput(stderr, chunk, maxCapturedOutputBytes);
logStream.write(text);
stderr = appendBoundedCommandOutput(
stderr,
stderrDecoder.write(chunk),
maxCapturedOutputBytes,
);
logStream.write(chunk);
});
child.on("error", (error) => {
@@ -415,6 +437,8 @@ export async function runCommandInvocation(
return;
}
activeChildTree.unregister();
stdout = appendBoundedCommandOutput(stdout, stdoutDecoder.end(), maxCapturedOutputBytes);
stderr = appendBoundedCommandOutput(stderr, stderrDecoder.end(), maxCapturedOutputBytes);
finalize(() => {
const result = {
exitCode: exitCode ?? 1,

View File

@@ -1413,6 +1413,115 @@ describe("scripts/openclaw-cross-os-release-checks", () => {
});
});
it("keeps multibyte command output and error tails within the byte budget", async () => {
await withTempDirAsync("openclaw-cross-os-run-command-utf8-tail-", async (dir) => {
const logPath = join(dir, "command.log");
const result = await runCommand(
process.execPath,
["-e", "process.stdout.write('a😀bbbb'); process.stderr.write('a😀cccc');"],
{ cwd: dir, env: process.env, logPath, maxOutputBytes: 6 },
);
expect(result.stdout).toBe("bbbb");
expect(result.stderr).toBe("cccc");
expect(Buffer.byteLength(result.stdout, "utf8")).toBeLessThanOrEqual(6);
expect(Buffer.byteLength(result.stderr, "utf8")).toBeLessThanOrEqual(6);
const log = readFileSync(logPath, "utf8");
expect(log).toContain("a😀bbbb");
expect(log).toContain("a😀cccc");
});
});
it("keeps rolling multibyte command output and error tails within the byte budget", async () => {
await withTempDirAsync("openclaw-cross-os-run-command-utf8-rolling-", async (dir) => {
const logPath = join(dir, "command.log");
const script = [
"process.stdout.write('a😀');",
"process.stderr.write('a😀');",
"setTimeout(() => { process.stdout.write('bbbb'); process.stderr.write('cccc'); }, 25);",
].join("");
const result = await runCommand(process.execPath, ["-e", script], {
cwd: dir,
env: process.env,
logPath,
maxOutputBytes: 7,
});
expect(result.stdout).toBe("bbbb");
expect(result.stderr).toBe("cccc");
expect(Buffer.byteLength(result.stdout, "utf8")).toBeLessThanOrEqual(7);
expect(Buffer.byteLength(result.stderr, "utf8")).toBeLessThanOrEqual(7);
});
});
it.each(["stdout", "stderr"] as const)(
"preserves a UTF-8 character split across real %s chunks and its full log",
async (stream) => {
await withTempDirAsync("openclaw-cross-os-run-command-utf8-split-", async (dir) => {
const logPath = join(dir, "command.log");
const script = [
`process.${stream}.write('A');`,
`process.${stream}.write(Buffer.from([0xf0, 0x9f]));`,
`setTimeout(() => { process.${stream}.write(Buffer.from([0x98, 0x80])); process.${stream}.write('Z'); }, 25);`,
].join("");
const result = await runCommand(process.execPath, ["-e", script], {
cwd: dir,
env: process.env,
logPath,
maxOutputBytes: 64,
});
expect(result[stream]).toBe("A😀Z");
expect(readFileSync(logPath, "utf8")).toContain("A😀Z");
});
},
);
it.each([1, 2, 3])(
"never exceeds a %i-byte command output budget with a truncated UTF-8 character",
async (maxOutputBytes) => {
await withTempDirAsync("openclaw-cross-os-run-command-utf8-budget-", async (dir) => {
const logPath = join(dir, "command.log");
const result = await runCommand(
process.execPath,
["-e", "process.stdout.write('😀'); process.stderr.write('😀');"],
{ cwd: dir, env: process.env, logPath, maxOutputBytes },
);
expect(result.stdout).toBe("");
expect(result.stderr).toBe("");
expect(Buffer.byteLength(result.stdout, "utf8")).toBeLessThanOrEqual(maxOutputBytes);
expect(Buffer.byteLength(result.stderr, "utf8")).toBeLessThanOrEqual(maxOutputBytes);
});
},
);
it.each([
{ maxOutputBytes: 1, expected: "" },
{ maxOutputBytes: 2, expected: "" },
{ maxOutputBytes: 3, expected: "<22>" },
])(
"bounds incomplete UTF-8 command output to $maxOutputBytes bytes",
async ({ maxOutputBytes, expected }) => {
await withTempDirAsync("openclaw-cross-os-run-command-utf8-incomplete-", async (dir) => {
const logPath = join(dir, "command.log");
const result = await runCommand(
process.execPath,
[
"-e",
"process.stdout.write(Buffer.from([0xf0])); process.stderr.write(Buffer.from([0xf0]));",
],
{ cwd: dir, env: process.env, logPath, maxOutputBytes },
);
expect(result.stdout).toBe(expected);
expect(result.stderr).toBe(expected);
expect(Buffer.byteLength(result.stdout, "utf8")).toBeLessThanOrEqual(maxOutputBytes);
expect(Buffer.byteLength(result.stderr, "utf8")).toBeLessThanOrEqual(maxOutputBytes);
});
},
);
it("flushes command logs before resolving", async () => {
await withTempDirAsync("openclaw-cross-os-run-command-flush-", async (dir) => {
const logPath = join(dir, "flush.log");