diff --git a/scripts/lib/cross-os-release-checks/process.ts b/scripts/lib/cross-os-release-checks/process.ts index 4f4b7465c0fb..d0443340d662 100644 --- a/scripts/lib/cross-os-release-checks/process.ts +++ b/scripts/lib/cross-os-release-checks/process.ts @@ -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, diff --git a/test/scripts/openclaw-cross-os-release-checks.test.ts b/test/scripts/openclaw-cross-os-release-checks.test.ts index 778649b34352..8ff75d6c9dc0 100644 --- a/test/scripts/openclaw-cross-os-release-checks.test.ts +++ b/test/scripts/openclaw-cross-os-release-checks.test.ts @@ -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: "�" }, + ])( + "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");