blob: a62892fc986c472631e2336b5c6de951d6701f09 [file] [log] [blame]
Earl Lee2e463fb2025-04-17 11:22:22 -07001package claudetool
2
3import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "fmt"
Josh Bleecher Snyder495c1fa2025-05-29 00:37:22 +00008 "log/slog"
Earl Lee2e463fb2025-04-17 11:22:22 -07009 "math"
Philip Zeyligerb60f0f22025-04-23 18:19:32 +000010 "os"
Earl Lee2e463fb2025-04-17 11:22:22 -070011 "os/exec"
Philip Zeyligerb60f0f22025-04-23 18:19:32 +000012 "path/filepath"
Earl Lee2e463fb2025-04-17 11:22:22 -070013 "strings"
Josh Bleecher Snyder495c1fa2025-05-29 00:37:22 +000014 "sync"
Earl Lee2e463fb2025-04-17 11:22:22 -070015 "syscall"
16 "time"
17
Earl Lee2e463fb2025-04-17 11:22:22 -070018 "sketch.dev/claudetool/bashkit"
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070019 "sketch.dev/llm"
Josh Bleecher Snyder495c1fa2025-05-29 00:37:22 +000020 "sketch.dev/llm/conversation"
Earl Lee2e463fb2025-04-17 11:22:22 -070021)
22
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +000023// PermissionCallback is a function type for checking if a command is allowed to run
24type PermissionCallback func(command string) error
25
26// BashTool is a struct for executing shell commands with bash -c and optional timeout
27type BashTool struct {
28 // CheckPermission is called before running any command, if set
29 CheckPermission PermissionCallback
Josh Bleecher Snyder495c1fa2025-05-29 00:37:22 +000030 // EnableJITInstall enables just-in-time tool installation for missing commands
31 EnableJITInstall bool
Earl Lee2e463fb2025-04-17 11:22:22 -070032}
33
Josh Bleecher Snyder495c1fa2025-05-29 00:37:22 +000034const (
35 EnableBashToolJITInstall = true
36 NoBashToolJITInstall = false
37)
38
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +000039// NewBashTool creates a new Bash tool with optional permission callback
Josh Bleecher Snyder495c1fa2025-05-29 00:37:22 +000040func NewBashTool(checkPermission PermissionCallback, enableJITInstall bool) *llm.Tool {
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +000041 tool := &BashTool{
Josh Bleecher Snyder495c1fa2025-05-29 00:37:22 +000042 CheckPermission: checkPermission,
43 EnableJITInstall: enableJITInstall,
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +000044 }
45
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070046 return &llm.Tool{
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +000047 Name: bashName,
48 Description: strings.TrimSpace(bashDescription),
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070049 InputSchema: llm.MustSchema(bashInputSchema),
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +000050 Run: tool.Run,
51 }
52}
53
54// The Bash tool executes shell commands with bash -c and optional timeout
Josh Bleecher Snyder495c1fa2025-05-29 00:37:22 +000055var Bash = NewBashTool(nil, NoBashToolJITInstall)
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +000056
Earl Lee2e463fb2025-04-17 11:22:22 -070057const (
58 bashName = "bash"
59 bashDescription = `
60Executes a shell command using bash -c with an optional timeout, returning combined stdout and stderr.
Philip Zeyligerb60f0f22025-04-23 18:19:32 +000061When run with background flag, the process may keep running after the tool call returns, and
62the agent can inspect the output by reading the output files. Use the background task when, for example,
63starting a server to test something. Be sure to kill the process group when done.
Earl Lee2e463fb2025-04-17 11:22:22 -070064`
65 // If you modify this, update the termui template for prettier rendering.
66 bashInputSchema = `
67{
68 "type": "object",
69 "required": ["command"],
70 "properties": {
71 "command": {
72 "type": "string",
73 "description": "Shell script to execute"
74 },
75 "timeout": {
76 "type": "string",
Philip Zeyliger208938f2025-05-13 17:17:18 -070077 "description": "Timeout as a Go duration string, defaults to 10s if background is false; 10m if background is true"
Philip Zeyligerb60f0f22025-04-23 18:19:32 +000078 },
79 "background": {
80 "type": "boolean",
81 "description": "If true, executes the command in the background without waiting for completion"
Earl Lee2e463fb2025-04-17 11:22:22 -070082 }
83 }
84}
85`
86)
87
88type bashInput struct {
Philip Zeyligerb60f0f22025-04-23 18:19:32 +000089 Command string `json:"command"`
90 Timeout string `json:"timeout,omitempty"`
91 Background bool `json:"background,omitempty"`
92}
93
94type BackgroundResult struct {
95 PID int `json:"pid"`
96 StdoutFile string `json:"stdout_file"`
97 StderrFile string `json:"stderr_file"`
Earl Lee2e463fb2025-04-17 11:22:22 -070098}
99
100func (i *bashInput) timeout() time.Duration {
Philip Zeyligerb60f0f22025-04-23 18:19:32 +0000101 if i.Timeout != "" {
102 dur, err := time.ParseDuration(i.Timeout)
103 if err == nil {
104 return dur
105 }
106 }
107
108 // Otherwise, use different defaults based on background mode
109 if i.Background {
110 return 10 * time.Minute
111 } else {
Philip Zeyliger208938f2025-05-13 17:17:18 -0700112 return 10 * time.Second
Earl Lee2e463fb2025-04-17 11:22:22 -0700113 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700114}
115
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700116func (b *BashTool) Run(ctx context.Context, m json.RawMessage) ([]llm.Content, error) {
Earl Lee2e463fb2025-04-17 11:22:22 -0700117 var req bashInput
118 if err := json.Unmarshal(m, &req); err != nil {
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700119 return nil, fmt.Errorf("failed to unmarshal bash command input: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -0700120 }
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000121
Earl Lee2e463fb2025-04-17 11:22:22 -0700122 // do a quick permissions check (NOT a security barrier)
123 err := bashkit.Check(req.Command)
124 if err != nil {
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700125 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -0700126 }
Philip Zeyligerb60f0f22025-04-23 18:19:32 +0000127
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000128 // Custom permission callback if set
129 if b.CheckPermission != nil {
130 if err := b.CheckPermission(req.Command); err != nil {
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700131 return nil, err
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000132 }
133 }
134
Josh Bleecher Snyder495c1fa2025-05-29 00:37:22 +0000135 // Check for missing tools and try to install them if needed, best effort only
136 if b.EnableJITInstall {
137 err := b.checkAndInstallMissingTools(ctx, req.Command)
138 if err != nil {
139 slog.DebugContext(ctx, "failed to auto-install missing tools", "error", err)
140 }
141 }
142
Philip Zeyligerb60f0f22025-04-23 18:19:32 +0000143 // If Background is set to true, use executeBackgroundBash
144 if req.Background {
145 result, err := executeBackgroundBash(ctx, req)
146 if err != nil {
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700147 return nil, err
Philip Zeyligerb60f0f22025-04-23 18:19:32 +0000148 }
149 // Marshal the result to JSON
Josh Bleecher Snyder56ac6052025-04-24 10:40:55 -0700150 // TODO: emit XML(-ish) instead?
Philip Zeyligerb60f0f22025-04-23 18:19:32 +0000151 output, err := json.Marshal(result)
152 if err != nil {
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700153 return nil, fmt.Errorf("failed to marshal background result: %w", err)
Philip Zeyligerb60f0f22025-04-23 18:19:32 +0000154 }
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700155 return llm.TextContent(string(output)), nil
Philip Zeyligerb60f0f22025-04-23 18:19:32 +0000156 }
157
158 // For foreground commands, use executeBash
Earl Lee2e463fb2025-04-17 11:22:22 -0700159 out, execErr := executeBash(ctx, req)
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700160 if execErr != nil {
161 return nil, execErr
Earl Lee2e463fb2025-04-17 11:22:22 -0700162 }
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700163 return llm.TextContent(out), nil
Earl Lee2e463fb2025-04-17 11:22:22 -0700164}
165
166const maxBashOutputLength = 131072
167
168func executeBash(ctx context.Context, req bashInput) (string, error) {
169 execCtx, cancel := context.WithTimeout(ctx, req.timeout())
170 defer cancel()
171
172 // Can't do the simple thing and call CombinedOutput because of the need to kill the process group.
173 cmd := exec.CommandContext(execCtx, "bash", "-c", req.Command)
174 cmd.Dir = WorkingDir(ctx)
175 cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
176
Pokey Ruleb6d6d382025-05-07 10:29:03 +0100177 // Set environment with SKETCH=1
178 cmd.Env = append(os.Environ(), "SKETCH=1")
179
Earl Lee2e463fb2025-04-17 11:22:22 -0700180 var output bytes.Buffer
181 cmd.Stdin = nil
182 cmd.Stdout = &output
183 cmd.Stderr = &output
184 if err := cmd.Start(); err != nil {
185 return "", fmt.Errorf("command failed: %w", err)
186 }
187 proc := cmd.Process
188 done := make(chan struct{})
189 go func() {
190 select {
191 case <-execCtx.Done():
192 if execCtx.Err() == context.DeadlineExceeded && proc != nil {
193 // Kill the entire process group.
194 syscall.Kill(-proc.Pid, syscall.SIGKILL)
195 }
196 case <-done:
197 }
198 }()
199
200 err := cmd.Wait()
201 close(done)
202
Earl Lee2e463fb2025-04-17 11:22:22 -0700203 longOutput := output.Len() > maxBashOutputLength
204 var outstr string
205 if longOutput {
206 outstr = fmt.Sprintf("output too long: got %v, max is %v\ninitial bytes of output:\n%s",
207 humanizeBytes(output.Len()), humanizeBytes(maxBashOutputLength),
208 output.Bytes()[:1024],
209 )
210 } else {
211 outstr = output.String()
212 }
213
Philip Zeyliger8a1b89a2025-05-13 17:58:41 -0700214 if execCtx.Err() == context.DeadlineExceeded {
215 // Get the partial output that was captured before the timeout
216 partialOutput := output.String()
217 // Truncate if the output is too large
218 if len(partialOutput) > maxBashOutputLength {
219 partialOutput = partialOutput[:maxBashOutputLength] + "\n[output truncated due to size]\n"
220 }
221 return "", fmt.Errorf("command timed out after %s\nCommand output (until it timed out):\n%s", req.timeout(), outstr)
222 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700223 if err != nil {
224 return "", fmt.Errorf("command failed: %w\n%s", err, outstr)
225 }
226
227 if longOutput {
228 return "", fmt.Errorf("%s", outstr)
229 }
230
231 return output.String(), nil
232}
233
234func humanizeBytes(bytes int) string {
235 switch {
236 case bytes < 4*1024:
237 return fmt.Sprintf("%dB", bytes)
238 case bytes < 1024*1024:
239 kb := int(math.Round(float64(bytes) / 1024.0))
240 return fmt.Sprintf("%dkB", kb)
241 case bytes < 1024*1024*1024:
242 mb := int(math.Round(float64(bytes) / (1024.0 * 1024.0)))
243 return fmt.Sprintf("%dMB", mb)
244 }
245 return "more than 1GB"
246}
Philip Zeyligerb60f0f22025-04-23 18:19:32 +0000247
248// executeBackgroundBash executes a command in the background and returns the pid and output file locations
249func executeBackgroundBash(ctx context.Context, req bashInput) (*BackgroundResult, error) {
250 // Create temporary directory for output files
251 tmpDir, err := os.MkdirTemp("", "sketch-bg-")
252 if err != nil {
253 return nil, fmt.Errorf("failed to create temp directory: %w", err)
254 }
255
256 // Create temp files for stdout and stderr
257 stdoutFile := filepath.Join(tmpDir, "stdout")
258 stderrFile := filepath.Join(tmpDir, "stderr")
259
260 // Prepare the command
261 cmd := exec.Command("bash", "-c", req.Command)
262 cmd.Dir = WorkingDir(ctx)
263 cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
264
Pokey Ruleb6d6d382025-05-07 10:29:03 +0100265 // Set environment with SKETCH=1
266 cmd.Env = append(os.Environ(), "SKETCH=1")
267
Philip Zeyligerb60f0f22025-04-23 18:19:32 +0000268 // Open output files
269 stdout, err := os.Create(stdoutFile)
270 if err != nil {
271 return nil, fmt.Errorf("failed to create stdout file: %w", err)
272 }
273 defer stdout.Close()
274
275 stderr, err := os.Create(stderrFile)
276 if err != nil {
277 return nil, fmt.Errorf("failed to create stderr file: %w", err)
278 }
279 defer stderr.Close()
280
281 // Configure command to use the files
282 cmd.Stdin = nil
283 cmd.Stdout = stdout
284 cmd.Stderr = stderr
285
286 // Start the command
287 if err := cmd.Start(); err != nil {
288 return nil, fmt.Errorf("failed to start background command: %w", err)
289 }
290
291 // Start a goroutine to reap the process when it finishes
292 go func() {
293 cmd.Wait()
294 // Process has been reaped
295 }()
296
297 // Set up timeout handling if a timeout was specified
298 pid := cmd.Process.Pid
299 timeout := req.timeout()
300 if timeout > 0 {
301 // Launch a goroutine that will kill the process after the timeout
302 go func() {
Josh Bleecher Snyder56ac6052025-04-24 10:40:55 -0700303 // TODO(josh): this should use a context instead of a sleep, like executeBash above,
304 // to avoid goroutine leaks. Possibly should be partially unified with executeBash.
Philip Zeyligerb60f0f22025-04-23 18:19:32 +0000305 // Sleep for the timeout duration
306 time.Sleep(timeout)
307
308 // TODO(philip): Should we do SIGQUIT and then SIGKILL in 5s?
309
310 // Try to kill the process group
311 killErr := syscall.Kill(-pid, syscall.SIGKILL)
312 if killErr != nil {
313 // If killing the process group fails, try to kill just the process
314 syscall.Kill(pid, syscall.SIGKILL)
315 }
316 }()
317 }
318
319 // Return the process ID and file paths
320 return &BackgroundResult{
321 PID: cmd.Process.Pid,
322 StdoutFile: stdoutFile,
323 StderrFile: stderrFile,
324 }, nil
325}
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000326
Josh Bleecher Snyder495c1fa2025-05-29 00:37:22 +0000327// checkAndInstallMissingTools analyzes a bash command and attempts to automatically install any missing tools.
328func (b *BashTool) checkAndInstallMissingTools(ctx context.Context, command string) error {
329 commands, err := bashkit.ExtractCommands(command)
330 if err != nil {
331 return err
332 }
333
334 autoInstallMu.Lock()
335 defer autoInstallMu.Unlock()
336
337 var missing []string
338 for _, cmd := range commands {
339 if doNotAttemptToolInstall[cmd] {
340 continue
341 }
342 _, err := exec.LookPath(cmd)
343 if err == nil {
344 doNotAttemptToolInstall[cmd] = true // spare future LookPath calls
345 continue
346 }
347 missing = append(missing, cmd)
348 }
349
Josh Bleecher Snyder855afff2025-05-30 08:47:47 -0700350 if len(missing) == 0 {
351 return nil
352 }
353
Josh Bleecher Snyder495c1fa2025-05-29 00:37:22 +0000354 err = b.installTools(ctx, missing)
355 if err != nil {
356 return err
357 }
358 for _, cmd := range missing {
359 doNotAttemptToolInstall[cmd] = true // either it's installed or it's not--either way, we're done with it
360 }
361 return nil
362}
363
Josh Bleecher Snyder495c1fa2025-05-29 00:37:22 +0000364// Command safety check cache to avoid repeated LLM calls
365var (
366 autoInstallMu sync.Mutex
367 doNotAttemptToolInstall = make(map[string]bool) // set to true if the tool should not be auto-installed
368)
369
370// installTools installs missing tools.
371func (b *BashTool) installTools(ctx context.Context, missing []string) error {
372 slog.InfoContext(ctx, "installTools subconvo", "tools", missing)
373
374 info := conversation.ToolCallInfoFromContext(ctx)
375 if info.Convo == nil {
376 return fmt.Errorf("no conversation context available for tool installation")
377 }
378 subConvo := info.Convo.SubConvo()
379 subConvo.Hidden = true
380 subBash := NewBashTool(nil, NoBashToolJITInstall)
381
382 done := false
383 doneTool := &llm.Tool{
384 Name: "done",
385 Description: "Call this tool once when finished processing all commands, providing the installation status for each.",
386 InputSchema: json.RawMessage(`{
387 "type": "object",
388 "properties": {
389 "results": {
390 "type": "array",
391 "items": {
392 "type": "object",
393 "properties": {
394 "command_name": {
395 "type": "string",
396 "description": "The name of the command"
397 },
398 "installed": {
399 "type": "boolean",
400 "description": "Whether the command was installed"
401 }
402 },
403 "required": ["command_name", "installed"]
404 }
405 }
406 },
407 "required": ["results"]
408}`),
409 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
410 type InstallResult struct {
411 CommandName string `json:"command_name"`
412 Installed bool `json:"installed"`
413 }
414 type DoneInput struct {
415 Results []InstallResult `json:"results"`
416 }
417 var doneInput DoneInput
418 err := json.Unmarshal(input, &doneInput)
419 results := doneInput.Results
420 if err != nil {
421 slog.WarnContext(ctx, "failed to parse install results", "raw", string(input), "error", err)
422 } else {
423 slog.InfoContext(ctx, "auto-tool installation complete", "results", results)
424 }
425 done = true
426 return llm.TextContent(""), nil
427 },
428 }
429
430 subConvo.Tools = []*llm.Tool{
431 subBash,
432 doneTool,
433 }
434
435 const autoinstallSystemPrompt = `The assistant powers an entirely automated auto-installer tool.
436
437The user will provide a list of commands that were not found on the system.
438
439The assistant's task:
440
441First, decide whether each command is mainstream and safe for automatic installation in a development environment. Skip any commands that could cause security issues, legal problems, or consume excessive resources.
442
443For each appropriate command:
444
4451. Detect the system's package manager and install the command using standard repositories only (no source builds, no curl|bash installs).
4462. Make a minimal verification attempt (package manager success is sufficient).
4473. If installation fails after reasonable attempts, mark as failed and move on.
448
449Once all commands have been processed, call the "done" tool with the status of each command.
450`
451
452 subConvo.SystemPrompt = autoinstallSystemPrompt
453
454 cmds := new(strings.Builder)
455 cmds.WriteString("<commands>\n")
456 for _, cmd := range missing {
457 cmds.WriteString("<command>")
458 cmds.WriteString(cmd)
459 cmds.WriteString("</command>\n")
460 }
461 cmds.WriteString("</commands>\n")
462
463 resp, err := subConvo.SendUserTextMessage(cmds.String())
464 if err != nil {
465 return err
466 }
467
468 for !done {
469 if resp.StopReason != llm.StopReasonToolUse {
470 return fmt.Errorf("subagent finished without calling tool")
471 }
472
473 ctxWithWorkDir := WithWorkingDir(ctx, WorkingDir(ctx))
474 results, _, err := subConvo.ToolResultContents(ctxWithWorkDir, resp)
475 if err != nil {
476 return err
477 }
478
479 resp, err = subConvo.SendMessage(llm.Message{
480 Role: llm.MessageRoleUser,
481 Content: results,
482 })
483 if err != nil {
484 return err
485 }
486 }
487
488 return nil
489}