diff --git a/src/services/ipcMain.ts b/src/services/ipcMain.ts index febc8079f..1dcf47f80 100644 --- a/src/services/ipcMain.ts +++ b/src/services/ipcMain.ts @@ -595,7 +595,10 @@ export class IpcMain { _event, workspaceId: string, script: string, - options?: { timeout_secs?: number; niceness?: number } + options?: { + timeout_secs?: number; + niceness?: number; + } ) => { try { // Get workspace metadata to find workspacePath @@ -616,11 +619,13 @@ export class IpcMain { using tempDir = new DisposableTempDir("cmux-ipc-bash"); // Create bash tool with workspace's cwd and secrets + // All IPC bash calls are from UI (background operations) - use truncate to avoid temp file spam const bashTool = createBashTool({ cwd: workspacePath, secrets: secretsToRecord(projectSecrets), niceness: options?.niceness, tempDir: tempDir.path, + overflow_policy: "truncate", }); // Execute the script with provided options diff --git a/src/services/tools/bash.test.ts b/src/services/tools/bash.test.ts index d5fa4fdc0..e7d345b1e 100644 --- a/src/services/tools/bash.test.ts +++ b/src/services/tools/bash.test.ts @@ -157,6 +157,76 @@ describe("bash tool", () => { } }); + it("should truncate overflow output when overflow_policy is 'truncate'", async () => { + const tempDir = new TestTempDir("test-bash-truncate"); + const tool = createBashTool({ + cwd: process.cwd(), + tempDir: tempDir.path, + overflow_policy: "truncate", + }); + + const args: BashToolArgs = { + script: "for i in {1..400}; do echo line$i; done", // Exceeds 300 line hard cap + timeout_secs: 5, + }; + + const result = (await tool.execute!(args, mockToolCallOptions)) as BashToolResult; + + expect(result.success).toBe(false); + if (!result.success) { + // Should contain truncation notice + expect(result.error).toContain("[OUTPUT TRUNCATED"); + expect(result.error).toContain("Showing first 50 of"); + expect(result.error).toContain("lines:"); + + // Should contain first 50 lines + expect(result.error).toContain("line1"); + expect(result.error).toContain("line50"); + + // Should NOT contain line 51 or beyond + expect(result.error).not.toContain("line51"); + expect(result.error).not.toContain("line100"); + + // Should NOT create temp file + const files = fs.readdirSync(tempDir.path); + const bashFiles = files.filter((f) => f.startsWith("bash-")); + expect(bashFiles.length).toBe(0); + } + + tempDir[Symbol.dispose](); + }); + + it("should use tmpfile policy by default when overflow_policy not specified", async () => { + const tempDir = new TestTempDir("test-bash-default"); + const tool = createBashTool({ + cwd: process.cwd(), + tempDir: tempDir.path, + // overflow_policy not specified - should default to tmpfile + }); + + const args: BashToolArgs = { + script: "for i in {1..400}; do echo line$i; done", + timeout_secs: 5, + }; + + const result = (await tool.execute!(args, mockToolCallOptions)) as BashToolResult; + + expect(result.success).toBe(false); + if (!result.success) { + // Should use tmpfile behavior + expect(result.error).toContain("[OUTPUT OVERFLOW"); + expect(result.error).toContain("saved to"); + expect(result.error).not.toContain("[OUTPUT TRUNCATED"); + + // Verify temp file was created + const files = fs.readdirSync(tempDir.path); + const bashFiles = files.filter((f) => f.startsWith("bash-")); + expect(bashFiles.length).toBe(1); + } + + tempDir[Symbol.dispose](); + }); + it("should interleave stdout and stderr", async () => { using testEnv = createTestBashTool(); const tool = testEnv.tool; diff --git a/src/services/tools/bash.ts b/src/services/tools/bash.ts index 6c919002a..cb90960d0 100644 --- a/src/services/tools/bash.ts +++ b/src/services/tools/bash.ts @@ -320,38 +320,56 @@ export const createBashTool: ToolFactory = (config: ToolConfiguration) => { wall_duration_ms, }); } else if (truncated) { - // Save overflow output to temp file instead of returning an error - // We don't show ANY of the actual output to avoid overwhelming context. - // Instead, save it to a temp file and encourage the agent to use filtering tools. - try { - // Use 8 hex characters for short, memorable temp file IDs - const fileId = Math.random().toString(16).substring(2, 10); - const overflowPath = path.join(config.tempDir, `bash-${fileId}.txt`); - const fullOutput = lines.join("\n"); - fs.writeFileSync(overflowPath, fullOutput, "utf-8"); - - const output = `[OUTPUT OVERFLOW - ${overflowReason ?? "unknown reason"}] + // Handle overflow based on policy + const overflowPolicy = config.overflow_policy ?? "tmpfile"; -Full output (${lines.length} lines) saved to ${overflowPath} - -Use selective filtering tools (e.g. grep) to extract relevant information and continue your task - -File will be automatically cleaned up when stream ends.`; + if (overflowPolicy === "truncate") { + // Return truncated output with first 50 lines + const maxTruncateLines = 50; + const truncatedLines = lines.slice(0, maxTruncateLines); + const truncatedOutput = truncatedLines.join("\n"); + const errorMessage = `[OUTPUT TRUNCATED - ${overflowReason ?? "unknown reason"}]\n\nShowing first ${maxTruncateLines} of ${lines.length} lines:\n\n${truncatedOutput}`; resolveOnce({ success: false, - error: output, - exitCode: -1, - wall_duration_ms, - }); - } catch (err) { - // If temp file creation fails, fall back to original error - resolveOnce({ - success: false, - error: `Command output overflow: ${overflowReason ?? "unknown reason"}. Failed to save overflow to temp file: ${String(err)}`, + error: errorMessage, exitCode: -1, wall_duration_ms, }); + } else { + // tmpfile policy: Save overflow output to temp file instead of returning an error + // We don't show ANY of the actual output to avoid overwhelming context. + // Instead, save it to a temp file and encourage the agent to use filtering tools. + try { + // Use 8 hex characters for short, memorable temp file IDs + const fileId = Math.random().toString(16).substring(2, 10); + const overflowPath = path.join(config.tempDir, `bash-${fileId}.txt`); + const fullOutput = lines.join("\n"); + fs.writeFileSync(overflowPath, fullOutput, "utf-8"); + + const output = `[OUTPUT OVERFLOW - ${overflowReason ?? "unknown reason"}] + +Full output (${lines.length} lines) saved to ${overflowPath} + +Use selective filtering tools (e.g. grep) to extract relevant information and continue your task + +File will be automatically cleaned up when stream ends.`; + + resolveOnce({ + success: false, + error: output, + exitCode: -1, + wall_duration_ms, + }); + } catch (err) { + // If temp file creation fails, fall back to original error + resolveOnce({ + success: false, + error: `Command output overflow: ${overflowReason ?? "unknown reason"}. Failed to save overflow to temp file: ${String(err)}`, + exitCode: -1, + wall_duration_ms, + }); + } } } else if (exitCode === 0 || exitCode === null) { resolveOnce({ diff --git a/src/types/ipc.ts b/src/types/ipc.ts index 9bba03829..ece311231 100644 --- a/src/types/ipc.ts +++ b/src/types/ipc.ts @@ -210,7 +210,10 @@ export interface IPCApi { executeBash( workspaceId: string, script: string, - options?: { timeout_secs?: number; niceness?: number } + options?: { + timeout_secs?: number; + niceness?: number; + } ): Promise>; openTerminal(workspacePath: string): Promise; diff --git a/src/utils/tools/tools.ts b/src/utils/tools/tools.ts index a2130ea25..923ccfb6b 100644 --- a/src/utils/tools/tools.ts +++ b/src/utils/tools/tools.ts @@ -21,6 +21,8 @@ export interface ToolConfiguration { niceness?: number; /** Temporary directory for tool outputs (required) */ tempDir: string; + /** Overflow policy for bash tool output (optional, not exposed to AI) */ + overflow_policy?: "truncate" | "tmpfile"; } /**