Skip to content

Commit f5fc7b5

Browse files
committed
fix(tools): use spawn() for bash tool, fix timeout units and non-zero exit handling
- Switch exec() to spawn() so non-zero exit codes return output instead of throwing - Convert timeout from seconds to milliseconds (model sends seconds, exec expected ms) - Add SHELL env var for correct PATH in user's shell - Return exit code in output for transparency on non-zero exits Fixes #48, resolves false-positive tool loop guard triggers in #45
1 parent 8d6a468 commit f5fc7b5

File tree

1 file changed

+50
-18
lines changed

1 file changed

+50
-18
lines changed

src/tools/defaults.ts

Lines changed: 50 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ export function registerDefaultTools(registry: ToolRegistry): void {
1818
},
1919
timeout: {
2020
type: "number",
21-
description: "Timeout in milliseconds (default: 30000)"
21+
description: "Timeout in seconds (default: 30)"
2222
},
2323
cwd: {
2424
type: "string",
@@ -29,25 +29,49 @@ export function registerDefaultTools(registry: ToolRegistry): void {
2929
},
3030
source: "local" as const
3131
}, async (args) => {
32-
const { exec } = await import("child_process");
33-
const { promisify } = await import("util");
34-
const execAsync = promisify(exec);
32+
const { spawn } = await import("child_process");
3533

36-
try {
37-
const command = resolveBashCommand(args);
38-
if (!command) {
39-
throw new Error("bash: missing required argument 'command'");
40-
}
41-
const timeout = resolveTimeout(args.timeout);
42-
const cwd = resolveWorkingDirectory(args);
43-
const { stdout, stderr } = await execAsync(command, {
44-
timeout: timeout ?? 30000,
45-
cwd: cwd
46-
});
47-
return stdout || stderr || "Command executed successfully";
48-
} catch (error: any) {
49-
throw error;
34+
const command = resolveBashCommand(args);
35+
if (!command) {
36+
throw new Error("bash: missing required argument 'command'");
5037
}
38+
const timeoutMs = resolveTimeoutMs(args.timeout);
39+
const cwd = resolveWorkingDirectory(args);
40+
41+
return new Promise<string>((resolve, reject) => {
42+
const proc = spawn(command, {
43+
shell: process.env.SHELL || "/bin/bash",
44+
cwd,
45+
});
46+
47+
const stdoutChunks: Buffer[] = [];
48+
const stderrChunks: Buffer[] = [];
49+
let timedOut = false;
50+
51+
const timer = setTimeout(() => {
52+
timedOut = true;
53+
proc.kill("SIGTERM");
54+
}, timeoutMs);
55+
56+
proc.stdout.on("data", (chunk: Buffer) => stdoutChunks.push(chunk));
57+
proc.stderr.on("data", (chunk: Buffer) => stderrChunks.push(chunk));
58+
59+
proc.on("close", (code) => {
60+
clearTimeout(timer);
61+
const stdout = Buffer.concat(stdoutChunks).toString("utf8");
62+
const stderr = Buffer.concat(stderrChunks).toString("utf8");
63+
const output = stdout || stderr || "Command executed successfully";
64+
if (timedOut) {
65+
resolve(`Command timed out after ${timeoutMs / 1000}s\n${output}`);
66+
} else if (code !== 0) {
67+
resolve(`${output}\n[Exit code: ${code}]`);
68+
} else {
69+
resolve(output);
70+
}
71+
});
72+
73+
proc.on("error", reject);
74+
});
5175
});
5276

5377
// 2. Read tool - Read file contents
@@ -598,6 +622,14 @@ function resolveTimeout(value: unknown): number | undefined {
598622
return undefined;
599623
}
600624

625+
// Convert model-supplied timeout (seconds) to milliseconds. Falls back to 30s.
626+
function resolveTimeoutMs(value: unknown): number {
627+
const raw = resolveTimeout(value);
628+
if (raw === undefined) return 30_000;
629+
// Values ≤ 600 are treated as seconds (no real use case for a <600ms shell timeout).
630+
return raw <= 600 ? raw * 1000 : raw;
631+
}
632+
601633
function resolveBoolean(value: unknown, defaultValue: boolean): boolean {
602634
if (typeof value === "boolean") {
603635
return value;

0 commit comments

Comments
 (0)