Files
openclaw/src/process/exec-output.ts
mushuiyu886 4e11da2872 fix(process): UTF-8 command output corrupts at Windows byte limits (#105274)
* fix(process): preserve byte-capped UTF-8 output on Windows

* fix(process): skip codepage probe for UTF-8 output

Co-authored-by: 杨浩宇0668001029 <yang.haoyu@xydigit.com>

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-07-18 22:44:12 +01:00

211 lines
6.5 KiB
TypeScript

import process from "node:process";
import { StringDecoder } from "node:string_decoder";
import { expectDefined } from "@openclaw/normalization-core";
import { truncateUtf8Suffix } from "../utils/utf8-truncate.js";
export type CommandOutputCaptureMode = "head" | "tail" | "discard";
export type CommandOutputStream = "stdout" | "stderr";
export type CommandOutputCaptureOption =
| CommandOutputCaptureMode
| { stdout?: CommandOutputCaptureMode; stderr?: CommandOutputCaptureMode };
export type CommandOutputLimitOption =
| boolean
| { stdout?: boolean; stderr?: boolean; combined?: boolean };
export type PreserveOutputLine = (line: string, stream: CommandOutputStream) => boolean;
export type CapturedOutputBuffers = {
chunks: Buffer[];
bytes: number;
truncatedBytes: number;
preservedLines: string[];
decoder: StringDecoder;
pendingLine: string;
};
const DEFAULT_COMMAND_OUTPUT_MAX_BYTES = 16 * 1024 * 1024;
export const MAX_PRESERVED_PENDING_LINE_BYTES = 8 * 1024;
export function createCapturedOutputBuffers(): CapturedOutputBuffers {
return {
chunks: [],
bytes: 0,
truncatedBytes: 0,
preservedLines: [],
decoder: new StringDecoder("utf8"),
pendingLine: "",
};
}
function normalizeMaxOutputBytes(value: number | undefined): number {
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
return DEFAULT_COMMAND_OUTPUT_MAX_BYTES;
}
return Math.max(1, Math.floor(value));
}
export function resolveMaxOutputBytes(
value: number | { stdout?: number; stderr?: number } | undefined,
stream: CommandOutputStream,
): number {
return normalizeMaxOutputBytes(typeof value === "number" ? value : value?.[stream]);
}
export function resolveOutputCapture(
value: CommandOutputCaptureOption | undefined,
stream: CommandOutputStream,
): CommandOutputCaptureMode {
return (typeof value === "string" ? value : value?.[stream]) ?? "tail";
}
export function shouldTerminateOnOutputLimit(
value: CommandOutputLimitOption | undefined,
limit: CommandOutputStream | "combined",
): boolean {
return typeof value === "boolean" ? value : value?.[limit] === true;
}
export function appendCapturedOutput(
capture: CapturedOutputBuffers,
chunk: Buffer | string,
maxBytes: number,
mode: CommandOutputCaptureMode,
): void {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
if (mode === "discard") {
capture.truncatedBytes += buffer.byteLength;
return;
}
if (mode === "head") {
const remaining = Math.max(0, maxBytes - capture.bytes);
if (remaining > 0) {
const kept = buffer.subarray(0, remaining);
capture.chunks.push(kept);
capture.bytes += kept.byteLength;
}
capture.truncatedBytes += Math.max(0, buffer.byteLength - remaining);
return;
}
if (buffer.byteLength >= maxBytes) {
capture.chunks = [Buffer.from(buffer.subarray(buffer.byteLength - maxBytes))];
capture.truncatedBytes += capture.bytes + buffer.byteLength - maxBytes;
capture.bytes = maxBytes;
return;
}
capture.chunks.push(buffer);
capture.bytes += buffer.byteLength;
while (capture.bytes > maxBytes && capture.chunks.length > 0) {
const first = expectDefined(capture.chunks[0], "chunks entry at 0");
const overflow = capture.bytes - maxBytes;
if (first.byteLength <= overflow) {
capture.chunks.shift();
capture.bytes -= first.byteLength;
capture.truncatedBytes += first.byteLength;
} else {
capture.chunks[0] = Buffer.from(first.subarray(overflow));
capture.bytes -= overflow;
capture.truncatedBytes += overflow;
}
}
}
function trimTruncatedUtf8Boundary(
buffer: Buffer,
mode: CommandOutputCaptureMode,
truncatedBytes: number,
forceUtf8: boolean,
): Buffer {
if (truncatedBytes === 0 || buffer.length === 0 || (process.platform === "win32" && !forceUtf8)) {
return buffer;
}
if (mode === "tail") {
let start = 0;
while (start < buffer.length && (expectDefined(buffer[start], "buffer byte") & 0xc0) === 0x80) {
start += 1;
}
return buffer.subarray(start);
}
const decoder = new TextDecoder("utf-8", { fatal: true });
for (let removed = 0; removed <= 3 && removed <= buffer.length; removed += 1) {
const end = buffer.length - removed;
try {
decoder.decode(buffer.subarray(0, end));
return buffer.subarray(0, end);
} catch {
// A UTF-8 code point is at most four bytes; try the preceding boundary.
}
}
return buffer;
}
export function finalizeCapturedOutput(
capture: CapturedOutputBuffers,
mode: CommandOutputCaptureMode,
forceUtf8 = false,
): Buffer {
const buffered = Buffer.concat(capture.chunks, capture.bytes);
const trimmed = trimTruncatedUtf8Boundary(buffered, mode, capture.truncatedBytes, forceUtf8);
capture.truncatedBytes += buffered.byteLength - trimmed.byteLength;
return trimmed;
}
function trimPreservedPendingLine(value: string, maxBytes: number): string {
return truncateUtf8Suffix(value, maxBytes);
}
export function appendPreservedOutputLines(params: {
capture: CapturedOutputBuffers;
chunk: Buffer | string;
stream: CommandOutputStream;
preserveOutputLine?: PreserveOutputLine;
maxPreservedOutputLines: number;
maxPendingLineBytes: number;
}): void {
if (!params.preserveOutputLine || params.maxPreservedOutputLines <= 0) {
return;
}
const text = Buffer.isBuffer(params.chunk)
? params.capture.decoder.write(params.chunk)
: params.chunk;
if (!text) {
return;
}
const lines = (params.capture.pendingLine + text).split(/\r?\n/);
params.capture.pendingLine = trimPreservedPendingLine(
lines.pop() ?? "",
params.maxPendingLineBytes,
);
for (const line of lines) {
if (
params.capture.preservedLines.length < params.maxPreservedOutputLines &&
params.preserveOutputLine(line, params.stream)
) {
params.capture.preservedLines.push(line);
}
}
}
export function flushPreservedOutputLine(params: {
capture: CapturedOutputBuffers;
stream: CommandOutputStream;
preserveOutputLine?: PreserveOutputLine;
maxPreservedOutputLines: number;
maxPendingLineBytes: number;
}): void {
if (!params.preserveOutputLine || params.maxPreservedOutputLines <= 0) {
return;
}
const trailing = trimPreservedPendingLine(
params.capture.pendingLine + params.capture.decoder.end(),
params.maxPendingLineBytes,
);
params.capture.pendingLine = "";
if (
trailing &&
params.capture.preservedLines.length < params.maxPreservedOutputLines &&
params.preserveOutputLine(trailing, params.stream)
) {
params.capture.preservedLines.push(trailing);
}
}