nub/internal/tool/builtin/bash.go
Tom a97013d876 initial commit
- v 0.1.0 siehe CHANGELOG.md
2026-07-25 11:08:01 +02:00

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
}