112 lines
3.1 KiB
Go
112 lines
3.1 KiB
Go
package agents
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"g.fc.run/ai/byre/internal/mcp"
|
|
)
|
|
|
|
const bashKeep = 64 << 10
|
|
|
|
type bashArgs struct {
|
|
Command string `json:"command"`
|
|
TimeoutMs *int `json:"timeout_ms"`
|
|
}
|
|
|
|
func (m *Manager) bashTool(l *live) mcp.Tool {
|
|
return mcp.Tool{
|
|
Name: "bash",
|
|
Description: "Run a shell command on the Mac byre runs on, in your task's worktree, with $SHELL as a login shell, and get its exit status and output (stdout and stderr together, the last 64 KiB). " +
|
|
"byre runs it itself, outside any sandbox your own shell tool has, with byre's environment and $PORT set to this task's first port. " +
|
|
"Stdin is empty. The command runs in its own process group, which is killed at the timeout.",
|
|
InputSchema: json.RawMessage(`{
|
|
"type": "object",
|
|
"properties": {
|
|
"command": {"type": "string"},
|
|
"timeout_ms": {"type": "integer", "description": "Kill the command after this long (default 120000, at most 600000)."}
|
|
},
|
|
"required": ["command"]
|
|
}`),
|
|
Handler: func(ctx context.Context, raw json.RawMessage) (string, error) {
|
|
return m.bash(ctx, l, raw)
|
|
},
|
|
}
|
|
}
|
|
|
|
func (m *Manager) bash(ctx context.Context, l *live, raw json.RawMessage) (string, error) {
|
|
args := bashArgs{}
|
|
err := json.Unmarshal(raw, &args)
|
|
if err != nil {
|
|
return "", fmt.Errorf("malformed bash call: %w", err)
|
|
}
|
|
if strings.TrimSpace(args.Command) == "" {
|
|
return "", errors.New("command is empty")
|
|
}
|
|
timeout := defaultBashTimeout
|
|
if args.TimeoutMs != nil {
|
|
timeout = time.Duration(*args.TimeoutMs) * time.Millisecond
|
|
}
|
|
if timeout <= 0 || timeout > maxBashTimeout {
|
|
return "", fmt.Errorf("timeout_ms must be between 1 and %d", maxBashTimeout.Milliseconds())
|
|
}
|
|
task, err := m.store.GetTask(ctx, l.taskID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
shell := os.Getenv("SHELL")
|
|
if shell == "" {
|
|
return "", errors.New("SHELL is not set in byre's environment")
|
|
}
|
|
|
|
cctx, cancel := context.WithTimeout(ctx, timeout)
|
|
defer cancel()
|
|
cmd := exec.CommandContext(cctx, shell, "-l", "-c", args.Command)
|
|
cmd.Dir = l.dir
|
|
cmd.Env = append(os.Environ(), portEnv(task)...)
|
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
|
cmd.Cancel = func() error {
|
|
return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
|
|
}
|
|
cmd.WaitDelay = 5 * time.Second
|
|
out := &tailWriter{}
|
|
cmd.Stdout = out
|
|
cmd.Stderr = out
|
|
err = cmd.Run()
|
|
|
|
status := "exit status 0"
|
|
exit := &exec.ExitError{}
|
|
switch {
|
|
case ctx.Err() != nil:
|
|
return "", ctx.Err()
|
|
case cctx.Err() != nil:
|
|
status = fmt.Sprintf("killed after %s", timeout)
|
|
case errors.As(err, &exit):
|
|
status = exit.ProcessState.String()
|
|
case errors.Is(err, exec.ErrWaitDelay):
|
|
status = "exit status 0; output cut off after 5s: a background process kept it open"
|
|
case err != nil:
|
|
return "", err
|
|
}
|
|
return status + "\n" + string(out.b[max(0, len(out.b)-bashKeep):]), nil
|
|
}
|
|
|
|
type tailWriter struct {
|
|
b []byte
|
|
}
|
|
|
|
func (w *tailWriter) Write(p []byte) (int, error) {
|
|
w.b = append(w.b, p...)
|
|
if len(w.b) > 2*bashKeep {
|
|
w.b = append([]byte{}, w.b[len(w.b)-bashKeep:]...)
|
|
}
|
|
return len(p), nil
|
|
}
|