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
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ fmt-check: ## Check code formatting
fmt-shell: ## Format shell scripts with shfmt
@./scripts/fmt.sh --shell

typecheck: ## Run TypeScript type checking
typecheck: dist/version.txt ## Run TypeScript type checking
@./scripts/typecheck.sh

## Testing
Expand Down
58 changes: 50 additions & 8 deletions src/services/tools/bash.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,8 @@ describe("bash tool", () => {

expect(result.success).toBe(false);
if (!result.success) {
expect(result.error).toContain("output exceeded limits");
// Should contain specific overflow reason
expect(result.error).toMatch(/Line count exceeded limit|OUTPUT OVERFLOW/);
expect(result.exitCode).toBe(-1);
}
});
Expand All @@ -76,7 +77,10 @@ describe("bash tool", () => {
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error).toContain("[OUTPUT OVERFLOW");
expect(result.error).toContain("lines saved to");
// Should contain specific overflow reason (one of the three types)
expect(result.error).toMatch(/Line count exceeded limit|Total output exceeded limit|exceeded per-line limit/);
expect(result.error).toContain("Full output");
expect(result.error).toContain("lines) saved to");
expect(result.error).toContain("bash-");
expect(result.error).toContain(".txt");

Expand All @@ -86,8 +90,8 @@ describe("bash tool", () => {
expect(result.error).toContain("tail -n 300");
expect(result.error).toContain("When done, clean up: rm");

// Extract file path from error message
const match = /saved to (\/[^\]]+\.txt)/.exec(result.error);
// Extract file path from error message (handles both "lines saved to" and "lines) saved to")
const match = /saved to (\/.+?\.txt)/.exec(result.error);
expect(match).toBeDefined();
if (match) {
const overflowPath = match[1];
Expand Down Expand Up @@ -130,7 +134,7 @@ describe("bash tool", () => {

expect(result.success).toBe(false);
if (!result.success) {
expect(result.error).toContain("output exceeded limits");
expect(result.error).toMatch(/Line count exceeded limit|OUTPUT OVERFLOW/);
// Should complete much faster than 10 seconds (give it 2 seconds buffer)
expect(duration).toBeLessThan(2000);
}
Expand Down Expand Up @@ -513,7 +517,7 @@ describe("bash tool", () => {

expect(result.success).toBe(false);
if (!result.success) {
expect(result.error).toContain("output exceeded limits");
expect(result.error).toMatch(/exceeded per-line limit|OUTPUT OVERFLOW/);
expect(result.error).toContain("head");
expect(result.exitCode).toBe(-1);
}
Expand All @@ -533,7 +537,7 @@ describe("bash tool", () => {

expect(result.success).toBe(false);
if (!result.success) {
expect(result.error).toContain("output exceeded limits");
expect(result.error).toMatch(/Total output exceeded limit|OUTPUT OVERFLOW/);
expect(result.error).toContain("grep");
expect(result.exitCode).toBe(-1);
}
Expand All @@ -551,9 +555,47 @@ describe("bash tool", () => {

expect(result.success).toBe(false);
if (!result.success) {
expect(result.error).toContain("output exceeded limits");
expect(result.error).toMatch(/Total output exceeded limit|OUTPUT OVERFLOW/);
expect(result.error).toContain("tail");
expect(result.exitCode).toBe(-1);
}
});

it("should fail immediately when script is empty", async () => {
const tool = createBashTool({ cwd: process.cwd() });
const args: BashToolArgs = {
script: "",
timeout_secs: 5,
max_lines: 100,
};

const result = (await tool.execute!(args, mockToolCallOptions)) as BashToolResult;

expect(result.success).toBe(false);
if (!result.success) {
expect(result.error).toContain("Script parameter is empty");
expect(result.error).toContain("malformed tool call");
expect(result.exitCode).toBe(-1);
expect(result.wall_duration_ms).toBe(0);
}
});

it("should fail immediately when script is only whitespace", async () => {
const tool = createBashTool({ cwd: process.cwd() });
const args: BashToolArgs = {
script: " \n\t ",
timeout_secs: 5,
max_lines: 100,
};

const result = (await tool.execute!(args, mockToolCallOptions)) as BashToolResult;

expect(result.success).toBe(false);
if (!result.success) {
expect(result.error).toContain("Script parameter is empty");
expect(result.exitCode).toBe(-1);
expect(result.wall_duration_ms).toBe(0);
}
});

});
47 changes: 35 additions & 12 deletions src/services/tools/bash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,21 @@ export const createBashTool: ToolFactory = (config: ToolConfiguration) => {
{ script, timeout_secs, max_lines = BASH_DEFAULT_MAX_LINES, stdin },
{ abortSignal }
): Promise<BashToolResult> => {
// Validate script is not empty - likely indicates a malformed tool call
if (!script || script.trim().length === 0) {
return {
success: false,
error: "Script parameter is empty. This likely indicates a malformed tool call.",
exitCode: -1,
wall_duration_ms: 0,
};
}

const startTime = performance.now();
const normalizedMaxLines = Math.max(1, Math.floor(max_lines));
const effectiveMaxLines = Math.min(normalizedMaxLines, BASH_HARD_MAX_LINES);
let totalBytesAccumulated = 0;
let overflowReason: string | null = null;

// Detect redundant cd to working directory
// Match patterns like: "cd /path &&", "cd /path;", "cd '/path' &&", "cd \"/path\" &&"
Expand Down Expand Up @@ -142,8 +153,9 @@ export const createBashTool: ToolFactory = (config: ToolConfiguration) => {

// Helper to trigger truncation and clean shutdown
// Prevents duplication and ensures consistent cleanup
const triggerTruncation = () => {
const triggerTruncation = (reason: string) => {
truncated = true;
overflowReason = reason;
stdoutReader.close();
stderrReader.close();
childProcess.child.kill();
Expand All @@ -160,21 +172,27 @@ export const createBashTool: ToolFactory = (config: ToolConfiguration) => {

// Check if line exceeds per-line limit
if (lineBytes > BASH_MAX_LINE_BYTES) {
triggerTruncation();
triggerTruncation(
`Line ${lines.length} exceeded per-line limit: ${lineBytes} bytes > ${BASH_MAX_LINE_BYTES} bytes`
);
return;
}

totalBytesAccumulated += lineBytes + 1; // +1 for newline

// Check if adding this line would exceed total bytes limit
if (totalBytesAccumulated > BASH_MAX_TOTAL_BYTES) {
triggerTruncation();
triggerTruncation(
`Total output exceeded limit: ${totalBytesAccumulated} bytes > ${BASH_MAX_TOTAL_BYTES} bytes (at line ${lines.length})`
);
return;
}

// Check if we've exceeded the effective max_lines limit
if (lines.length >= effectiveMaxLines) {
triggerTruncation();
triggerTruncation(
`Line count exceeded limit: ${lines.length} lines >= ${effectiveMaxLines} lines (${totalBytesAccumulated} bytes read)`
);
}
}
}
Expand All @@ -191,21 +209,27 @@ export const createBashTool: ToolFactory = (config: ToolConfiguration) => {

// Check if line exceeds per-line limit
if (lineBytes > BASH_MAX_LINE_BYTES) {
triggerTruncation();
triggerTruncation(
`Line ${lines.length} exceeded per-line limit: ${lineBytes} bytes > ${BASH_MAX_LINE_BYTES} bytes`
);
return;
}

totalBytesAccumulated += lineBytes + 1; // +1 for newline

// Check if adding this line would exceed total bytes limit
if (totalBytesAccumulated > BASH_MAX_TOTAL_BYTES) {
triggerTruncation();
triggerTruncation(
`Total output exceeded limit: ${totalBytesAccumulated} bytes > ${BASH_MAX_TOTAL_BYTES} bytes (at line ${lines.length})`
);
return;
}

// Check if we've exceeded the effective max_lines limit
if (lines.length >= effectiveMaxLines) {
triggerTruncation();
triggerTruncation(
`Line count exceeded limit: ${lines.length} lines >= ${effectiveMaxLines} lines (${totalBytesAccumulated} bytes read)`
);
}
}
}
Expand Down Expand Up @@ -302,9 +326,10 @@ export const createBashTool: ToolFactory = (config: ToolConfiguration) => {
const fullOutput = lines.join("\n");
fs.writeFileSync(overflowPath, fullOutput, "utf-8");

const output = `[OUTPUT OVERFLOW - ${lines.length} lines saved to ${overflowPath}]
const output = `[OUTPUT OVERFLOW - ${overflowReason}]

Full output (${lines.length} lines) saved to ${overflowPath}

The command output exceeded limits and was saved to a temporary file.
Use filtering tools to extract what you need:
- grep '<pattern>' ${overflowPath}
- head -n 300 ${overflowPath}
Expand All @@ -323,9 +348,7 @@ When done, clean up: rm ${overflowPath}`;
// If temp file creation fails, fall back to original error
resolveOnce({
success: false,
error:
`Command output exceeded limits (max ${BASH_MAX_TOTAL_BYTES} bytes total, ${BASH_MAX_LINE_BYTES} bytes per line, ${effectiveMaxLines} lines). ` +
`Use output-limiting commands like 'head', 'tail', or 'grep' to reduce output size. Failed to save overflow: ${String(err)}`,
error: `Command output overflow: ${overflowReason}. Failed to save overflow to temp file: ${String(err)}`,
exitCode: -1,
wall_duration_ms,
});
Expand Down
4 changes: 2 additions & 2 deletions tests/ipcMain/executeBash.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ describeIntegration("IpcMain executeBash integration tests", () => {

expect(maxLinesResult.success).toBe(true);
expect(maxLinesResult.data.success).toBe(false);
expect(maxLinesResult.data.error).toContain("output exceeded limits");
expect(maxLinesResult.data.error).toMatch(/Line count exceeded limit|OUTPUT OVERFLOW/);
expect(maxLinesResult.data.exitCode).toBe(-1);

// Clean up
Expand Down Expand Up @@ -222,7 +222,7 @@ describeIntegration("IpcMain executeBash integration tests", () => {

expect(oversizedResult.success).toBe(true);
expect(oversizedResult.data.success).toBe(false);
expect(oversizedResult.data.error).toContain("output exceeded limits");
expect(oversizedResult.data.error).toMatch(/Line count exceeded limit|OUTPUT OVERFLOW/);
expect(oversizedResult.data.exitCode).toBe(-1);

await env.mockIpcRenderer.invoke(IPC_CHANNELS.WORKSPACE_REMOVE, workspaceId);
Expand Down