Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion src/services/ipcMain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
70 changes: 70 additions & 0 deletions src/services/tools/bash.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
68 changes: 43 additions & 25 deletions src/services/tools/bash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
5 changes: 4 additions & 1 deletion src/types/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Result<BashToolResult, string>>;
openTerminal(workspacePath: string): Promise<void>;

Expand Down
2 changes: 2 additions & 0 deletions src/utils/tools/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}

/**
Expand Down