152 lines
3.3 KiB
Go
152 lines
3.3 KiB
Go
package builtin
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"os/exec"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
"time"
|
|
|
|
"nub/internal/tool"
|
|
)
|
|
|
|
const (
|
|
defaultBashTimeout = 120 * time.Second
|
|
maxBashOutput = 30000
|
|
)
|
|
|
|
type BashTool struct{}
|
|
|
|
func (BashTool) Name() string { return "bash" }
|
|
func (BashTool) Description() string {
|
|
return "Führt einen Shell-Befehl aus. Timeout, Output wird bei ~30k Zeichen in der Mitte gekürzt."
|
|
}
|
|
|
|
func (BashTool) Schema() json.RawMessage {
|
|
return json.RawMessage(`{
|
|
"type": "object",
|
|
"properties": {
|
|
"command": {"type": "string"},
|
|
"timeout_seconds": {"type": "integer", "description": "Default 120"}
|
|
},
|
|
"required": ["command"]
|
|
}`)
|
|
}
|
|
|
|
type bashInput struct {
|
|
Command string `json:"command"`
|
|
TimeoutSeconds int `json:"timeout_seconds"`
|
|
}
|
|
|
|
func (BashTool) Run(ctx context.Context, input json.RawMessage, env tool.Env) (tool.Result, error) {
|
|
var in bashInput
|
|
if err := json.Unmarshal(input, &in); err != nil {
|
|
return tool.Result{ForModel: "invalid input: " + err.Error(), IsError: true}, nil
|
|
}
|
|
|
|
timeout := defaultBashTimeout
|
|
if in.TimeoutSeconds > 0 {
|
|
timeout = time.Duration(in.TimeoutSeconds) * time.Second
|
|
}
|
|
runCtx, cancel := context.WithTimeout(ctx, timeout)
|
|
defer cancel()
|
|
|
|
cmd := exec.CommandContext(runCtx, "/bin/sh", "-c", in.Command)
|
|
cmd.Dir = env.RepoRoot
|
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
|
// CommandContext killt bei Timeout nur den direkten Prozess; wir killen
|
|
// stattdessen die ganze Prozessgruppe (siehe Cancel unten), damit keine
|
|
// Kindprozesse überleben.
|
|
cmd.Cancel = func() error {
|
|
if cmd.Process == nil {
|
|
return nil
|
|
}
|
|
return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
|
|
}
|
|
|
|
stdout, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
return tool.Result{ForModel: err.Error(), IsError: true}, nil
|
|
}
|
|
cmd.Stderr = cmd.Stdout
|
|
|
|
var buf strings.Builder
|
|
var mu sync.Mutex
|
|
truncated := false
|
|
|
|
if err := cmd.Start(); err != nil {
|
|
return tool.Result{ForModel: fmt.Sprintf("bash: %v", err), IsError: true}, nil
|
|
}
|
|
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
reader := bufio.NewReader(stdout)
|
|
chunk := make([]byte, 4096)
|
|
for {
|
|
n, rerr := reader.Read(chunk)
|
|
if n > 0 {
|
|
text := string(chunk[:n])
|
|
mu.Lock()
|
|
buf.WriteString(text)
|
|
mu.Unlock()
|
|
if env.Emit != nil {
|
|
env.Emit(tool.ToolStream{Text: text})
|
|
}
|
|
}
|
|
if rerr == io.EOF {
|
|
return
|
|
}
|
|
if rerr != nil {
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
|
|
<-done
|
|
waitErr := cmd.Wait()
|
|
|
|
mu.Lock()
|
|
out := buf.String()
|
|
mu.Unlock()
|
|
|
|
if len(out) > maxBashOutput {
|
|
half := maxBashOutput / 2
|
|
out = out[:half] + "\n... [truncated] ...\n" + out[len(out)-half:]
|
|
truncated = true
|
|
}
|
|
|
|
if runCtx.Err() == context.DeadlineExceeded {
|
|
return tool.Result{
|
|
ForModel: fmt.Sprintf("command timed out after %s\n%s", timeout, out),
|
|
IsError: true,
|
|
}, nil
|
|
}
|
|
|
|
exitCode := 0
|
|
if waitErr != nil {
|
|
if exitErr, ok := waitErr.(*exec.ExitError); ok {
|
|
exitCode = exitErr.ExitCode()
|
|
} else {
|
|
return tool.Result{ForModel: fmt.Sprintf("bash: %v", waitErr), IsError: true}, nil
|
|
}
|
|
}
|
|
|
|
result := tool.Result{
|
|
ForModel: out,
|
|
ForUI: BashResult{Command: in.Command, ExitCode: exitCode, Truncated: truncated},
|
|
IsError: exitCode != 0,
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
type BashResult struct {
|
|
Command string
|
|
ExitCode int
|
|
Truncated bool
|
|
}
|