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 }