blob: 741f1e911352830441a67c28d88a97e349b697b5 [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
Josh Bleecher Snyder17b2fd92025-07-09 22:47:13 +000026// BashTool specifies a llm.Tool for executing shell commands.
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +000027type 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
Josh Bleecher Snyder17b2fd92025-07-09 22:47:13 +000032 // Timeouts holds the configurable timeout values (uses defaults if nil)
33 Timeouts *Timeouts
Earl Lee2e463fb2025-04-17 11:22:22 -070034}
35
Josh Bleecher Snyder495c1fa2025-05-29 00:37:22 +000036const (
37 EnableBashToolJITInstall = true
38 NoBashToolJITInstall = false
Josh Bleecher Snyder17b2fd92025-07-09 22:47:13 +000039
40 DefaultFastTimeout = 30 * time.Second
41 DefaultSlowTimeout = 15 * time.Minute
42 DefaultBackgroundTimeout = 24 * time.Hour
Josh Bleecher Snyder495c1fa2025-05-29 00:37:22 +000043)
44
Josh Bleecher Snyder17b2fd92025-07-09 22:47:13 +000045// Timeouts holds the configurable timeout values for bash commands.
46type Timeouts struct {
47 Fast time.Duration // regular commands (e.g., ls, echo, simple scripts)
48 Slow time.Duration // commands that may reasonably take longer (e.g., downloads, builds, tests)
49 Background time.Duration // background commands (e.g., servers, long-running processes)
50}
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +000051
Josh Bleecher Snyder17b2fd92025-07-09 22:47:13 +000052// Fast returns t's fast timeout, or DefaultFastTimeout if t is nil.
53func (t *Timeouts) fast() time.Duration {
54 if t == nil {
55 return DefaultFastTimeout
56 }
57 return t.Fast
58}
59
60// Slow returns t's slow timeout, or DefaultSlowTimeout if t is nil.
61func (t *Timeouts) slow() time.Duration {
62 if t == nil {
63 return DefaultSlowTimeout
64 }
65 return t.Slow
66}
67
68// Background returns t's background timeout, or DefaultBackgroundTimeout if t is nil.
69func (t *Timeouts) background() time.Duration {
70 if t == nil {
71 return DefaultBackgroundTimeout
72 }
73 return t.Background
74}
75
76// Tool returns an llm.Tool based on b.
77func (b *BashTool) Tool() *llm.Tool {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070078 return &llm.Tool{
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +000079 Name: bashName,
80 Description: strings.TrimSpace(bashDescription),
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070081 InputSchema: llm.MustSchema(bashInputSchema),
Josh Bleecher Snyder17b2fd92025-07-09 22:47:13 +000082 Run: b.Run,
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +000083 }
84}
85
Earl Lee2e463fb2025-04-17 11:22:22 -070086const (
87 bashName = "bash"
88 bashDescription = `
Josh Bleecher Snyder17b2fd92025-07-09 22:47:13 +000089Executes shell commands via bash -c, returning combined stdout/stderr.
90
91With background=true, returns immediately while process continues running
92with output redirected to files. Kill process group when done.
93Use background for servers/demos that need to stay running.
94
95MUST set slow_ok=true for potentially slow commands: builds, downloads,
96installs, tests, or any other substantive operation.
Earl Lee2e463fb2025-04-17 11:22:22 -070097`
98 // If you modify this, update the termui template for prettier rendering.
99 bashInputSchema = `
100{
101 "type": "object",
102 "required": ["command"],
103 "properties": {
104 "command": {
105 "type": "string",
Josh Bleecher Snyder17b2fd92025-07-09 22:47:13 +0000106 "description": "Shell to execute"
Earl Lee2e463fb2025-04-17 11:22:22 -0700107 },
Josh Bleecher Snyder17b2fd92025-07-09 22:47:13 +0000108 "slow_ok": {
109 "type": "boolean",
110 "description": "Use extended timeout"
Philip Zeyligerb60f0f22025-04-23 18:19:32 +0000111 },
112 "background": {
113 "type": "boolean",
Josh Bleecher Snyder17b2fd92025-07-09 22:47:13 +0000114 "description": "Execute in background"
Earl Lee2e463fb2025-04-17 11:22:22 -0700115 }
116 }
117}
118`
119)
120
121type bashInput struct {
Philip Zeyligerb60f0f22025-04-23 18:19:32 +0000122 Command string `json:"command"`
Josh Bleecher Snyder17b2fd92025-07-09 22:47:13 +0000123 SlowOK bool `json:"slow_ok,omitempty"`
Philip Zeyligerb60f0f22025-04-23 18:19:32 +0000124 Background bool `json:"background,omitempty"`
125}
126
127type BackgroundResult struct {
128 PID int `json:"pid"`
129 StdoutFile string `json:"stdout_file"`
130 StderrFile string `json:"stderr_file"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700131}
132
Josh Bleecher Snyder17b2fd92025-07-09 22:47:13 +0000133func (i *bashInput) timeout(t *Timeouts) time.Duration {
134 switch {
135 case i.Background:
136 return t.background()
137 case i.SlowOK:
138 return t.slow()
139 default:
140 return t.fast()
Earl Lee2e463fb2025-04-17 11:22:22 -0700141 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700142}
143
Josh Bleecher Snyder43b60b92025-07-21 14:57:10 -0700144func (b *BashTool) Run(ctx context.Context, m json.RawMessage) llm.ToolOut {
Earl Lee2e463fb2025-04-17 11:22:22 -0700145 var req bashInput
146 if err := json.Unmarshal(m, &req); err != nil {
Josh Bleecher Snyder43b60b92025-07-21 14:57:10 -0700147 return llm.ErrorfToolOut("failed to unmarshal bash command input: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -0700148 }
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000149
Earl Lee2e463fb2025-04-17 11:22:22 -0700150 // do a quick permissions check (NOT a security barrier)
151 err := bashkit.Check(req.Command)
152 if err != nil {
Josh Bleecher Snyder43b60b92025-07-21 14:57:10 -0700153 return llm.ErrorToolOut(err)
Earl Lee2e463fb2025-04-17 11:22:22 -0700154 }
Philip Zeyligerb60f0f22025-04-23 18:19:32 +0000155
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000156 // Custom permission callback if set
157 if b.CheckPermission != nil {
158 if err := b.CheckPermission(req.Command); err != nil {
Josh Bleecher Snyder43b60b92025-07-21 14:57:10 -0700159 return llm.ErrorToolOut(err)
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000160 }
161 }
162
Josh Bleecher Snyder495c1fa2025-05-29 00:37:22 +0000163 // Check for missing tools and try to install them if needed, best effort only
164 if b.EnableJITInstall {
165 err := b.checkAndInstallMissingTools(ctx, req.Command)
166 if err != nil {
167 slog.DebugContext(ctx, "failed to auto-install missing tools", "error", err)
168 }
169 }
170
Josh Bleecher Snyder17b2fd92025-07-09 22:47:13 +0000171 timeout := req.timeout(b.Timeouts)
172
Philip Zeyligerb60f0f22025-04-23 18:19:32 +0000173 // If Background is set to true, use executeBackgroundBash
174 if req.Background {
Josh Bleecher Snyder17b2fd92025-07-09 22:47:13 +0000175 result, err := executeBackgroundBash(ctx, req, timeout)
Philip Zeyligerb60f0f22025-04-23 18:19:32 +0000176 if err != nil {
Josh Bleecher Snyder43b60b92025-07-21 14:57:10 -0700177 return llm.ErrorToolOut(err)
Philip Zeyligerb60f0f22025-04-23 18:19:32 +0000178 }
179 // Marshal the result to JSON
Josh Bleecher Snyder56ac6052025-04-24 10:40:55 -0700180 // TODO: emit XML(-ish) instead?
Philip Zeyligerb60f0f22025-04-23 18:19:32 +0000181 output, err := json.Marshal(result)
182 if err != nil {
Josh Bleecher Snyder43b60b92025-07-21 14:57:10 -0700183 return llm.ErrorfToolOut("failed to marshal background result: %w", err)
Philip Zeyligerb60f0f22025-04-23 18:19:32 +0000184 }
Josh Bleecher Snyder43b60b92025-07-21 14:57:10 -0700185 return llm.ToolOut{LLMContent: llm.TextContent(string(output))}
Philip Zeyligerb60f0f22025-04-23 18:19:32 +0000186 }
187
188 // For foreground commands, use executeBash
Josh Bleecher Snyder17b2fd92025-07-09 22:47:13 +0000189 out, execErr := executeBash(ctx, req, timeout)
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700190 if execErr != nil {
Josh Bleecher Snyder43b60b92025-07-21 14:57:10 -0700191 return llm.ErrorToolOut(execErr)
Earl Lee2e463fb2025-04-17 11:22:22 -0700192 }
Josh Bleecher Snyder43b60b92025-07-21 14:57:10 -0700193 return llm.ToolOut{LLMContent: llm.TextContent(out)}
Earl Lee2e463fb2025-04-17 11:22:22 -0700194}
195
196const maxBashOutputLength = 131072
197
Josh Bleecher Snyder17b2fd92025-07-09 22:47:13 +0000198func executeBash(ctx context.Context, req bashInput, timeout time.Duration) (string, error) {
199 execCtx, cancel := context.WithTimeout(ctx, timeout)
Earl Lee2e463fb2025-04-17 11:22:22 -0700200 defer cancel()
201
202 // Can't do the simple thing and call CombinedOutput because of the need to kill the process group.
203 cmd := exec.CommandContext(execCtx, "bash", "-c", req.Command)
204 cmd.Dir = WorkingDir(ctx)
205 cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
206
Pokey Ruleb6d6d382025-05-07 10:29:03 +0100207 // Set environment with SKETCH=1
208 cmd.Env = append(os.Environ(), "SKETCH=1")
209
Earl Lee2e463fb2025-04-17 11:22:22 -0700210 var output bytes.Buffer
211 cmd.Stdin = nil
212 cmd.Stdout = &output
213 cmd.Stderr = &output
214 if err := cmd.Start(); err != nil {
215 return "", fmt.Errorf("command failed: %w", err)
216 }
217 proc := cmd.Process
218 done := make(chan struct{})
219 go func() {
220 select {
221 case <-execCtx.Done():
222 if execCtx.Err() == context.DeadlineExceeded && proc != nil {
223 // Kill the entire process group.
224 syscall.Kill(-proc.Pid, syscall.SIGKILL)
225 }
226 case <-done:
227 }
228 }()
229
230 err := cmd.Wait()
231 close(done)
232
Earl Lee2e463fb2025-04-17 11:22:22 -0700233 longOutput := output.Len() > maxBashOutputLength
234 var outstr string
235 if longOutput {
236 outstr = fmt.Sprintf("output too long: got %v, max is %v\ninitial bytes of output:\n%s",
237 humanizeBytes(output.Len()), humanizeBytes(maxBashOutputLength),
238 output.Bytes()[:1024],
239 )
240 } else {
241 outstr = output.String()
242 }
243
Philip Zeyliger8a1b89a2025-05-13 17:58:41 -0700244 if execCtx.Err() == context.DeadlineExceeded {
245 // Get the partial output that was captured before the timeout
246 partialOutput := output.String()
247 // Truncate if the output is too large
248 if len(partialOutput) > maxBashOutputLength {
249 partialOutput = partialOutput[:maxBashOutputLength] + "\n[output truncated due to size]\n"
250 }
Josh Bleecher Snyder17b2fd92025-07-09 22:47:13 +0000251 return "", fmt.Errorf("command timed out after %s\nCommand output (until it timed out):\n%s", timeout, outstr)
Philip Zeyliger8a1b89a2025-05-13 17:58:41 -0700252 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700253 if err != nil {
254 return "", fmt.Errorf("command failed: %w\n%s", err, outstr)
255 }
256
257 if longOutput {
258 return "", fmt.Errorf("%s", outstr)
259 }
260
261 return output.String(), nil
262}
263
264func humanizeBytes(bytes int) string {
265 switch {
266 case bytes < 4*1024:
267 return fmt.Sprintf("%dB", bytes)
268 case bytes < 1024*1024:
269 kb := int(math.Round(float64(bytes) / 1024.0))
270 return fmt.Sprintf("%dkB", kb)
271 case bytes < 1024*1024*1024:
272 mb := int(math.Round(float64(bytes) / (1024.0 * 1024.0)))
273 return fmt.Sprintf("%dMB", mb)
274 }
275 return "more than 1GB"
276}
Philip Zeyligerb60f0f22025-04-23 18:19:32 +0000277
278// executeBackgroundBash executes a command in the background and returns the pid and output file locations
Josh Bleecher Snyder17b2fd92025-07-09 22:47:13 +0000279func executeBackgroundBash(ctx context.Context, req bashInput, timeout time.Duration) (*BackgroundResult, error) {
Philip Zeyligerb60f0f22025-04-23 18:19:32 +0000280 // Create temporary directory for output files
281 tmpDir, err := os.MkdirTemp("", "sketch-bg-")
282 if err != nil {
283 return nil, fmt.Errorf("failed to create temp directory: %w", err)
284 }
285
286 // Create temp files for stdout and stderr
287 stdoutFile := filepath.Join(tmpDir, "stdout")
288 stderrFile := filepath.Join(tmpDir, "stderr")
289
290 // Prepare the command
291 cmd := exec.Command("bash", "-c", req.Command)
292 cmd.Dir = WorkingDir(ctx)
293 cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
294
Pokey Ruleb6d6d382025-05-07 10:29:03 +0100295 // Set environment with SKETCH=1
296 cmd.Env = append(os.Environ(), "SKETCH=1")
297
Philip Zeyligerb60f0f22025-04-23 18:19:32 +0000298 // Open output files
299 stdout, err := os.Create(stdoutFile)
300 if err != nil {
301 return nil, fmt.Errorf("failed to create stdout file: %w", err)
302 }
303 defer stdout.Close()
304
305 stderr, err := os.Create(stderrFile)
306 if err != nil {
307 return nil, fmt.Errorf("failed to create stderr file: %w", err)
308 }
309 defer stderr.Close()
310
311 // Configure command to use the files
312 cmd.Stdin = nil
313 cmd.Stdout = stdout
314 cmd.Stderr = stderr
315
316 // Start the command
317 if err := cmd.Start(); err != nil {
318 return nil, fmt.Errorf("failed to start background command: %w", err)
319 }
320
321 // Start a goroutine to reap the process when it finishes
322 go func() {
323 cmd.Wait()
324 // Process has been reaped
325 }()
326
327 // Set up timeout handling if a timeout was specified
328 pid := cmd.Process.Pid
Philip Zeyligerb60f0f22025-04-23 18:19:32 +0000329 if timeout > 0 {
330 // Launch a goroutine that will kill the process after the timeout
331 go func() {
Josh Bleecher Snyder56ac6052025-04-24 10:40:55 -0700332 // TODO(josh): this should use a context instead of a sleep, like executeBash above,
333 // to avoid goroutine leaks. Possibly should be partially unified with executeBash.
Philip Zeyligerb60f0f22025-04-23 18:19:32 +0000334 // Sleep for the timeout duration
335 time.Sleep(timeout)
336
337 // TODO(philip): Should we do SIGQUIT and then SIGKILL in 5s?
338
339 // Try to kill the process group
340 killErr := syscall.Kill(-pid, syscall.SIGKILL)
341 if killErr != nil {
342 // If killing the process group fails, try to kill just the process
343 syscall.Kill(pid, syscall.SIGKILL)
344 }
345 }()
346 }
347
348 // Return the process ID and file paths
349 return &BackgroundResult{
350 PID: cmd.Process.Pid,
351 StdoutFile: stdoutFile,
352 StderrFile: stderrFile,
353 }, nil
354}
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000355
Josh Bleecher Snyder495c1fa2025-05-29 00:37:22 +0000356// checkAndInstallMissingTools analyzes a bash command and attempts to automatically install any missing tools.
357func (b *BashTool) checkAndInstallMissingTools(ctx context.Context, command string) error {
358 commands, err := bashkit.ExtractCommands(command)
359 if err != nil {
360 return err
361 }
362
363 autoInstallMu.Lock()
364 defer autoInstallMu.Unlock()
365
366 var missing []string
367 for _, cmd := range commands {
368 if doNotAttemptToolInstall[cmd] {
369 continue
370 }
371 _, err := exec.LookPath(cmd)
372 if err == nil {
373 doNotAttemptToolInstall[cmd] = true // spare future LookPath calls
374 continue
375 }
376 missing = append(missing, cmd)
377 }
378
Josh Bleecher Snyder855afff2025-05-30 08:47:47 -0700379 if len(missing) == 0 {
380 return nil
381 }
382
Josh Bleecher Snyder495c1fa2025-05-29 00:37:22 +0000383 err = b.installTools(ctx, missing)
384 if err != nil {
385 return err
386 }
387 for _, cmd := range missing {
388 doNotAttemptToolInstall[cmd] = true // either it's installed or it's not--either way, we're done with it
389 }
390 return nil
391}
392
Josh Bleecher Snyder495c1fa2025-05-29 00:37:22 +0000393// Command safety check cache to avoid repeated LLM calls
394var (
395 autoInstallMu sync.Mutex
396 doNotAttemptToolInstall = make(map[string]bool) // set to true if the tool should not be auto-installed
397)
398
399// installTools installs missing tools.
400func (b *BashTool) installTools(ctx context.Context, missing []string) error {
401 slog.InfoContext(ctx, "installTools subconvo", "tools", missing)
402
403 info := conversation.ToolCallInfoFromContext(ctx)
404 if info.Convo == nil {
405 return fmt.Errorf("no conversation context available for tool installation")
406 }
407 subConvo := info.Convo.SubConvo()
408 subConvo.Hidden = true
Josh Bleecher Snyder17b2fd92025-07-09 22:47:13 +0000409 subBash := &BashTool{EnableJITInstall: NoBashToolJITInstall}
Josh Bleecher Snyder495c1fa2025-05-29 00:37:22 +0000410
411 done := false
412 doneTool := &llm.Tool{
413 Name: "done",
414 Description: "Call this tool once when finished processing all commands, providing the installation status for each.",
415 InputSchema: json.RawMessage(`{
416 "type": "object",
417 "properties": {
418 "results": {
419 "type": "array",
420 "items": {
421 "type": "object",
422 "properties": {
423 "command_name": {
424 "type": "string",
425 "description": "The name of the command"
426 },
427 "installed": {
428 "type": "boolean",
429 "description": "Whether the command was installed"
430 }
431 },
432 "required": ["command_name", "installed"]
433 }
434 }
435 },
436 "required": ["results"]
437}`),
Josh Bleecher Snyder43b60b92025-07-21 14:57:10 -0700438 Run: func(ctx context.Context, input json.RawMessage) llm.ToolOut {
Josh Bleecher Snyder495c1fa2025-05-29 00:37:22 +0000439 type InstallResult struct {
440 CommandName string `json:"command_name"`
441 Installed bool `json:"installed"`
442 }
443 type DoneInput struct {
444 Results []InstallResult `json:"results"`
445 }
446 var doneInput DoneInput
447 err := json.Unmarshal(input, &doneInput)
448 results := doneInput.Results
449 if err != nil {
450 slog.WarnContext(ctx, "failed to parse install results", "raw", string(input), "error", err)
451 } else {
452 slog.InfoContext(ctx, "auto-tool installation complete", "results", results)
453 }
454 done = true
Josh Bleecher Snyder43b60b92025-07-21 14:57:10 -0700455 return llm.ToolOut{LLMContent: llm.TextContent("")}
Josh Bleecher Snyder495c1fa2025-05-29 00:37:22 +0000456 },
457 }
458
459 subConvo.Tools = []*llm.Tool{
Josh Bleecher Snyder17b2fd92025-07-09 22:47:13 +0000460 subBash.Tool(),
Josh Bleecher Snyder495c1fa2025-05-29 00:37:22 +0000461 doneTool,
462 }
463
464 const autoinstallSystemPrompt = `The assistant powers an entirely automated auto-installer tool.
465
466The user will provide a list of commands that were not found on the system.
467
468The assistant's task:
469
470First, 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.
471
472For each appropriate command:
473
4741. Detect the system's package manager and install the command using standard repositories only (no source builds, no curl|bash installs).
4752. Make a minimal verification attempt (package manager success is sufficient).
4763. If installation fails after reasonable attempts, mark as failed and move on.
477
478Once all commands have been processed, call the "done" tool with the status of each command.
479`
480
481 subConvo.SystemPrompt = autoinstallSystemPrompt
482
483 cmds := new(strings.Builder)
484 cmds.WriteString("<commands>\n")
485 for _, cmd := range missing {
486 cmds.WriteString("<command>")
487 cmds.WriteString(cmd)
488 cmds.WriteString("</command>\n")
489 }
490 cmds.WriteString("</commands>\n")
491
492 resp, err := subConvo.SendUserTextMessage(cmds.String())
493 if err != nil {
494 return err
495 }
496
497 for !done {
498 if resp.StopReason != llm.StopReasonToolUse {
499 return fmt.Errorf("subagent finished without calling tool")
500 }
501
502 ctxWithWorkDir := WithWorkingDir(ctx, WorkingDir(ctx))
503 results, _, err := subConvo.ToolResultContents(ctxWithWorkDir, resp)
504 if err != nil {
505 return err
506 }
507
508 resp, err = subConvo.SendMessage(llm.Message{
509 Role: llm.MessageRoleUser,
510 Content: results,
511 })
512 if err != nil {
513 return err
514 }
515 }
516
517 return nil
518}