fix(coding-agent): improve bash tool error handling (#479)

- Validate working directory exists before spawning to provide clear error message
- Add spawn error handler to prevent uncaught exceptions when shell not found or cwd invalid
- Add tests for both error scenarios

Without these fixes, spawn errors (e.g., ENOENT from missing cwd or shell) would
cause uncaught exceptions that crash the entire agent session instead of being
returned as clean tool errors.

Co-authored-by: robinwander <robinwander@users.noreply.github.com>
This commit is contained in:
Robin 2026-01-05 17:48:10 -06:00 committed by GitHub
parent c6aa0407e7
commit 1432fd91d2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 41 additions and 3 deletions

View file

@ -1,5 +1,5 @@
import { randomBytes } from "node:crypto";
import { createWriteStream } from "node:fs";
import { createWriteStream, existsSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { AgentTool } from "@mariozechner/pi-agent-core";
@ -40,6 +40,11 @@ export function createBashTool(cwd: string): AgentTool<typeof bashSchema> {
) => {
return new Promise((resolve, reject) => {
const { shell, args } = getShellConfig();
if (!existsSync(cwd)) {
throw new Error(`Working directory does not exist: ${cwd}\nCannot execute bash commands.`);
}
const child = spawn(shell, [...args, command], {
cwd,
detached: true,
@ -119,6 +124,17 @@ export function createBashTool(cwd: string): AgentTool<typeof bashSchema> {
child.stderr.on("data", handleData);
}
// Handle shell spawn errors to prevent session from crashing
child.on("error", (err) => {
if (timeoutHandle) {
clearTimeout(timeoutHandle);
}
if (signal) {
signal.removeEventListener("abort", onAbort);
}
reject(err);
});
// Handle process exit
child.on("close", (code) => {
if (timeoutHandle) {

View file

@ -1,14 +1,15 @@
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { bashTool } from "../src/core/tools/bash.js";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { bashTool, createBashTool } from "../src/core/tools/bash.js";
import { editTool } from "../src/core/tools/edit.js";
import { findTool } from "../src/core/tools/find.js";
import { grepTool } from "../src/core/tools/grep.js";
import { lsTool } from "../src/core/tools/ls.js";
import { readTool } from "../src/core/tools/read.js";
import { writeTool } from "../src/core/tools/write.js";
import * as shellModule from "../src/utils/shell.js";
// Helper to extract text from content blocks
function getTextOutput(result: any): string {
@ -277,6 +278,27 @@ describe("Coding Agent Tools", () => {
/timed out/i,
);
});
it("should throw error when cwd does not exist", async () => {
const nonexistentCwd = "/this/directory/definitely/does/not/exist/12345";
const bashToolWithBadCwd = createBashTool(nonexistentCwd);
await expect(bashToolWithBadCwd.execute("test-call-11", { command: "echo test" })).rejects.toThrow(
/Working directory does not exist/,
);
});
it("should handle process spawn errors", async () => {
vi.spyOn(shellModule, "getShellConfig").mockReturnValueOnce({
shell: "/nonexistent-shell-path-xyz123",
args: ["-c"],
});
const bashWithBadShell = createBashTool(testDir);
await expect(bashWithBadShell.execute("test-call-12", { command: "echo test" })).rejects.toThrow(/ENOENT/);
});
});
describe("grep tool", () => {