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

@ -4499,8 +4499,8 @@ export const MODELS = {
reasoning: false, reasoning: false,
input: ["text", "image"], input: ["text", "image"],
cost: { cost: {
input: 0.049999999999999996, input: 0.03,
output: 0.22, output: 0.11,
cacheRead: 0, cacheRead: 0,
cacheWrite: 0, cacheWrite: 0,
}, },
@ -4983,9 +4983,9 @@ export const MODELS = {
contextWindow: 32768, contextWindow: 32768,
maxTokens: 4096, maxTokens: 4096,
} satisfies Model<"openai-completions">, } satisfies Model<"openai-completions">,
"anthropic/claude-3.5-haiku": { "anthropic/claude-3.5-haiku-20241022": {
id: "anthropic/claude-3.5-haiku", id: "anthropic/claude-3.5-haiku-20241022",
name: "Anthropic: Claude 3.5 Haiku", name: "Anthropic: Claude 3.5 Haiku (2024-10-22)",
api: "openai-completions", api: "openai-completions",
provider: "openrouter", provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1", baseUrl: "https://openrouter.ai/api/v1",
@ -5000,9 +5000,9 @@ export const MODELS = {
contextWindow: 200000, contextWindow: 200000,
maxTokens: 8192, maxTokens: 8192,
} satisfies Model<"openai-completions">, } satisfies Model<"openai-completions">,
"anthropic/claude-3.5-haiku-20241022": { "anthropic/claude-3.5-haiku": {
id: "anthropic/claude-3.5-haiku-20241022", id: "anthropic/claude-3.5-haiku",
name: "Anthropic: Claude 3.5 Haiku (2024-10-22)", name: "Anthropic: Claude 3.5 Haiku",
api: "openai-completions", api: "openai-completions",
provider: "openrouter", provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1", baseUrl: "https://openrouter.ai/api/v1",
@ -5034,23 +5034,6 @@ export const MODELS = {
contextWindow: 200000, contextWindow: 200000,
maxTokens: 8192, maxTokens: 8192,
} satisfies Model<"openai-completions">, } satisfies Model<"openai-completions">,
"mistralai/ministral-3b": {
id: "mistralai/ministral-3b",
name: "Mistral: Ministral 3B",
api: "openai-completions",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
reasoning: false,
input: ["text"],
cost: {
input: 0.04,
output: 0.04,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 131072,
maxTokens: 4096,
} satisfies Model<"openai-completions">,
"mistralai/ministral-8b": { "mistralai/ministral-8b": {
id: "mistralai/ministral-8b", id: "mistralai/ministral-8b",
name: "Mistral: Ministral 8B", name: "Mistral: Ministral 8B",
@ -5068,6 +5051,23 @@ export const MODELS = {
contextWindow: 131072, contextWindow: 131072,
maxTokens: 4096, maxTokens: 4096,
} satisfies Model<"openai-completions">, } satisfies Model<"openai-completions">,
"mistralai/ministral-3b": {
id: "mistralai/ministral-3b",
name: "Mistral: Ministral 3B",
api: "openai-completions",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
reasoning: false,
input: ["text"],
cost: {
input: 0.04,
output: 0.04,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 131072,
maxTokens: 4096,
} satisfies Model<"openai-completions">,
"nvidia/llama-3.1-nemotron-70b-instruct": { "nvidia/llama-3.1-nemotron-70b-instruct": {
id: "nvidia/llama-3.1-nemotron-70b-instruct", id: "nvidia/llama-3.1-nemotron-70b-instruct",
name: "NVIDIA: Llama 3.1 Nemotron 70B Instruct", name: "NVIDIA: Llama 3.1 Nemotron 70B Instruct",
@ -5153,23 +5153,6 @@ export const MODELS = {
contextWindow: 32768, contextWindow: 32768,
maxTokens: 4096, maxTokens: 4096,
} satisfies Model<"openai-completions">, } satisfies Model<"openai-completions">,
"cohere/command-r-plus-08-2024": {
id: "cohere/command-r-plus-08-2024",
name: "Cohere: Command R+ (08-2024)",
api: "openai-completions",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
reasoning: false,
input: ["text"],
cost: {
input: 2.5,
output: 10,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 128000,
maxTokens: 4000,
} satisfies Model<"openai-completions">,
"cohere/command-r-08-2024": { "cohere/command-r-08-2024": {
id: "cohere/command-r-08-2024", id: "cohere/command-r-08-2024",
name: "Cohere: Command R (08-2024)", name: "Cohere: Command R (08-2024)",
@ -5187,6 +5170,23 @@ export const MODELS = {
contextWindow: 128000, contextWindow: 128000,
maxTokens: 4000, maxTokens: 4000,
} satisfies Model<"openai-completions">, } satisfies Model<"openai-completions">,
"cohere/command-r-plus-08-2024": {
id: "cohere/command-r-plus-08-2024",
name: "Cohere: Command R+ (08-2024)",
api: "openai-completions",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
reasoning: false,
input: ["text"],
cost: {
input: 2.5,
output: 10,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 128000,
maxTokens: 4000,
} satisfies Model<"openai-completions">,
"sao10k/l3.1-euryale-70b": { "sao10k/l3.1-euryale-70b": {
id: "sao10k/l3.1-euryale-70b", id: "sao10k/l3.1-euryale-70b",
name: "Sao10K: Llama 3.1 Euryale 70B v2.2", name: "Sao10K: Llama 3.1 Euryale 70B v2.2",
@ -5238,6 +5238,23 @@ export const MODELS = {
contextWindow: 128000, contextWindow: 128000,
maxTokens: 16384, maxTokens: 16384,
} satisfies Model<"openai-completions">, } satisfies Model<"openai-completions">,
"meta-llama/llama-3.1-8b-instruct": {
id: "meta-llama/llama-3.1-8b-instruct",
name: "Meta: Llama 3.1 8B Instruct",
api: "openai-completions",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
reasoning: false,
input: ["text"],
cost: {
input: 0.02,
output: 0.03,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 131072,
maxTokens: 16384,
} satisfies Model<"openai-completions">,
"meta-llama/llama-3.1-405b-instruct": { "meta-llama/llama-3.1-405b-instruct": {
id: "meta-llama/llama-3.1-405b-instruct", id: "meta-llama/llama-3.1-405b-instruct",
name: "Meta: Llama 3.1 405B Instruct", name: "Meta: Llama 3.1 405B Instruct",
@ -5272,23 +5289,6 @@ export const MODELS = {
contextWindow: 131072, contextWindow: 131072,
maxTokens: 4096, maxTokens: 4096,
} satisfies Model<"openai-completions">, } satisfies Model<"openai-completions">,
"meta-llama/llama-3.1-8b-instruct": {
id: "meta-llama/llama-3.1-8b-instruct",
name: "Meta: Llama 3.1 8B Instruct",
api: "openai-completions",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
reasoning: false,
input: ["text"],
cost: {
input: 0.02,
output: 0.03,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 131072,
maxTokens: 16384,
} satisfies Model<"openai-completions">,
"mistralai/mistral-nemo": { "mistralai/mistral-nemo": {
id: "mistralai/mistral-nemo", id: "mistralai/mistral-nemo",
name: "Mistral: Mistral Nemo", name: "Mistral: Mistral Nemo",
@ -5476,23 +5476,6 @@ export const MODELS = {
contextWindow: 128000, contextWindow: 128000,
maxTokens: 64000, maxTokens: 64000,
} satisfies Model<"openai-completions">, } satisfies Model<"openai-completions">,
"meta-llama/llama-3-8b-instruct": {
id: "meta-llama/llama-3-8b-instruct",
name: "Meta: Llama 3 8B Instruct",
api: "openai-completions",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
reasoning: false,
input: ["text"],
cost: {
input: 0.03,
output: 0.06,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 8192,
maxTokens: 16384,
} satisfies Model<"openai-completions">,
"meta-llama/llama-3-70b-instruct": { "meta-llama/llama-3-70b-instruct": {
id: "meta-llama/llama-3-70b-instruct", id: "meta-llama/llama-3-70b-instruct",
name: "Meta: Llama 3 70B Instruct", name: "Meta: Llama 3 70B Instruct",
@ -5510,6 +5493,23 @@ export const MODELS = {
contextWindow: 8192, contextWindow: 8192,
maxTokens: 16384, maxTokens: 16384,
} satisfies Model<"openai-completions">, } satisfies Model<"openai-completions">,
"meta-llama/llama-3-8b-instruct": {
id: "meta-llama/llama-3-8b-instruct",
name: "Meta: Llama 3 8B Instruct",
api: "openai-completions",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
reasoning: false,
input: ["text"],
cost: {
input: 0.03,
output: 0.06,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 8192,
maxTokens: 16384,
} satisfies Model<"openai-completions">,
"mistralai/mixtral-8x22b-instruct": { "mistralai/mixtral-8x22b-instruct": {
id: "mistralai/mixtral-8x22b-instruct", id: "mistralai/mixtral-8x22b-instruct",
name: "Mistral: Mixtral 8x22B Instruct", name: "Mistral: Mixtral 8x22B Instruct",
@ -5697,23 +5697,6 @@ export const MODELS = {
contextWindow: 16385, contextWindow: 16385,
maxTokens: 4096, maxTokens: 4096,
} satisfies Model<"openai-completions">, } satisfies Model<"openai-completions">,
"openai/gpt-3.5-turbo": {
id: "openai/gpt-3.5-turbo",
name: "OpenAI: GPT-3.5 Turbo",
api: "openai-completions",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
reasoning: false,
input: ["text"],
cost: {
input: 0.5,
output: 1.5,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 16385,
maxTokens: 4096,
} satisfies Model<"openai-completions">,
"openai/gpt-4-0314": { "openai/gpt-4-0314": {
id: "openai/gpt-4-0314", id: "openai/gpt-4-0314",
name: "OpenAI: GPT-4 (older v0314)", name: "OpenAI: GPT-4 (older v0314)",
@ -5748,6 +5731,23 @@ export const MODELS = {
contextWindow: 8191, contextWindow: 8191,
maxTokens: 4096, maxTokens: 4096,
} satisfies Model<"openai-completions">, } satisfies Model<"openai-completions">,
"openai/gpt-3.5-turbo": {
id: "openai/gpt-3.5-turbo",
name: "OpenAI: GPT-3.5 Turbo",
api: "openai-completions",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
reasoning: false,
input: ["text"],
cost: {
input: 0.5,
output: 1.5,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 16385,
maxTokens: 4096,
} satisfies Model<"openai-completions">,
"openrouter/auto": { "openrouter/auto": {
id: "openrouter/auto", id: "openrouter/auto",
name: "OpenRouter: Auto Router", name: "OpenRouter: Auto Router",

View file

@ -1,5 +1,15 @@
# Changelog # 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 ## [0.14.1] - 2025-12-08
### Fixed ### Fixed

View file

@ -87,6 +87,21 @@ export function getShellConfig(): { shell: string; args: string[] } {
return cachedShellConfig; 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) * Kill a process and all its children (cross-platform)
*/ */

View file

@ -65,6 +65,7 @@ export class BashExecutionComponent extends Container {
appendOutput(chunk: string): void { appendOutput(chunk: string): void {
// Strip ANSI codes and normalize line endings // 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"); const clean = stripAnsi(chunk).replace(/\r\n/g, "\n").replace(/\r/g, "\n");
// Append to output lines // Append to output lines

View file

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