blob: 233b0b7e04a72783e702729c535c5549f282eeff [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
117 output, err := json.Marshal(result)
118 if err != nil {
119 return "", fmt.Errorf("failed to marshal background result: %w", err)
120 }
121 return string(output), nil
122 }
123
124 // For foreground commands, use executeBash
Earl Lee2e463fb2025-04-17 11:22:22 -0700125 out, execErr := executeBash(ctx, req)
126 if execErr == nil {
127 return out, nil
128 }
129 return "", execErr
130}
131
132const maxBashOutputLength = 131072
133
134func executeBash(ctx context.Context, req bashInput) (string, error) {
135 execCtx, cancel := context.WithTimeout(ctx, req.timeout())
136 defer cancel()
137
138 // Can't do the simple thing and call CombinedOutput because of the need to kill the process group.
139 cmd := exec.CommandContext(execCtx, "bash", "-c", req.Command)
140 cmd.Dir = WorkingDir(ctx)
141 cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
142
143 var output bytes.Buffer
144 cmd.Stdin = nil
145 cmd.Stdout = &output
146 cmd.Stderr = &output
147 if err := cmd.Start(); err != nil {
148 return "", fmt.Errorf("command failed: %w", err)
149 }
150 proc := cmd.Process
151 done := make(chan struct{})
152 go func() {
153 select {
154 case <-execCtx.Done():
155 if execCtx.Err() == context.DeadlineExceeded && proc != nil {
156 // Kill the entire process group.
157 syscall.Kill(-proc.Pid, syscall.SIGKILL)
158 }
159 case <-done:
160 }
161 }()
162
163 err := cmd.Wait()
164 close(done)
165
166 if execCtx.Err() == context.DeadlineExceeded {
167 return "", fmt.Errorf("command timed out after %s", req.timeout())
168 }
169 longOutput := output.Len() > maxBashOutputLength
170 var outstr string
171 if longOutput {
172 outstr = fmt.Sprintf("output too long: got %v, max is %v\ninitial bytes of output:\n%s",
173 humanizeBytes(output.Len()), humanizeBytes(maxBashOutputLength),
174 output.Bytes()[:1024],
175 )
176 } else {
177 outstr = output.String()
178 }
179
180 if err != nil {
181 return "", fmt.Errorf("command failed: %w\n%s", err, outstr)
182 }
183
184 if longOutput {
185 return "", fmt.Errorf("%s", outstr)
186 }
187
188 return output.String(), nil
189}
190
191func humanizeBytes(bytes int) string {
192 switch {
193 case bytes < 4*1024:
194 return fmt.Sprintf("%dB", bytes)
195 case bytes < 1024*1024:
196 kb := int(math.Round(float64(bytes) / 1024.0))
197 return fmt.Sprintf("%dkB", kb)
198 case bytes < 1024*1024*1024:
199 mb := int(math.Round(float64(bytes) / (1024.0 * 1024.0)))
200 return fmt.Sprintf("%dMB", mb)
201 }
202 return "more than 1GB"
203}
Philip Zeyligerb60f0f22025-04-23 18:19:32 +0000204
205// executeBackgroundBash executes a command in the background and returns the pid and output file locations
206func executeBackgroundBash(ctx context.Context, req bashInput) (*BackgroundResult, error) {
207 // Create temporary directory for output files
208 tmpDir, err := os.MkdirTemp("", "sketch-bg-")
209 if err != nil {
210 return nil, fmt.Errorf("failed to create temp directory: %w", err)
211 }
212
213 // Create temp files for stdout and stderr
214 stdoutFile := filepath.Join(tmpDir, "stdout")
215 stderrFile := filepath.Join(tmpDir, "stderr")
216
217 // Prepare the command
218 cmd := exec.Command("bash", "-c", req.Command)
219 cmd.Dir = WorkingDir(ctx)
220 cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
221
222 // Open output files
223 stdout, err := os.Create(stdoutFile)
224 if err != nil {
225 return nil, fmt.Errorf("failed to create stdout file: %w", err)
226 }
227 defer stdout.Close()
228
229 stderr, err := os.Create(stderrFile)
230 if err != nil {
231 return nil, fmt.Errorf("failed to create stderr file: %w", err)
232 }
233 defer stderr.Close()
234
235 // Configure command to use the files
236 cmd.Stdin = nil
237 cmd.Stdout = stdout
238 cmd.Stderr = stderr
239
240 // Start the command
241 if err := cmd.Start(); err != nil {
242 return nil, fmt.Errorf("failed to start background command: %w", err)
243 }
244
245 // Start a goroutine to reap the process when it finishes
246 go func() {
247 cmd.Wait()
248 // Process has been reaped
249 }()
250
251 // Set up timeout handling if a timeout was specified
252 pid := cmd.Process.Pid
253 timeout := req.timeout()
254 if timeout > 0 {
255 // Launch a goroutine that will kill the process after the timeout
256 go func() {
257 // Sleep for the timeout duration
258 time.Sleep(timeout)
259
260 // TODO(philip): Should we do SIGQUIT and then SIGKILL in 5s?
261
262 // Try to kill the process group
263 killErr := syscall.Kill(-pid, syscall.SIGKILL)
264 if killErr != nil {
265 // If killing the process group fails, try to kill just the process
266 syscall.Kill(pid, syscall.SIGKILL)
267 }
268 }()
269 }
270
271 // Return the process ID and file paths
272 return &BackgroundResult{
273 PID: cmd.Process.Pid,
274 StdoutFile: stdoutFile,
275 StderrFile: stderrFile,
276 }, nil
277}