v0.50.1 a637010

bash.go RawHistoryFolder

1package tools
2
3import (
4	"bytes"
5	"context"
6	"crypto/rand"
7	"encoding/hex"
8	"encoding/json"
9	"errors"
10	"fmt"
11	"io"
12	"os"
13	"os/exec"
14	"path/filepath"
15	"runtime"
16	"strings"
17	"time"
18
19	"github.com/bnema/zut/packages/core"
20	"github.com/bnema/zut/packages/provider"
21)
22
23const (
24	maxBashLines = 2000
25	maxBashBytes = 50 * 1024
26
27	// maxBashTimeoutSeconds is the largest whole-second timeout that can be
28	// converted to a time.Duration without overflowing it.
29	maxBashTimeoutSeconds int64 = int64(time.Duration(1<<63-1) / time.Second)
30)
31
32// BashTool runs a shell command in the agent's cwd.
33type BashTool struct {
34	CWD     string
35	Sandbox *Sandbox
36}
37
38type bashArgs struct {
39	Command string `json:"command"`
40	Timeout *int64 `json:"timeout"`
41}
42
43var bashSchema = fmt.Sprintf(`{"type":"object","properties":{"command":{"type":"string"},"timeout":{"type":"integer","minimum":1,"maximum":%d,"description":"Maximum command runtime in seconds."}},"required":["command","timeout"]}`, maxBashTimeoutSeconds)
44
45func (t *BashTool) Name() string            { return "bash" }
46func (t *BashTool) Description() string     { return shellDescription(currentShell()) }
47func (t *BashTool) Schema() json.RawMessage { return json.RawMessage(bashSchema) }
48
49func bashTimeoutDuration(seconds int64) (time.Duration, error) {
50	if seconds < 1 || seconds > maxBashTimeoutSeconds {
51		return 0, fmt.Errorf("timeout must be between 1 and %d seconds", maxBashTimeoutSeconds)
52	}
53	return time.Duration(seconds) * time.Second, nil
54}
55
56func (t *BashTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) {
57	var a bashArgs
58	if err := json.Unmarshal(raw, &a); err != nil {
59		return core.ToolResult{}, fmt.Errorf("invalid args: %w", err)
60	}
61	if strings.TrimSpace(a.Command) == "" {
62		return core.ToolResult{}, fmt.Errorf("command is required")
63	}
64	if a.Timeout == nil {
65		return core.ToolResult{}, fmt.Errorf("timeout is required")
66	}
67	timeout, err := bashTimeoutDuration(*a.Timeout)
68	if err != nil {
69		return core.ToolResult{}, err
70	}
71	if err := t.Sandbox.CheckCommand(a.Command); err != nil {
72		return core.ToolResult{}, err
73	}
74	if err := t.Sandbox.CheckBashPermission(a.Command); err != nil {
75		return core.ToolResult{}, err
76	}
77	cwd := t.CWD
78	if cwd == "" {
79		cwd, _ = os.Getwd()
80	}
81
82	runCtx, cancel := context.WithTimeout(ctx, timeout)
83	defer cancel()
84
85	cmd := newShellCmd(runCtx, a.Command)
86	cmd.Dir = cwd
87	cmd.Env = os.Environ()
88
89	// Capture merged stdout+stderr with line-by-line streaming.
90	pr, pw := io.Pipe()
91	cmd.Stdout = pw
92	cmd.Stderr = pw
93	closeOutput := configureBashProcess(cmd, pw)
94
95	if err := cmd.Start(); err != nil {
96		closeOutput()
97		return core.ToolResult{}, fmt.Errorf("start: %w", err)
98	}
99
100	// Writer to both the buffer (trimmed) and progress callback.
101	captured := &bytes.Buffer{}
102	readerDone := make(chan struct{})
103
104	go func() {
105		defer close(readerDone)
106		buf := make([]byte, 4096)
107		for {
108			n, err := pr.Read(buf)
109			if n > 0 {
110				chunk := buf[:n]
111				if captured.Len() < maxBashBytes {
112					room := maxBashBytes - captured.Len()
113					if n > room {
114						captured.Write(chunk[:room])
115					} else {
116						captured.Write(chunk)
117					}
118				}
119				if progress != nil {
120					progress(string(chunk))
121				}
122			}
123			if err != nil {
124				return
125			}
126		}
127	}()
128
129	waitErr := cmd.Wait()
130	closeOutput()
131	<-readerDone
132
133	output := captured.String()
134	truncBytes := captured.Len() >= maxBashBytes
135	lines := strings.Split(output, "\n")
136	truncLines := false
137	if len(lines) > maxBashLines {
138		lines = lines[:maxBashLines]
139		truncLines = true
140	}
141	trimmed := strings.Join(lines, "\n")
142
143	exitCode := 0
144	if waitErr != nil {
145		if ee, ok := waitErr.(*exec.ExitError); ok {
146			exitCode = ee.ExitCode()
147		} else {
148			exitCode = -1
149		}
150	}
151
152	// Terminal-log style: echo the command on the first line with
153	// a shell-prompt prefix, a blank line, the captured output, and
154	// a footer showing the exit code. Invocation timing is added once
155	// by the core tool boundary for the model-only projection.
156	var sb strings.Builder
157	fmt.Fprintf(&sb, "$ %s\n", a.Command)
158	if trimmed != "" {
159		sb.WriteString("\n")
160		sb.WriteString(trimmed)
161		if !strings.HasSuffix(trimmed, "\n") {
162			sb.WriteString("\n")
163		}
164	}
165	if truncLines {
166		fmt.Fprintf(&sb, "... [truncated at %d lines]\n", maxBashLines)
167	}
168	if truncBytes {
169		fmt.Fprintf(&sb, "... [truncated at %d bytes]\n", maxBashBytes)
170	}
171	sb.WriteString("\n")
172	if errors.Is(runCtx.Err(), context.DeadlineExceeded) && ctx.Err() == nil {
173		fmt.Fprintf(&sb, "[timed out after %d second", *a.Timeout)
174		if *a.Timeout != 1 {
175			sb.WriteByte('s')
176		}
177		sb.WriteByte(']')
178	} else if exitCode == 0 {
179		fmt.Fprintf(&sb, "[exit 0]")
180	} else {
181		fmt.Fprintf(&sb, "[exit %d]", exitCode)
182	}
183
184	var fullPath string
185	if truncBytes || truncLines {
186		fullPath = writeFullOutput(output)
187		if fullPath != "" {
188			fmt.Fprintf(&sb, " (full output: %s)", fullPath)
189		}
190	}
191
192	isErr := exitCode != 0 || runCtx.Err() != nil
193	return core.ToolResult{
194		Content: []provider.Content{provider.TextBlock{Text: sb.String()}},
195		IsError: isErr,
196		Details: map[string]any{
197			"exit_code":        exitCode,
198			"full_output_path": fullPath,
199			"lines_truncated":  truncLines,
200			"bytes_truncated":  truncBytes,
201		},
202	}, nil
203}
204
205func writeFullOutput(s string) string {
206	b := make([]byte, 6)
207	_, _ = rand.Read(b)
208	name := filepath.Join(os.TempDir(), "zut-bash-"+hex.EncodeToString(b)+".log")
209	if err := os.WriteFile(name, []byte(s), 0o600); err != nil {
210		return ""
211	}
212	return name
213}
214
215type shellCommand struct {
216	path   string
217	flag   string
218	isBash bool
219}
220
221func currentShell() shellCommand {
222	return resolveShell(runtime.GOOS, isExecutableFile, exec.LookPath)
223}
224
225func resolveShell(goos string, executable func(string) bool, lookPath func(string) (string, error)) shellCommand {
226	if goos == "windows" {
227		return shellCommand{path: "cmd", flag: "/C"}
228	}
229	if executable("/bin/bash") {
230		return shellCommand{path: "/bin/bash", flag: "-c", isBash: true}
231	}
232	if path, err := lookPath("bash"); err == nil {
233		return shellCommand{path: path, flag: "-c", isBash: true}
234	}
235	return shellCommand{path: "/bin/sh", flag: "-c"}
236}
237
238func isExecutableFile(path string) bool {
239	info, err := os.Stat(path)
240	return err == nil && !info.IsDir() && info.Mode().Perm()&0o111 != 0
241}
242
243func shellDescription(shell shellCommand) string {
244	if shell.flag == "/C" {
245		return "Run a Windows Command Prompt command via cmd /C. stdout+stderr merged."
246	}
247	if shell.isBash {
248		return fmt.Sprintf("Run a Bash command via %s -c. stdout+stderr merged.", shell.path)
249	}
250	return "Bash is unavailable; run a POSIX sh command via /bin/sh -c. stdout+stderr merged."
251}
252
253func newShellCmd(ctx context.Context, command string) *exec.Cmd {
254	shell := currentShell()
255	return exec.CommandContext(ctx, shell.path, shell.flag, command)
256}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close