zot/internal/agent/tools/bash.go
2026-04-17 20:36:38 +02:00

207 lines
4.7 KiB
Go

package tools
import (
"bytes"
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"syscall"
"time"
"github.com/patriceckhart/zot/internal/core"
"github.com/patriceckhart/zot/internal/provider"
)
const (
maxBashLines = 2000
maxBashBytes = 50 * 1024
)
// BashTool runs a shell command in the agent's cwd.
type BashTool struct {
CWD string
Sandbox *Sandbox
}
type bashArgs struct {
Command string `json:"command"`
Timeout int `json:"timeout,omitempty"`
}
const bashSchema = `{
"type":"object",
"properties":{
"command":{"type":"string","description":"Shell command to execute."},
"timeout":{"type":"integer","description":"Timeout in seconds. No default timeout."}
},
"required":["command"],
"additionalProperties":false
}`
func (t *BashTool) Name() string { return "bash" }
func (t *BashTool) Description() string { return "Run a shell command. stdout and stderr are merged." }
func (t *BashTool) Schema() json.RawMessage { return json.RawMessage(bashSchema) }
func (t *BashTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) {
var a bashArgs
if err := json.Unmarshal(raw, &a); err != nil {
return core.ToolResult{}, fmt.Errorf("invalid args: %w", err)
}
if strings.TrimSpace(a.Command) == "" {
return core.ToolResult{}, fmt.Errorf("command is required")
}
if err := t.Sandbox.CheckCommand(a.Command); err != nil {
return core.ToolResult{}, err
}
cwd := t.CWD
if cwd == "" {
cwd, _ = os.Getwd()
}
runCtx := ctx
var cancel context.CancelFunc
if a.Timeout > 0 {
runCtx, cancel = context.WithTimeout(ctx, time.Duration(a.Timeout)*time.Second)
defer cancel()
}
cmd := newShellCmd(runCtx, a.Command)
cmd.Dir = cwd
cmd.Env = os.Environ()
// Capture merged stdout+stderr with line-by-line streaming.
pr, pw := io.Pipe()
cmd.Stdout = pw
cmd.Stderr = pw
if err := cmd.Start(); err != nil {
return core.ToolResult{}, fmt.Errorf("start: %w", err)
}
// Writer to both the buffer (trimmed) and progress callback.
captured := &bytes.Buffer{}
done := make(chan struct{})
go func() {
defer close(done)
buf := make([]byte, 4096)
for {
n, err := pr.Read(buf)
if n > 0 {
chunk := buf[:n]
if captured.Len() < maxBashBytes {
room := maxBashBytes - captured.Len()
if n > room {
captured.Write(chunk[:room])
} else {
captured.Write(chunk)
}
}
if progress != nil {
progress(string(chunk))
}
}
if err != nil {
return
}
}
}()
waitErr := cmd.Wait()
pw.Close()
<-done
// On hard cancel, try to kill the process group.
if ctx.Err() != nil && cmd.Process != nil {
killProcessGroup(cmd)
}
output := captured.String()
truncBytes := captured.Len() >= maxBashBytes
lines := strings.Split(output, "\n")
truncLines := false
if len(lines) > maxBashLines {
lines = lines[:maxBashLines]
truncLines = true
}
trimmed := strings.Join(lines, "\n")
exitCode := 0
if waitErr != nil {
if ee, ok := waitErr.(*exec.ExitError); ok {
exitCode = ee.ExitCode()
} else {
exitCode = -1
}
}
var sb strings.Builder
if trimmed != "" {
sb.WriteString(trimmed)
if !strings.HasSuffix(trimmed, "\n") {
sb.WriteString("\n")
}
}
if truncLines {
fmt.Fprintf(&sb, "... [truncated at %d lines]\n", maxBashLines)
}
if truncBytes {
fmt.Fprintf(&sb, "... [truncated at %d bytes]\n", maxBashBytes)
}
sb.WriteString(fmt.Sprintf("[exit %d]", exitCode))
var fullPath string
if truncBytes || truncLines {
fullPath = writeFullOutput(output)
if fullPath != "" {
fmt.Fprintf(&sb, " (full output: %s)", fullPath)
}
}
isErr := exitCode != 0 || ctx.Err() != nil
return core.ToolResult{
Content: []provider.Content{provider.TextBlock{Text: sb.String()}},
IsError: isErr,
Details: map[string]any{
"exit_code": exitCode,
"full_output_path": fullPath,
"lines_truncated": truncLines,
"bytes_truncated": truncBytes,
},
}, nil
}
func writeFullOutput(s string) string {
b := make([]byte, 6)
_, _ = rand.Read(b)
name := filepath.Join(os.TempDir(), "zot-bash-"+hex.EncodeToString(b)+".log")
if err := os.WriteFile(name, []byte(s), 0o600); err != nil {
return ""
}
return name
}
func newShellCmd(ctx context.Context, command string) *exec.Cmd {
if runtime.GOOS == "windows" {
return exec.CommandContext(ctx, "cmd", "/C", command)
}
return exec.CommandContext(ctx, "/bin/sh", "-c", command)
}
// killProcessGroup best-effort SIGTERM then SIGKILL.
func killProcessGroup(cmd *exec.Cmd) {
if cmd.Process == nil {
return
}
_ = cmd.Process.Signal(syscall.SIGTERM)
time.AfterFunc(3*time.Second, func() {
_ = cmd.Process.Kill()
})
}