mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 08:51:38 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user