blob: 2edd23061fe338060f5287578926a7141bad4d27 [file] [log] [blame]
Earl Lee2e463fb2025-04-17 11:22:22 -07001package claudetool
2
3import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "fmt"
8 "math"
Philip Zeyligerb60f0f22025-04-23 18:19:32 +00009 "os"
Earl Lee2e463fb2025-04-17 11:22:22 -070010 "os/exec"
Philip Zeyligerb60f0f22025-04-23 18:19:32 +000011 "path/filepath"
Earl Lee2e463fb2025-04-17 11:22:22 -070012 "strings"
13 "syscall"
14 "time"
15
16 "sketch.dev/ant"
17 "sketch.dev/claudetool/bashkit"
18)
19
20// The Bash tool executes shell commands with bash -c and optional timeout
21var Bash = &ant.Tool{
22 Name: bashName,
23 Description: strings.TrimSpace(bashDescription),
24 InputSchema: ant.MustSchema(bashInputSchema),
25 Run: BashRun,
26}
27
28const (
29 bashName = "bash"
30 bashDescription = `
31Executes a shell command using bash -c with an optional timeout, returning combined stdout and stderr.
Philip Zeyligerb60f0f22025-04-23 18:19:32 +000032When run with background flag, the process may keep running after the tool call returns, and
33the agent can inspect the output by reading the output files. Use the background task when, for example,
34starting a server to test something. Be sure to kill the process group when done.
Earl Lee2e463fb2025-04-17 11:22:22 -070035
36Executables pre-installed in this environment include:
37- standard unix tools
38- go
39- git
40- rg
41- jq
42- gopls
43- sqlite
44- fzf
45- gh
46- python3
47`
48 // If you modify this, update the termui template for prettier rendering.
49 bashInputSchema = `
50{
51 "type": "object",
52 "required": ["command"],
53 "properties": {
54 "command": {
55 "type": "string",
56 "description": "Shell script to execute"
57 },
58 "timeout": {
59 "type": "string",
Philip Zeyligerb60f0f22025-04-23 18:19:32 +000060 "description": "Timeout as a Go duration string, defaults to 1m if background is false; 10m if background is true"
61 },
62 "background": {
63 "type": "boolean",
64 "description": "If true, executes the command in the background without waiting for completion"
Earl Lee2e463fb2025-04-17 11:22:22 -070065 }
66 }
67}
68`
69)
70
71type bashInput struct {
Philip Zeyligerb60f0f22025-04-23 18:19:32 +000072 Command string `json:"command"`
73 Timeout string `json:"timeout,omitempty"`
74 Background bool `json:"background,omitempty"`
75}
76
77type BackgroundResult struct {
78 PID int `json:"pid"`
79 StdoutFile string `json:"stdout_file"`
80 StderrFile string `json:"stderr_file"`
Earl Lee2e463fb2025-04-17 11:22:22 -070081}
82
83func (i *bashInput) timeout() time.Duration {
Philip Zeyligerb60f0f22025-04-23 18:19:32 +000084 if i.Timeout != "" {
85 dur, err := time.ParseDuration(i.Timeout)
86 if err == nil {
87 return dur
88 }
89 }
90
91 // Otherwise, use different defaults based on background mode
92 if i.Background {
93 return 10 * time.Minute
94 } else {
Earl Lee2e463fb2025-04-17 11:22:22 -070095 return 1 * time.Minute
96 }
Earl Lee2e463fb2025-04-17 11:22:22 -070097}
98
99func BashRun(ctx context.Context, m json.RawMessage) (string, error) {
100 var req bashInput
101 if err := json.Unmarshal(m, &req); err != nil {
102 return "", fmt.Errorf("failed to unmarshal bash command input: %w", err)
103 }
104 // do a quick permissions check (NOT a security barrier)
105 err := bashkit.Check(req.Command)
106 if err != nil {
107 return "", err
108 }
Philip Zeyligerb60f0f22025-04-23 18:19:32 +0000109
110 // If Background is set to true, use executeBackgroundBash
111 if req.Background {
112 result, err := executeBackgroundBash(ctx, req)
113 if err != nil {
114 return "", err
115 }
116 // Marshal the result to JSON
Josh Bleecher Snyder56ac6052025-04-24 10:40:55 -0700117 // TODO: emit XML(-ish) instead?
Philip Zeyligerb60f0f22025-04-23 18:19:32 +0000118 output, err := json.Marshal(result)
119 if err != nil {
120 return "", fmt.Errorf("failed to marshal background result: %w", err)
121 }
122 return string(output), nil
123 }
124
125 // For foreground commands, use executeBash
Earl Lee2e463fb2025-04-17 11:22:22 -0700126 out, execErr := executeBash(ctx, req)
127 if execErr == nil {
128 return out, nil
129 }
130 return "", execErr
131}
132
133const maxBashOutputLength = 131072
134
135func executeBash(ctx context.Context, req bashInput) (string, error) {
136 execCtx, cancel := context.WithTimeout(ctx, req.timeout())
137 defer cancel()
138
139 // Can't do the simple thing and call CombinedOutput because of the need to kill the process group.
140 cmd := exec.CommandContext(execCtx, "bash", "-c", req.Command)
141 cmd.Dir = WorkingDir(ctx)
142 cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
143
144 var output bytes.Buffer
145 cmd.Stdin = nil
146 cmd.Stdout = &output
147 cmd.Stderr = &output
148 if err := cmd.Start(); err != nil {
149 return "", fmt.Errorf("command failed: %w", err)
150 }
151 proc := cmd.Process
152 done := make(chan struct{})
153 go func() {
154 select {
155 case <-execCtx.Done():
156 if execCtx.Err() == context.DeadlineExceeded && proc != nil {
157 // Kill the entire process group.
158 syscall.Kill(-proc.Pid, syscall.SIGKILL)
159 }
160 case <-done:
161 }
162 }()
163
164 err := cmd.Wait()
165 close(done)
166
167 if execCtx.Err() == context.DeadlineExceeded {
168 return "", fmt.Errorf("command timed out after %s", req.timeout())
169 }
170 longOutput := output.Len() > maxBashOutputLength
171 var outstr string
172 if longOutput {
173 outstr = fmt.Sprintf("output too long: got %v, max is %v\ninitial bytes of output:\n%s",
174 humanizeBytes(output.Len()), humanizeBytes(maxBashOutputLength),
175 output.Bytes()[:1024],
176 )
177 } else {
178 outstr = output.String()
179 }
180
181 if err != nil {
182 return "", fmt.Errorf("command failed: %w\n%s", err, outstr)
183 }
184
185 if longOutput {
186 return "", fmt.Errorf("%s", outstr)
187 }
188
189 return output.String(), nil
190}
191
192func humanizeBytes(bytes int) string {
193 switch {
194 case bytes < 4*1024:
195 return fmt.Sprintf("%dB", bytes)
196 case bytes < 1024*1024:
197 kb := int(math.Round(float64(bytes) / 1024.0))
198 return fmt.Sprintf("%dkB", kb)
199 case bytes < 1024*1024*1024:
200 mb := int(math.Round(float64(bytes) / (1024.0 * 1024.0)))
201 return fmt.Sprintf("%dMB", mb)
202 }
203 return "more than 1GB"
204}
Philip Zeyligerb60f0f22025-04-23 18:19:32 +0000205
206// executeBackgroundBash executes a command in the background and returns the pid and output file locations
207func executeBackgroundBash(ctx context.Context, req bashInput) (*BackgroundResult, error) {
208 // Create temporary directory for output files
209 tmpDir, err := os.MkdirTemp("", "sketch-bg-")
210 if err != nil {
211 return nil, fmt.Errorf("failed to create temp directory: %w", err)
212 }
213
214 // Create temp files for stdout and stderr
215 stdoutFile := filepath.Join(tmpDir, "stdout")
216 stderrFile := filepath.Join(tmpDir, "stderr")
217
218 // Prepare the command
219 cmd := exec.Command("bash", "-c", req.Command)
220 cmd.Dir = WorkingDir(ctx)
221 cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
222
223 // Open output files
224 stdout, err := os.Create(stdoutFile)
225 if err != nil {
226 return nil, fmt.Errorf("failed to create stdout file: %w", err)
227 }
228 defer stdout.Close()
229
230 stderr, err := os.Create(stderrFile)
231 if err != nil {
232 return nil, fmt.Errorf("failed to create stderr file: %w", err)
233 }
234 defer stderr.Close()
235
236 // Configure command to use the files
237 cmd.Stdin = nil
238 cmd.Stdout = stdout
239 cmd.Stderr = stderr
240
241 // Start the command
242 if err := cmd.Start(); err != nil {
243 return nil, fmt.Errorf("failed to start background command: %w", err)
244 }
245
246 // Start a goroutine to reap the process when it finishes
247 go func() {
248 cmd.Wait()
249 // Process has been reaped
250 }()
251
252 // Set up timeout handling if a timeout was specified
253 pid := cmd.Process.Pid
254 timeout := req.timeout()
255 if timeout > 0 {
256 // Launch a goroutine that will kill the process after the timeout
257 go func() {
Josh Bleecher Snyder56ac6052025-04-24 10:40:55 -0700258 // TODO(josh): this should use a context instead of a sleep, like executeBash above,
259 // to avoid goroutine leaks. Possibly should be partially unified with executeBash.
Philip Zeyligerb60f0f22025-04-23 18:19:32 +0000260 // Sleep for the timeout duration
261 time.Sleep(timeout)
262
263 // TODO(philip): Should we do SIGQUIT and then SIGKILL in 5s?
264
265 // Try to kill the process group
266 killErr := syscall.Kill(-pid, syscall.SIGKILL)
267 if killErr != nil {
268 // If killing the process group fails, try to kill just the process
269 syscall.Kill(pid, syscall.SIGKILL)
270 }
271 }()
272 }
273
274 // Return the process ID and file paths
275 return &BackgroundResult{
276 PID: cmd.Process.Pid,
277 StdoutFile: stdoutFile,
278 StderrFile: stderrFile,
279 }, nil
280}