File size: 4,466 Bytes
3144483 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 | import { sliceUtf16Safe, truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import { sanitizeForLog, stripAnsi } from "../../packages/terminal-core/src/ansi.js";
import { sanitizeTerminalText } from "../../packages/terminal-core/src/safe-text.js";
import type { SpawnResult } from "./exec-result.js";
import type { runCommandBuffered } from "./exec.js";
const COMMAND_ERROR_NAME = "CommandExecutionError";
// Older serialized failures have no marker; recognize their explicit termination header.
const COMMAND_ERROR_HEADER_RE =
/^(?:Error:\s*)?.+ failed \((?:timed out after [\d.]+ seconds|timed out waiting for output|output limit exceeded|exit code -?\d+|terminated|signal SIG[A-Z0-9]+)(?:; signal SIG[A-Z0-9]+)?\):?$/u;
export function formatCommandErrorForUser(text: string): string | undefined {
const lines = text.trim().split(/\r?\n/u);
const header = lines[0] ?? "";
if (!header.startsWith(`${COMMAND_ERROR_NAME}: `) && !COMMAND_ERROR_HEADER_RE.test(header)) {
return undefined;
}
const tail = lines.length > 1 ? lines.at(-1)?.trim() : undefined;
const summary = [header, tail].filter(Boolean).join(" ").replace(/\s+/gu, " ").trim();
return summary.length > 500 ? `${truncateUtf16Safe(summary, 497)}...` : summary;
}
export function formatCommandOutput(output: string | Buffer, maxChars = 800): string {
// CR redraws replace the current frame; trim before making edge tabs visible.
// Anchor each CR run/frame so unmatched runs do not repeatedly scan their suffixes.
const lines = stripAnsi(output.toString())
.replace(/(^|[^\r])\r+(?=\n|$)/g, "$1")
.replace(/(^|\n)[^\n]*\r/g, "$1")
.trim()
.split("\n");
const tail = lines.slice(-12).map(sanitizeTerminalText).join("\n");
const omitted = lines.length > 12 || tail.length > maxChars;
return `${omitted ? "…\n" : ""}${sliceUtf16Safe(tail, Math.max(0, tail.length - maxChars))}`;
}
/** Use an operation label, never argv that may contain credentials. */
export function formatCommandResult(command: string, result: SpawnResult): string {
const label = truncateUtf16Safe(sanitizeForLog(command.replace(/[\r\n]+/g, " ")), 256);
const termination = result.outputLimitExceeded ? "output-limit" : result.termination;
const signal = result.signal ? `, signal=${result.signal}` : "";
const killed = result.killed ? ", killed=true" : "";
const status = result.code === 0 ? "exited" : "failed";
return [
`${label} ${status} (code=${result.code}, termination=${termination}${signal}${killed})`,
...(["stderr", "stdout"] as const).flatMap((stream) => {
const output = formatCommandOutput(result[stream]);
return output ? [`${stream}: ${output}`] : [];
}),
].join("\n");
}
export function createCommandError(
command: string,
result: SpawnResult | Awaited<ReturnType<typeof runCommandBuffered>>,
options: { timeoutMs: number },
): Error {
const stderr = formatCommandOutput(result.stderr, 2_000);
const stdout = formatCommandOutput(result.stdout, 2_000);
let detail = stderr || stdout;
if (stderr && stdout) {
const budget = 2_000 - "stderr: \nstdout: ".length;
const first = Math.min(stderr.length, Math.max(Math.ceil(budget / 2), budget - stdout.length));
// Pure suffix fitting replaces any earlier marker; normalization runs only once.
const tail = (output: string, limit: number) =>
output.length <= limit ? output : `…\n${sliceUtf16Safe(output, 2 - limit)}`;
detail = `stderr: ${tail(stderr, first)}\nstdout: ${tail(stdout, budget - first)}`;
}
const signal = result.signal ? `signal ${result.signal}` : "";
const limited =
"outputLimitExceeded" in result && result.outputLimitExceeded ? "output limit exceeded" : "";
const exitReason = limited || (!signal && result.code !== null ? `exit code ${result.code}` : "");
const primary = {
timeout: `timed out after ${options.timeoutMs / 1000} seconds`,
"no-output-timeout": "timed out waiting for output",
"output-limit": "output limit exceeded",
exit: exitReason,
error: exitReason,
signal: limited || (!signal ? "terminated" : ""),
}[result.termination];
const reason = [primary, signal].filter(Boolean).join("; ");
const label = truncateUtf16Safe(stripAnsi(command).replace(/[\r\n]+/g, " "), 256);
const error = new Error(
`${label} failed${reason ? ` (${reason})` : ""}${detail ? `:\n${detail}` : ""}`,
);
error.name = COMMAND_ERROR_NAME;
return error;
}
|