| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1 | package claudetool |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "encoding/json" |
| 7 | "fmt" |
| Josh Bleecher Snyder | 6fe809c | 2025-07-24 16:22:51 -0700 | [diff] [blame^] | 8 | "io" |
| Josh Bleecher Snyder | 495c1fa | 2025-05-29 00:37:22 +0000 | [diff] [blame] | 9 | "log/slog" |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 10 | "math" |
| Philip Zeyliger | b60f0f2 | 2025-04-23 18:19:32 +0000 | [diff] [blame] | 11 | "os" |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 12 | "os/exec" |
| Philip Zeyliger | b60f0f2 | 2025-04-23 18:19:32 +0000 | [diff] [blame] | 13 | "path/filepath" |
| Josh Bleecher Snyder | 6fe809c | 2025-07-24 16:22:51 -0700 | [diff] [blame^] | 14 | "slices" |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 15 | "strings" |
| Josh Bleecher Snyder | 495c1fa | 2025-05-29 00:37:22 +0000 | [diff] [blame] | 16 | "sync" |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 17 | "syscall" |
| 18 | "time" |
| 19 | |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 20 | "sketch.dev/claudetool/bashkit" |
| Josh Bleecher Snyder | 4f84ab7 | 2025-04-22 16:40:54 -0700 | [diff] [blame] | 21 | "sketch.dev/llm" |
| Josh Bleecher Snyder | 495c1fa | 2025-05-29 00:37:22 +0000 | [diff] [blame] | 22 | "sketch.dev/llm/conversation" |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 23 | ) |
| 24 | |
| Josh Bleecher Snyder | d499fd6 | 2025-04-30 01:31:29 +0000 | [diff] [blame] | 25 | // PermissionCallback is a function type for checking if a command is allowed to run |
| 26 | type PermissionCallback func(command string) error |
| 27 | |
| Josh Bleecher Snyder | 17b2fd9 | 2025-07-09 22:47:13 +0000 | [diff] [blame] | 28 | // BashTool specifies a llm.Tool for executing shell commands. |
| Josh Bleecher Snyder | d499fd6 | 2025-04-30 01:31:29 +0000 | [diff] [blame] | 29 | type BashTool struct { |
| 30 | // CheckPermission is called before running any command, if set |
| 31 | CheckPermission PermissionCallback |
| Josh Bleecher Snyder | 495c1fa | 2025-05-29 00:37:22 +0000 | [diff] [blame] | 32 | // EnableJITInstall enables just-in-time tool installation for missing commands |
| 33 | EnableJITInstall bool |
| Josh Bleecher Snyder | 17b2fd9 | 2025-07-09 22:47:13 +0000 | [diff] [blame] | 34 | // Timeouts holds the configurable timeout values (uses defaults if nil) |
| 35 | Timeouts *Timeouts |
| Josh Bleecher Snyder | 6fe809c | 2025-07-24 16:22:51 -0700 | [diff] [blame^] | 36 | // Pwd is the working directory for the tool |
| 37 | Pwd string |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 38 | } |
| 39 | |
| Josh Bleecher Snyder | 495c1fa | 2025-05-29 00:37:22 +0000 | [diff] [blame] | 40 | const ( |
| 41 | EnableBashToolJITInstall = true |
| 42 | NoBashToolJITInstall = false |
| Josh Bleecher Snyder | 17b2fd9 | 2025-07-09 22:47:13 +0000 | [diff] [blame] | 43 | |
| 44 | DefaultFastTimeout = 30 * time.Second |
| 45 | DefaultSlowTimeout = 15 * time.Minute |
| 46 | DefaultBackgroundTimeout = 24 * time.Hour |
| Josh Bleecher Snyder | 495c1fa | 2025-05-29 00:37:22 +0000 | [diff] [blame] | 47 | ) |
| 48 | |
| Josh Bleecher Snyder | 17b2fd9 | 2025-07-09 22:47:13 +0000 | [diff] [blame] | 49 | // Timeouts holds the configurable timeout values for bash commands. |
| 50 | type Timeouts struct { |
| 51 | Fast time.Duration // regular commands (e.g., ls, echo, simple scripts) |
| 52 | Slow time.Duration // commands that may reasonably take longer (e.g., downloads, builds, tests) |
| 53 | Background time.Duration // background commands (e.g., servers, long-running processes) |
| 54 | } |
| Josh Bleecher Snyder | d499fd6 | 2025-04-30 01:31:29 +0000 | [diff] [blame] | 55 | |
| Josh Bleecher Snyder | 17b2fd9 | 2025-07-09 22:47:13 +0000 | [diff] [blame] | 56 | // Fast returns t's fast timeout, or DefaultFastTimeout if t is nil. |
| 57 | func (t *Timeouts) fast() time.Duration { |
| 58 | if t == nil { |
| 59 | return DefaultFastTimeout |
| 60 | } |
| 61 | return t.Fast |
| 62 | } |
| 63 | |
| 64 | // Slow returns t's slow timeout, or DefaultSlowTimeout if t is nil. |
| 65 | func (t *Timeouts) slow() time.Duration { |
| 66 | if t == nil { |
| 67 | return DefaultSlowTimeout |
| 68 | } |
| 69 | return t.Slow |
| 70 | } |
| 71 | |
| 72 | // Background returns t's background timeout, or DefaultBackgroundTimeout if t is nil. |
| 73 | func (t *Timeouts) background() time.Duration { |
| 74 | if t == nil { |
| 75 | return DefaultBackgroundTimeout |
| 76 | } |
| 77 | return t.Background |
| 78 | } |
| 79 | |
| 80 | // Tool returns an llm.Tool based on b. |
| 81 | func (b *BashTool) Tool() *llm.Tool { |
| Josh Bleecher Snyder | 4f84ab7 | 2025-04-22 16:40:54 -0700 | [diff] [blame] | 82 | return &llm.Tool{ |
| Josh Bleecher Snyder | d499fd6 | 2025-04-30 01:31:29 +0000 | [diff] [blame] | 83 | Name: bashName, |
| Josh Bleecher Snyder | 6fe809c | 2025-07-24 16:22:51 -0700 | [diff] [blame^] | 84 | Description: fmt.Sprintf(strings.TrimSpace(bashDescription), b.Pwd), |
| Josh Bleecher Snyder | 4f84ab7 | 2025-04-22 16:40:54 -0700 | [diff] [blame] | 85 | InputSchema: llm.MustSchema(bashInputSchema), |
| Josh Bleecher Snyder | 17b2fd9 | 2025-07-09 22:47:13 +0000 | [diff] [blame] | 86 | Run: b.Run, |
| Josh Bleecher Snyder | d499fd6 | 2025-04-30 01:31:29 +0000 | [diff] [blame] | 87 | } |
| 88 | } |
| 89 | |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 90 | const ( |
| 91 | bashName = "bash" |
| 92 | bashDescription = ` |
| Josh Bleecher Snyder | 17b2fd9 | 2025-07-09 22:47:13 +0000 | [diff] [blame] | 93 | Executes shell commands via bash -c, returning combined stdout/stderr. |
| Josh Bleecher Snyder | 6fe809c | 2025-07-24 16:22:51 -0700 | [diff] [blame^] | 94 | Bash state changes (working dir, variables, aliases) don't persist between calls. |
| Josh Bleecher Snyder | 17b2fd9 | 2025-07-09 22:47:13 +0000 | [diff] [blame] | 95 | |
| Josh Bleecher Snyder | 6fe809c | 2025-07-24 16:22:51 -0700 | [diff] [blame^] | 96 | With background=true, returns immediately, with output redirected to a file. |
| Josh Bleecher Snyder | 17b2fd9 | 2025-07-09 22:47:13 +0000 | [diff] [blame] | 97 | Use background for servers/demos that need to stay running. |
| 98 | |
| 99 | MUST set slow_ok=true for potentially slow commands: builds, downloads, |
| 100 | installs, tests, or any other substantive operation. |
| Josh Bleecher Snyder | 6fe809c | 2025-07-24 16:22:51 -0700 | [diff] [blame^] | 101 | |
| 102 | <pwd>%s</pwd> |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 103 | ` |
| 104 | // If you modify this, update the termui template for prettier rendering. |
| 105 | bashInputSchema = ` |
| 106 | { |
| 107 | "type": "object", |
| 108 | "required": ["command"], |
| 109 | "properties": { |
| 110 | "command": { |
| 111 | "type": "string", |
| Josh Bleecher Snyder | 17b2fd9 | 2025-07-09 22:47:13 +0000 | [diff] [blame] | 112 | "description": "Shell to execute" |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 113 | }, |
| Josh Bleecher Snyder | 17b2fd9 | 2025-07-09 22:47:13 +0000 | [diff] [blame] | 114 | "slow_ok": { |
| 115 | "type": "boolean", |
| 116 | "description": "Use extended timeout" |
| Philip Zeyliger | b60f0f2 | 2025-04-23 18:19:32 +0000 | [diff] [blame] | 117 | }, |
| 118 | "background": { |
| 119 | "type": "boolean", |
| Josh Bleecher Snyder | 17b2fd9 | 2025-07-09 22:47:13 +0000 | [diff] [blame] | 120 | "description": "Execute in background" |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 121 | } |
| 122 | } |
| 123 | } |
| 124 | ` |
| 125 | ) |
| 126 | |
| 127 | type bashInput struct { |
| Philip Zeyliger | b60f0f2 | 2025-04-23 18:19:32 +0000 | [diff] [blame] | 128 | Command string `json:"command"` |
| Josh Bleecher Snyder | 17b2fd9 | 2025-07-09 22:47:13 +0000 | [diff] [blame] | 129 | SlowOK bool `json:"slow_ok,omitempty"` |
| Philip Zeyliger | b60f0f2 | 2025-04-23 18:19:32 +0000 | [diff] [blame] | 130 | Background bool `json:"background,omitempty"` |
| 131 | } |
| 132 | |
| 133 | type BackgroundResult struct { |
| Josh Bleecher Snyder | 6fe809c | 2025-07-24 16:22:51 -0700 | [diff] [blame^] | 134 | PID int |
| 135 | OutFile string |
| 136 | } |
| 137 | |
| 138 | func (r *BackgroundResult) XMLish() string { |
| 139 | return fmt.Sprintf("<pid>%d</pid>\n<output_file>%s</output_file>\n<reminder>To stop the process: `kill -9 -%d`</reminder>\n", |
| 140 | r.PID, r.OutFile, r.PID) |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 141 | } |
| 142 | |
| Josh Bleecher Snyder | 17b2fd9 | 2025-07-09 22:47:13 +0000 | [diff] [blame] | 143 | func (i *bashInput) timeout(t *Timeouts) time.Duration { |
| 144 | switch { |
| 145 | case i.Background: |
| 146 | return t.background() |
| 147 | case i.SlowOK: |
| 148 | return t.slow() |
| 149 | default: |
| 150 | return t.fast() |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 151 | } |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 152 | } |
| 153 | |
| Josh Bleecher Snyder | 43b60b9 | 2025-07-21 14:57:10 -0700 | [diff] [blame] | 154 | func (b *BashTool) Run(ctx context.Context, m json.RawMessage) llm.ToolOut { |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 155 | var req bashInput |
| 156 | if err := json.Unmarshal(m, &req); err != nil { |
| Josh Bleecher Snyder | 43b60b9 | 2025-07-21 14:57:10 -0700 | [diff] [blame] | 157 | return llm.ErrorfToolOut("failed to unmarshal bash command input: %w", err) |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 158 | } |
| Josh Bleecher Snyder | d499fd6 | 2025-04-30 01:31:29 +0000 | [diff] [blame] | 159 | |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 160 | // do a quick permissions check (NOT a security barrier) |
| 161 | err := bashkit.Check(req.Command) |
| 162 | if err != nil { |
| Josh Bleecher Snyder | 43b60b9 | 2025-07-21 14:57:10 -0700 | [diff] [blame] | 163 | return llm.ErrorToolOut(err) |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 164 | } |
| Philip Zeyliger | b60f0f2 | 2025-04-23 18:19:32 +0000 | [diff] [blame] | 165 | |
| Josh Bleecher Snyder | d499fd6 | 2025-04-30 01:31:29 +0000 | [diff] [blame] | 166 | // Custom permission callback if set |
| 167 | if b.CheckPermission != nil { |
| 168 | if err := b.CheckPermission(req.Command); err != nil { |
| Josh Bleecher Snyder | 43b60b9 | 2025-07-21 14:57:10 -0700 | [diff] [blame] | 169 | return llm.ErrorToolOut(err) |
| Josh Bleecher Snyder | d499fd6 | 2025-04-30 01:31:29 +0000 | [diff] [blame] | 170 | } |
| 171 | } |
| 172 | |
| Josh Bleecher Snyder | 495c1fa | 2025-05-29 00:37:22 +0000 | [diff] [blame] | 173 | // Check for missing tools and try to install them if needed, best effort only |
| 174 | if b.EnableJITInstall { |
| 175 | err := b.checkAndInstallMissingTools(ctx, req.Command) |
| 176 | if err != nil { |
| 177 | slog.DebugContext(ctx, "failed to auto-install missing tools", "error", err) |
| 178 | } |
| 179 | } |
| 180 | |
| Josh Bleecher Snyder | 17b2fd9 | 2025-07-09 22:47:13 +0000 | [diff] [blame] | 181 | timeout := req.timeout(b.Timeouts) |
| 182 | |
| Philip Zeyliger | b60f0f2 | 2025-04-23 18:19:32 +0000 | [diff] [blame] | 183 | // If Background is set to true, use executeBackgroundBash |
| 184 | if req.Background { |
| Josh Bleecher Snyder | 6fe809c | 2025-07-24 16:22:51 -0700 | [diff] [blame^] | 185 | result, err := b.executeBackgroundBash(ctx, req, timeout) |
| Philip Zeyliger | b60f0f2 | 2025-04-23 18:19:32 +0000 | [diff] [blame] | 186 | if err != nil { |
| Josh Bleecher Snyder | 43b60b9 | 2025-07-21 14:57:10 -0700 | [diff] [blame] | 187 | return llm.ErrorToolOut(err) |
| Philip Zeyliger | b60f0f2 | 2025-04-23 18:19:32 +0000 | [diff] [blame] | 188 | } |
| Josh Bleecher Snyder | 6fe809c | 2025-07-24 16:22:51 -0700 | [diff] [blame^] | 189 | return llm.ToolOut{LLMContent: llm.TextContent(result.XMLish())} |
| Philip Zeyliger | b60f0f2 | 2025-04-23 18:19:32 +0000 | [diff] [blame] | 190 | } |
| 191 | |
| 192 | // For foreground commands, use executeBash |
| Josh Bleecher Snyder | 6fe809c | 2025-07-24 16:22:51 -0700 | [diff] [blame^] | 193 | out, execErr := b.executeBash(ctx, req, timeout) |
| Philip Zeyliger | 72252cb | 2025-05-10 17:00:08 -0700 | [diff] [blame] | 194 | if execErr != nil { |
| Josh Bleecher Snyder | 43b60b9 | 2025-07-21 14:57:10 -0700 | [diff] [blame] | 195 | return llm.ErrorToolOut(execErr) |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 196 | } |
| Josh Bleecher Snyder | 43b60b9 | 2025-07-21 14:57:10 -0700 | [diff] [blame] | 197 | return llm.ToolOut{LLMContent: llm.TextContent(out)} |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 198 | } |
| 199 | |
| 200 | const maxBashOutputLength = 131072 |
| 201 | |
| Josh Bleecher Snyder | 6fe809c | 2025-07-24 16:22:51 -0700 | [diff] [blame^] | 202 | func (b *BashTool) makeBashCommand(ctx context.Context, command string, out io.Writer) *exec.Cmd { |
| 203 | cmd := exec.CommandContext(ctx, "bash", "-c", command) |
| 204 | cmd.Dir = b.Pwd |
| 205 | cmd.Stdin = nil |
| 206 | cmd.Stdout = out |
| 207 | cmd.Stderr = out |
| 208 | cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} // set up for killing the process group |
| 209 | cmd.Cancel = func() error { |
| 210 | return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) // kill entire process group |
| 211 | } |
| 212 | // Remove SKETCH_MODEL_URL, SKETCH_PUB_KEY, SKETCH_MODEL_API_KEY, |
| 213 | // and any other future SKETCH_ goodies from the environment. |
| 214 | // ...except for SKETCH_PROXY_ID, which is intentionally available. |
| 215 | env := slices.DeleteFunc(os.Environ(), func(s string) bool { |
| 216 | return strings.HasPrefix(s, "SKETCH_") && s != "SKETCH_PROXY_ID" |
| 217 | }) |
| 218 | env = append(env, "SKETCH=1") // signal that this has been run by Sketch, sometimes useful for scripts |
| 219 | env = append(env, "EDITOR=/bin/false") // interactive editors won't work |
| 220 | cmd.Env = env |
| 221 | return cmd |
| 222 | } |
| 223 | |
| 224 | // processGroupHasProcesses reports whether process group pgid contains any processes. |
| 225 | func processGroupHasProcesses(pgid int) bool { |
| 226 | return syscall.Kill(-pgid, 0) == nil |
| 227 | } |
| 228 | |
| 229 | func cmdWait(cmd *exec.Cmd) error { |
| 230 | pgid := cmd.Process.Pid |
| 231 | err := cmd.Wait() |
| 232 | // After Wait, if there were misbehaved children, |
| 233 | // and the process group is still around, |
| 234 | // (if the process died via some means other than context cancellation), |
| 235 | // we need to (re-)kill the process group, not just the process. |
| 236 | // Tiny logical race here--we could snipe some other process--but |
| 237 | // it is extremely unlikely in practice, because our process just died, |
| 238 | // so it almost certainly hasn't had its PID recycled yet. |
| 239 | if processGroupHasProcesses(pgid) { |
| 240 | _ = syscall.Kill(-pgid, syscall.SIGKILL) |
| 241 | } |
| 242 | |
| 243 | // Clean up any zombie processes that may have been left behind. |
| 244 | reapZombies(pgid) |
| 245 | |
| 246 | return err |
| 247 | } |
| 248 | |
| 249 | func (b *BashTool) executeBash(ctx context.Context, req bashInput, timeout time.Duration) (string, error) { |
| Josh Bleecher Snyder | 17b2fd9 | 2025-07-09 22:47:13 +0000 | [diff] [blame] | 250 | execCtx, cancel := context.WithTimeout(ctx, timeout) |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 251 | defer cancel() |
| 252 | |
| Josh Bleecher Snyder | 6fe809c | 2025-07-24 16:22:51 -0700 | [diff] [blame^] | 253 | output := new(bytes.Buffer) |
| 254 | cmd := b.makeBashCommand(execCtx, req.Command, output) |
| 255 | // TODO: maybe detect simple interactive git rebase commands and auto-background them? |
| 256 | // Would need to hint to the agent what is happening. |
| 257 | // We might also be able to do this for other simple interactive commands that use EDITOR. |
| 258 | cmd.Env = append(cmd.Env, `GIT_SEQUENCE_EDITOR=echo "To do an interactive rebase, run it as a background task and check the output file." && exit 1`) |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 259 | if err := cmd.Start(); err != nil { |
| 260 | return "", fmt.Errorf("command failed: %w", err) |
| 261 | } |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 262 | |
| Josh Bleecher Snyder | 6fe809c | 2025-07-24 16:22:51 -0700 | [diff] [blame^] | 263 | err := cmdWait(cmd) |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 264 | |
| Josh Bleecher Snyder | 6fe809c | 2025-07-24 16:22:51 -0700 | [diff] [blame^] | 265 | out := output.String() |
| 266 | out = formatForegroundBashOutput(out) |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 267 | |
| Philip Zeyliger | 8a1b89a | 2025-05-13 17:58:41 -0700 | [diff] [blame] | 268 | if execCtx.Err() == context.DeadlineExceeded { |
| Josh Bleecher Snyder | 6fe809c | 2025-07-24 16:22:51 -0700 | [diff] [blame^] | 269 | return "", fmt.Errorf("[command timed out after %s, showing output until timeout]\n%s", timeout, out) |
| Philip Zeyliger | 8a1b89a | 2025-05-13 17:58:41 -0700 | [diff] [blame] | 270 | } |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 271 | if err != nil { |
| Josh Bleecher Snyder | 6fe809c | 2025-07-24 16:22:51 -0700 | [diff] [blame^] | 272 | return "", fmt.Errorf("[command failed: %w]\n%s", err, out) |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 273 | } |
| 274 | |
| Josh Bleecher Snyder | 6fe809c | 2025-07-24 16:22:51 -0700 | [diff] [blame^] | 275 | return out, nil |
| 276 | } |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 277 | |
| Josh Bleecher Snyder | 6fe809c | 2025-07-24 16:22:51 -0700 | [diff] [blame^] | 278 | // formatForegroundBashOutput formats the output of a foreground bash command for display to the agent. |
| 279 | func formatForegroundBashOutput(out string) string { |
| 280 | if len(out) > maxBashOutputLength { |
| 281 | const snipSize = 4096 |
| 282 | out = fmt.Sprintf("[output truncated in middle: got %v, max is %v]\n%s\n\n[snip]\n\n%s", |
| 283 | humanizeBytes(len(out)), humanizeBytes(maxBashOutputLength), |
| 284 | out[:snipSize], out[len(out)-snipSize:], |
| 285 | ) |
| 286 | } |
| 287 | return out |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 288 | } |
| 289 | |
| 290 | func humanizeBytes(bytes int) string { |
| 291 | switch { |
| 292 | case bytes < 4*1024: |
| 293 | return fmt.Sprintf("%dB", bytes) |
| 294 | case bytes < 1024*1024: |
| 295 | kb := int(math.Round(float64(bytes) / 1024.0)) |
| 296 | return fmt.Sprintf("%dkB", kb) |
| 297 | case bytes < 1024*1024*1024: |
| 298 | mb := int(math.Round(float64(bytes) / (1024.0 * 1024.0))) |
| 299 | return fmt.Sprintf("%dMB", mb) |
| 300 | } |
| 301 | return "more than 1GB" |
| 302 | } |
| Philip Zeyliger | b60f0f2 | 2025-04-23 18:19:32 +0000 | [diff] [blame] | 303 | |
| 304 | // executeBackgroundBash executes a command in the background and returns the pid and output file locations |
| Josh Bleecher Snyder | 6fe809c | 2025-07-24 16:22:51 -0700 | [diff] [blame^] | 305 | func (b *BashTool) executeBackgroundBash(ctx context.Context, req bashInput, timeout time.Duration) (*BackgroundResult, error) { |
| 306 | // Create temp output files |
| Philip Zeyliger | b60f0f2 | 2025-04-23 18:19:32 +0000 | [diff] [blame] | 307 | tmpDir, err := os.MkdirTemp("", "sketch-bg-") |
| 308 | if err != nil { |
| 309 | return nil, fmt.Errorf("failed to create temp directory: %w", err) |
| 310 | } |
| Josh Bleecher Snyder | 6fe809c | 2025-07-24 16:22:51 -0700 | [diff] [blame^] | 311 | // We can't really clean up tempDir, because we have no idea |
| 312 | // how far into the future the agent might want to read the output. |
| Philip Zeyliger | b60f0f2 | 2025-04-23 18:19:32 +0000 | [diff] [blame] | 313 | |
| Josh Bleecher Snyder | 6fe809c | 2025-07-24 16:22:51 -0700 | [diff] [blame^] | 314 | outFile := filepath.Join(tmpDir, "output") |
| 315 | out, err := os.Create(outFile) |
| Philip Zeyliger | b60f0f2 | 2025-04-23 18:19:32 +0000 | [diff] [blame] | 316 | if err != nil { |
| Josh Bleecher Snyder | 6fe809c | 2025-07-24 16:22:51 -0700 | [diff] [blame^] | 317 | return nil, fmt.Errorf("failed to create output file: %w", err) |
| Philip Zeyliger | b60f0f2 | 2025-04-23 18:19:32 +0000 | [diff] [blame] | 318 | } |
| Philip Zeyliger | b60f0f2 | 2025-04-23 18:19:32 +0000 | [diff] [blame] | 319 | |
| Josh Bleecher Snyder | 6fe809c | 2025-07-24 16:22:51 -0700 | [diff] [blame^] | 320 | execCtx, cancel := context.WithTimeout(context.Background(), timeout) // detach from tool use context |
| 321 | cmd := b.makeBashCommand(execCtx, req.Command, out) |
| 322 | cmd.Env = append(cmd.Env, `GIT_SEQUENCE_EDITOR=python3 -c "import os, sys, signal, threading; print(f\"Send USR1 to pid {os.getpid()} after editing {sys.argv[1]}\", flush=True); signal.signal(signal.SIGUSR1, lambda *_: sys.exit(0)); threading.Event().wait()"`) |
| Philip Zeyliger | b60f0f2 | 2025-04-23 18:19:32 +0000 | [diff] [blame] | 323 | |
| Philip Zeyliger | b60f0f2 | 2025-04-23 18:19:32 +0000 | [diff] [blame] | 324 | if err := cmd.Start(); err != nil { |
| Josh Bleecher Snyder | 6fe809c | 2025-07-24 16:22:51 -0700 | [diff] [blame^] | 325 | cancel() |
| 326 | out.Close() |
| 327 | os.RemoveAll(tmpDir) // clean up temp dir -- didn't start means we don't need the output |
| Philip Zeyliger | b60f0f2 | 2025-04-23 18:19:32 +0000 | [diff] [blame] | 328 | return nil, fmt.Errorf("failed to start background command: %w", err) |
| 329 | } |
| 330 | |
| Josh Bleecher Snyder | 6fe809c | 2025-07-24 16:22:51 -0700 | [diff] [blame^] | 331 | // Wait for completion in the background, then do cleanup. |
| Philip Zeyliger | b60f0f2 | 2025-04-23 18:19:32 +0000 | [diff] [blame] | 332 | go func() { |
| Josh Bleecher Snyder | 6fe809c | 2025-07-24 16:22:51 -0700 | [diff] [blame^] | 333 | err := cmdWait(cmd) |
| 334 | // Leave a note to the agent so that it knows that the process has finished. |
| 335 | if err != nil { |
| 336 | fmt.Fprintf(out, "\n\n[background process failed: %v]\n", err) |
| 337 | } else { |
| 338 | fmt.Fprintf(out, "\n\n[background process completed]\n") |
| 339 | } |
| 340 | out.Close() |
| 341 | cancel() |
| Philip Zeyliger | b60f0f2 | 2025-04-23 18:19:32 +0000 | [diff] [blame] | 342 | }() |
| 343 | |
| Philip Zeyliger | b60f0f2 | 2025-04-23 18:19:32 +0000 | [diff] [blame] | 344 | return &BackgroundResult{ |
| Josh Bleecher Snyder | 6fe809c | 2025-07-24 16:22:51 -0700 | [diff] [blame^] | 345 | PID: cmd.Process.Pid, |
| 346 | OutFile: outFile, |
| Philip Zeyliger | b60f0f2 | 2025-04-23 18:19:32 +0000 | [diff] [blame] | 347 | }, nil |
| 348 | } |
| Josh Bleecher Snyder | d499fd6 | 2025-04-30 01:31:29 +0000 | [diff] [blame] | 349 | |
| Josh Bleecher Snyder | 495c1fa | 2025-05-29 00:37:22 +0000 | [diff] [blame] | 350 | // checkAndInstallMissingTools analyzes a bash command and attempts to automatically install any missing tools. |
| 351 | func (b *BashTool) checkAndInstallMissingTools(ctx context.Context, command string) error { |
| 352 | commands, err := bashkit.ExtractCommands(command) |
| 353 | if err != nil { |
| 354 | return err |
| 355 | } |
| 356 | |
| 357 | autoInstallMu.Lock() |
| 358 | defer autoInstallMu.Unlock() |
| 359 | |
| 360 | var missing []string |
| 361 | for _, cmd := range commands { |
| 362 | if doNotAttemptToolInstall[cmd] { |
| 363 | continue |
| 364 | } |
| 365 | _, err := exec.LookPath(cmd) |
| 366 | if err == nil { |
| 367 | doNotAttemptToolInstall[cmd] = true // spare future LookPath calls |
| 368 | continue |
| 369 | } |
| 370 | missing = append(missing, cmd) |
| 371 | } |
| 372 | |
| Josh Bleecher Snyder | 855afff | 2025-05-30 08:47:47 -0700 | [diff] [blame] | 373 | if len(missing) == 0 { |
| 374 | return nil |
| 375 | } |
| 376 | |
| Josh Bleecher Snyder | 495c1fa | 2025-05-29 00:37:22 +0000 | [diff] [blame] | 377 | for _, cmd := range missing { |
| Josh Bleecher Snyder | 6fe809c | 2025-07-24 16:22:51 -0700 | [diff] [blame^] | 378 | err := b.installTool(ctx, cmd) |
| 379 | if err != nil { |
| 380 | slog.WarnContext(ctx, "failed to install tool", "tool", cmd, "error", err) |
| 381 | } |
| Josh Bleecher Snyder | 495c1fa | 2025-05-29 00:37:22 +0000 | [diff] [blame] | 382 | doNotAttemptToolInstall[cmd] = true // either it's installed or it's not--either way, we're done with it |
| 383 | } |
| 384 | return nil |
| 385 | } |
| 386 | |
| Josh Bleecher Snyder | 495c1fa | 2025-05-29 00:37:22 +0000 | [diff] [blame] | 387 | // Command safety check cache to avoid repeated LLM calls |
| 388 | var ( |
| 389 | autoInstallMu sync.Mutex |
| 390 | doNotAttemptToolInstall = make(map[string]bool) // set to true if the tool should not be auto-installed |
| 391 | ) |
| 392 | |
| Josh Bleecher Snyder | 6fe809c | 2025-07-24 16:22:51 -0700 | [diff] [blame^] | 393 | // autodetectPackageManager returns the first package‑manager binary |
| 394 | // found in PATH, or an empty string if none are present. |
| 395 | func autodetectPackageManager() string { |
| 396 | // TODO: cache this result with a sync.OnceValue |
| Josh Bleecher Snyder | 495c1fa | 2025-05-29 00:37:22 +0000 | [diff] [blame] | 397 | |
| Josh Bleecher Snyder | 6fe809c | 2025-07-24 16:22:51 -0700 | [diff] [blame^] | 398 | managers := []string{ |
| 399 | "apt", "apt-get", // Debian/Ubuntu |
| 400 | "brew", "port", // macOS (Homebrew / MacPorts) |
| 401 | "apk", // Alpine |
| 402 | "yum", "dnf", // RHEL/Fedora |
| 403 | "pacman", // Arch |
| 404 | "zypper", // openSUSE |
| 405 | "xbps-install", // Void |
| 406 | "emerge", // Gentoo |
| 407 | "nix-env", "guix", // NixOS / Guix |
| 408 | "pkg", // FreeBSD |
| 409 | "slackpkg", // Slackware |
| 410 | } |
| 411 | |
| 412 | for _, m := range managers { |
| 413 | if _, err := exec.LookPath(m); err == nil { |
| 414 | return m |
| 415 | } |
| 416 | } |
| 417 | return "" |
| 418 | } |
| 419 | |
| 420 | // installTool attempts to install a single missing tool using LLM validation and system package manager. |
| 421 | func (b *BashTool) installTool(ctx context.Context, cmd string) error { |
| 422 | slog.InfoContext(ctx, "attempting to install tool", "tool", cmd) |
| 423 | |
| 424 | packageManager := autodetectPackageManager() |
| 425 | if packageManager == "" { |
| 426 | return fmt.Errorf("no known package manager found in PATH") |
| 427 | } |
| Josh Bleecher Snyder | 495c1fa | 2025-05-29 00:37:22 +0000 | [diff] [blame] | 428 | info := conversation.ToolCallInfoFromContext(ctx) |
| 429 | if info.Convo == nil { |
| 430 | return fmt.Errorf("no conversation context available for tool installation") |
| 431 | } |
| 432 | subConvo := info.Convo.SubConvo() |
| 433 | subConvo.Hidden = true |
| Josh Bleecher Snyder | 6fe809c | 2025-07-24 16:22:51 -0700 | [diff] [blame^] | 434 | subConvo.SystemPrompt = "You are an expert in software developer tools." |
| Josh Bleecher Snyder | 495c1fa | 2025-05-29 00:37:22 +0000 | [diff] [blame] | 435 | |
| Josh Bleecher Snyder | 6fe809c | 2025-07-24 16:22:51 -0700 | [diff] [blame^] | 436 | query := fmt.Sprintf(`Do you know this command/package/tool? Is it legitimate, clearly non-harmful, and commonly used? Can it be installed with package manager %s? |
| Josh Bleecher Snyder | 495c1fa | 2025-05-29 00:37:22 +0000 | [diff] [blame] | 437 | |
| Josh Bleecher Snyder | 6fe809c | 2025-07-24 16:22:51 -0700 | [diff] [blame^] | 438 | Command: %s |
| Josh Bleecher Snyder | 495c1fa | 2025-05-29 00:37:22 +0000 | [diff] [blame] | 439 | |
| Josh Bleecher Snyder | 6fe809c | 2025-07-24 16:22:51 -0700 | [diff] [blame^] | 440 | - YES: Respond ONLY with the package name used to install it |
| 441 | - NO or UNSURE: Respond ONLY with the word NO`, packageManager, cmd) |
| Josh Bleecher Snyder | 495c1fa | 2025-05-29 00:37:22 +0000 | [diff] [blame] | 442 | |
| Josh Bleecher Snyder | 6fe809c | 2025-07-24 16:22:51 -0700 | [diff] [blame^] | 443 | resp, err := subConvo.SendUserTextMessage(query) |
| Josh Bleecher Snyder | 495c1fa | 2025-05-29 00:37:22 +0000 | [diff] [blame] | 444 | if err != nil { |
| Josh Bleecher Snyder | 6fe809c | 2025-07-24 16:22:51 -0700 | [diff] [blame^] | 445 | return fmt.Errorf("failed to validate tool with LLM: %w", err) |
| Josh Bleecher Snyder | 495c1fa | 2025-05-29 00:37:22 +0000 | [diff] [blame] | 446 | } |
| 447 | |
| Josh Bleecher Snyder | 6fe809c | 2025-07-24 16:22:51 -0700 | [diff] [blame^] | 448 | if len(resp.Content) == 0 { |
| 449 | return fmt.Errorf("empty response from LLM for tool validation") |
| 450 | } |
| Josh Bleecher Snyder | 495c1fa | 2025-05-29 00:37:22 +0000 | [diff] [blame] | 451 | |
| Josh Bleecher Snyder | 6fe809c | 2025-07-24 16:22:51 -0700 | [diff] [blame^] | 452 | response := strings.TrimSpace(resp.Content[0].Text) |
| 453 | if response == "NO" || response == "UNSURE" { |
| 454 | slog.InfoContext(ctx, "tool installation declined by LLM", "tool", cmd, "response", response) |
| 455 | return fmt.Errorf("tool %s not approved for installation", cmd) |
| 456 | } |
| Josh Bleecher Snyder | 495c1fa | 2025-05-29 00:37:22 +0000 | [diff] [blame] | 457 | |
| Josh Bleecher Snyder | 6fe809c | 2025-07-24 16:22:51 -0700 | [diff] [blame^] | 458 | packageName := strings.TrimSpace(response) |
| 459 | if packageName == "" { |
| 460 | return fmt.Errorf("no package name provided for tool %s", cmd) |
| 461 | } |
| 462 | |
| 463 | // Install the package (with update command first if needed) |
| 464 | // TODO: these invocations create zombies when we are PID 1. |
| 465 | // We should give them the same zombie-reaping treatment as above, |
| 466 | // if/when we care enough to put in the effort. Not today. |
| 467 | var updateCmd, installCmd string |
| 468 | switch packageManager { |
| 469 | case "apt", "apt-get": |
| 470 | updateCmd = fmt.Sprintf("sudo %s update", packageManager) |
| 471 | installCmd = fmt.Sprintf("sudo %s install -y %s", packageManager, packageName) |
| 472 | case "brew": |
| 473 | // brew handles updates automatically, no explicit update needed |
| 474 | installCmd = fmt.Sprintf("brew install %s", packageName) |
| 475 | case "apk": |
| 476 | updateCmd = "sudo apk update" |
| 477 | installCmd = fmt.Sprintf("sudo apk add %s", packageName) |
| 478 | case "yum", "dnf": |
| 479 | // For yum/dnf, we don't need a separate update command as the package cache is usually fresh enough |
| 480 | // and install will fetch the latest available packages |
| 481 | installCmd = fmt.Sprintf("sudo %s install -y %s", packageManager, packageName) |
| 482 | case "pacman": |
| 483 | updateCmd = "sudo pacman -Sy" |
| 484 | installCmd = fmt.Sprintf("sudo pacman -S --noconfirm %s", packageName) |
| 485 | case "zypper": |
| 486 | updateCmd = "sudo zypper refresh" |
| 487 | installCmd = fmt.Sprintf("sudo zypper install -y %s", packageName) |
| 488 | case "xbps-install": |
| 489 | updateCmd = "sudo xbps-install -S" |
| 490 | installCmd = fmt.Sprintf("sudo xbps-install -y %s", packageName) |
| 491 | case "emerge": |
| 492 | // Note: emerge --sync is expensive, so we skip it for JIT installs |
| 493 | // Users should manually sync if needed |
| 494 | installCmd = fmt.Sprintf("sudo emerge %s", packageName) |
| 495 | case "nix-env": |
| 496 | // nix-env doesn't require explicit updates for JIT installs |
| 497 | installCmd = fmt.Sprintf("nix-env -i %s", packageName) |
| 498 | case "guix": |
| 499 | // guix doesn't require explicit updates for JIT installs |
| 500 | installCmd = fmt.Sprintf("guix install %s", packageName) |
| 501 | case "pkg": |
| 502 | updateCmd = "sudo pkg update" |
| 503 | installCmd = fmt.Sprintf("sudo pkg install -y %s", packageName) |
| 504 | case "slackpkg": |
| 505 | updateCmd = "sudo slackpkg update" |
| 506 | installCmd = fmt.Sprintf("sudo slackpkg install %s", packageName) |
| 507 | default: |
| 508 | return fmt.Errorf("unsupported package manager: %s", packageManager) |
| 509 | } |
| 510 | |
| 511 | slog.InfoContext(ctx, "installing tool", "tool", cmd, "package", packageName, "update_command", updateCmd, "install_command", installCmd) |
| 512 | |
| 513 | // Execute the update command first if needed |
| 514 | if updateCmd != "" { |
| 515 | slog.InfoContext(ctx, "updating package cache", "command", updateCmd) |
| 516 | updateCmdExec := exec.CommandContext(ctx, "sh", "-c", updateCmd) |
| 517 | updateOutput, err := updateCmdExec.CombinedOutput() |
| Josh Bleecher Snyder | 495c1fa | 2025-05-29 00:37:22 +0000 | [diff] [blame] | 518 | if err != nil { |
| Josh Bleecher Snyder | 6fe809c | 2025-07-24 16:22:51 -0700 | [diff] [blame^] | 519 | slog.WarnContext(ctx, "package cache update failed, proceeding with install anyway", "error", err, "output", string(updateOutput)) |
| Josh Bleecher Snyder | 495c1fa | 2025-05-29 00:37:22 +0000 | [diff] [blame] | 520 | } |
| 521 | } |
| 522 | |
| Josh Bleecher Snyder | 6fe809c | 2025-07-24 16:22:51 -0700 | [diff] [blame^] | 523 | // Execute the install command |
| 524 | cmdExec := exec.CommandContext(ctx, "sh", "-c", installCmd) |
| 525 | output, err := cmdExec.CombinedOutput() |
| 526 | if err != nil { |
| 527 | return fmt.Errorf("failed to install %s: %w\nOutput: %s", packageName, err, string(output)) |
| 528 | } |
| 529 | |
| 530 | slog.InfoContext(ctx, "tool installation successful", "tool", cmd, "package", packageName) |
| Josh Bleecher Snyder | 495c1fa | 2025-05-29 00:37:22 +0000 | [diff] [blame] | 531 | return nil |
| 532 | } |