From ad42ebf5f5ce5900b06b1bdf997382bdb83f10ac Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 8 Dec 2025 23:26:58 +0100 Subject: [PATCH] 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. --- packages/ai/src/models.generated.ts | 186 +++++++++--------- packages/coding-agent/CHANGELOG.md | 10 + packages/coding-agent/src/shell.ts | 15 ++ .../coding-agent/src/tui/bash-execution.ts | 1 + packages/coding-agent/src/tui/tui-renderer.ts | 40 ++-- 5 files changed, 141 insertions(+), 111 deletions(-) diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index 698428e0..84d74bcd 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -4499,8 +4499,8 @@ export const MODELS = { reasoning: false, input: ["text", "image"], cost: { - input: 0.049999999999999996, - output: 0.22, + input: 0.03, + output: 0.11, cacheRead: 0, cacheWrite: 0, }, @@ -4983,9 +4983,9 @@ export const MODELS = { contextWindow: 32768, maxTokens: 4096, } satisfies Model<"openai-completions">, - "anthropic/claude-3.5-haiku": { - id: "anthropic/claude-3.5-haiku", - name: "Anthropic: Claude 3.5 Haiku", + "anthropic/claude-3.5-haiku-20241022": { + id: "anthropic/claude-3.5-haiku-20241022", + name: "Anthropic: Claude 3.5 Haiku (2024-10-22)", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", @@ -5000,9 +5000,9 @@ export const MODELS = { contextWindow: 200000, maxTokens: 8192, } satisfies Model<"openai-completions">, - "anthropic/claude-3.5-haiku-20241022": { - id: "anthropic/claude-3.5-haiku-20241022", - name: "Anthropic: Claude 3.5 Haiku (2024-10-22)", + "anthropic/claude-3.5-haiku": { + id: "anthropic/claude-3.5-haiku", + name: "Anthropic: Claude 3.5 Haiku", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", @@ -5034,23 +5034,6 @@ export const MODELS = { contextWindow: 200000, maxTokens: 8192, } 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": { id: "mistralai/ministral-8b", name: "Mistral: Ministral 8B", @@ -5068,6 +5051,23 @@ export const MODELS = { contextWindow: 131072, maxTokens: 4096, } 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": { id: "nvidia/llama-3.1-nemotron-70b-instruct", name: "NVIDIA: Llama 3.1 Nemotron 70B Instruct", @@ -5153,23 +5153,6 @@ export const MODELS = { contextWindow: 32768, maxTokens: 4096, } 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": { id: "cohere/command-r-08-2024", name: "Cohere: Command R (08-2024)", @@ -5187,6 +5170,23 @@ export const MODELS = { contextWindow: 128000, maxTokens: 4000, } 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": { id: "sao10k/l3.1-euryale-70b", name: "Sao10K: Llama 3.1 Euryale 70B v2.2", @@ -5238,6 +5238,23 @@ export const MODELS = { contextWindow: 128000, maxTokens: 16384, } 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": { id: "meta-llama/llama-3.1-405b-instruct", name: "Meta: Llama 3.1 405B Instruct", @@ -5272,23 +5289,6 @@ export const MODELS = { contextWindow: 131072, maxTokens: 4096, } 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": { id: "mistralai/mistral-nemo", name: "Mistral: Mistral Nemo", @@ -5476,23 +5476,6 @@ export const MODELS = { contextWindow: 128000, maxTokens: 64000, } 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": { id: "meta-llama/llama-3-70b-instruct", name: "Meta: Llama 3 70B Instruct", @@ -5510,6 +5493,23 @@ export const MODELS = { contextWindow: 8192, maxTokens: 16384, } 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": { id: "mistralai/mixtral-8x22b-instruct", name: "Mistral: Mixtral 8x22B Instruct", @@ -5697,23 +5697,6 @@ export const MODELS = { contextWindow: 16385, maxTokens: 4096, } 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": { id: "openai/gpt-4-0314", name: "OpenAI: GPT-4 (older v0314)", @@ -5748,6 +5731,23 @@ export const MODELS = { contextWindow: 8191, maxTokens: 4096, } 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": { id: "openrouter/auto", name: "OpenRouter: Auto Router", diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 699b0b46..1e05634b 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 diff --git a/packages/coding-agent/src/shell.ts b/packages/coding-agent/src/shell.ts index 1c36381e..92ca6d17 100644 --- a/packages/coding-agent/src/shell.ts +++ b/packages/coding-agent/src/shell.ts @@ -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) */ diff --git a/packages/coding-agent/src/tui/bash-execution.ts b/packages/coding-agent/src/tui/bash-execution.ts index b9ef3134..dd25bce5 100644 --- a/packages/coding-agent/src/tui/bash-execution.ts +++ b/packages/coding-agent/src/tui/bash-execution.ts @@ -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 diff --git a/packages/coding-agent/src/tui/tui-renderer.ts b/packages/coding-agent/src/tui/tui-renderer.ts index 509743c8..6a850680 100644 --- a/packages/coding-agent/src/tui/tui-renderer.ts +++ b/packages/coding-agent/src/tui/tui-renderer.ts @@ -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)