Fix crash when bash mode outputs binary data

Sanitize shell output by removing Unicode Format characters and lone
surrogates that crash string-width. This fixes crashes when running
commands like curl that download binary files.
This commit is contained in:
Mario Zechner 2025-12-08 23:26:58 +01:00
parent a054fecd11
commit ad42ebf5f5
5 changed files with 141 additions and 111 deletions

View file

@ -1,5 +1,15 @@
# Changelog
## [Unreleased]
### Added
- `/debug` command now includes agent messages as JSONL in the output
### Fixed
- Fix crash when bash command outputs binary data (e.g., `curl` downloading a video file)
## [0.14.1] - 2025-12-08
### Fixed

View file

@ -87,6 +87,21 @@ export function getShellConfig(): { shell: string; args: string[] } {
return cachedShellConfig;
}
/**
* Sanitize binary output for display/storage.
* Removes characters that crash string-width or cause display issues:
* - Control characters (except tab, newline, carriage return)
* - Lone surrogates
* - Unicode Format characters (crash string-width due to a bug)
*/
export function sanitizeBinaryOutput(str: string): string {
// Fast path: use regex to remove problematic characters
// - \p{Format}: Unicode format chars like \u0601 that crash string-width
// - \p{Surrogate}: Lone surrogates from invalid UTF-8
// - Control chars except \t \n \r
return str.replace(/[\p{Format}\p{Surrogate}]/gu, "").replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, "");
}
/**
* Kill a process and all its children (cross-platform)
*/

View file

@ -65,6 +65,7 @@ export class BashExecutionComponent extends Container {
appendOutput(chunk: string): void {
// Strip ANSI codes and normalize line endings
// Note: binary data is already sanitized in tui-renderer.ts executeBashCommand
const clean = stripAnsi(chunk).replace(/\r\n/g, "\n").replace(/\r/g, "\n");
// Append to output lines

View file

@ -38,7 +38,7 @@ import {
SUMMARY_SUFFIX,
} from "../session-manager.js";
import type { SettingsManager } from "../settings-manager.js";
import { getShellConfig, killProcessTree } from "../shell.js";
import { getShellConfig, killProcessTree, sanitizeBinaryOutput } from "../shell.js";
import { expandSlashCommand, type FileSlashCommand, loadSlashCommands } from "../slash-commands.js";
import { getEditorTheme, getMarkdownTheme, onThemeChange, setTheme, theme } from "../theme/theme.js";
import { DEFAULT_MAX_BYTES, type TruncationResult, truncateTail } from "../tools/truncate.js";
@ -2055,6 +2055,9 @@ export class TuiRenderer {
return `[${idx}] (w=${vw}) ${escaped}`;
}),
"",
"=== Agent messages (JSONL) ===",
...this.agent.state.messages.map((msg) => JSON.stringify(msg)),
"",
].join("\n");
fs.mkdirSync(path.dirname(debugLogPath), { recursive: true });
@ -2139,10 +2142,10 @@ export class TuiRenderer {
this.bashProcess = child;
// Track output for truncation
const chunks: Buffer[] = [];
let chunksBytes = 0;
const maxChunksBytes = DEFAULT_MAX_BYTES * 2;
// Track sanitized output for truncation
const outputChunks: string[] = [];
let outputBytes = 0;
const maxOutputBytes = DEFAULT_MAX_BYTES * 2;
// Temp file for large output
let tempFilePath: string | undefined;
@ -2152,30 +2155,32 @@ export class TuiRenderer {
const handleData = (data: Buffer) => {
totalBytes += data.length;
// Sanitize once at the source: strip ANSI, replace binary garbage, normalize newlines
const text = sanitizeBinaryOutput(stripAnsi(data.toString())).replace(/\r/g, "");
// Start writing to temp file if exceeds threshold
if (totalBytes > DEFAULT_MAX_BYTES && !tempFilePath) {
const id = randomBytes(8).toString("hex");
tempFilePath = join(tmpdir(), `pi-bash-${id}.log`);
tempFileStream = createWriteStream(tempFilePath);
for (const chunk of chunks) {
for (const chunk of outputChunks) {
tempFileStream.write(chunk);
}
}
if (tempFileStream) {
tempFileStream.write(data);
tempFileStream.write(text);
}
// Keep rolling buffer
chunks.push(data);
chunksBytes += data.length;
while (chunksBytes > maxChunksBytes && chunks.length > 1) {
const removed = chunks.shift()!;
chunksBytes -= removed.length;
// Keep rolling buffer of sanitized text
outputChunks.push(text);
outputBytes += text.length;
while (outputBytes > maxOutputBytes && outputChunks.length > 1) {
const removed = outputChunks.shift()!;
outputBytes -= removed.length;
}
// Stream to component (strip ANSI)
const text = stripAnsi(data.toString()).replace(/\r/g, "");
// Stream to component
onChunk(text);
};
@ -2189,9 +2194,8 @@ export class TuiRenderer {
this.bashProcess = null;
// Combine buffered chunks for truncation
const fullBuffer = Buffer.concat(chunks);
const fullOutput = stripAnsi(fullBuffer.toString("utf-8")).replace(/\r/g, "");
// Combine buffered chunks for truncation (already sanitized)
const fullOutput = outputChunks.join("");
const truncationResult = truncateTail(fullOutput);
// code === null means killed (cancelled)