192 lines
6.2 KiB
Go
192 lines
6.2 KiB
Go
package agents
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"encoding/json"
|
||
|
|
"errors"
|
||
|
|
"fmt"
|
||
|
|
"log/slog"
|
||
|
|
"os"
|
||
|
|
"os/exec"
|
||
|
|
"path/filepath"
|
||
|
|
"strings"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"g.fc.run/misc/byre/internal/api"
|
||
|
|
"g.fc.run/misc/byre/internal/mcp"
|
||
|
|
"g.fc.run/misc/byre/internal/store"
|
||
|
|
)
|
||
|
|
|
||
|
|
const (
|
||
|
|
bundleTimeout = 5 * time.Minute
|
||
|
|
MaxRunOutput = 64 << 10
|
||
|
|
)
|
||
|
|
|
||
|
|
type runOnClientArgs struct {
|
||
|
|
Paths []string `json:"paths"`
|
||
|
|
Command string `json:"command"`
|
||
|
|
Why string `json:"why"`
|
||
|
|
}
|
||
|
|
|
||
|
|
func (m *Manager) runOnClientTool(l *live) mcp.Tool {
|
||
|
|
return mcp.Tool{
|
||
|
|
Name: "run_on_client",
|
||
|
|
Description: "Ask the human to run a command on their own Mac, with files from your worktree, for example to install and launch an app you built. " +
|
||
|
|
"byre packs paths into a bundle now, so later changes to them aren't sent, and files a card in the human's inbox showing why, the command and the files. " +
|
||
|
|
"Every Mac the human has byre open on shows the card with Run and Dismiss; each Mac where they click Run downloads the bundle, unpacks it into a new empty directory, " +
|
||
|
|
"and runs command there with zsh as a login shell, as the human. The directory is deleted once the command finishes, so copy anything that must stay elsewhere first. " +
|
||
|
|
"The command keeps running if it quits byre's app, and the app reports it when it next starts. " +
|
||
|
|
"This call returns at once; you don't need to end your turn. Each Mac's run reaches you as a message with its host name, exit status and the last 64 KiB of its output. " +
|
||
|
|
"The card stays until the human dismisses it, so it can be run again or on another Mac.",
|
||
|
|
InputSchema: json.RawMessage(`{
|
||
|
|
"type": "object",
|
||
|
|
"properties": {
|
||
|
|
"paths": {"type": "array", "items": {"type": "string"}, "description": "Files or directories to send, relative to your worktree; each is unpacked at the same relative path. May be empty."},
|
||
|
|
"command": {"type": "string", "description": "The zsh command to run in the directory the bundle unpacks into, e.g. 'rm -rf ~/Applications/byre.app && ditto mac/.build/byre.app ~/Applications/byre.app && (pkill -x Byre; open ~/Applications/byre.app)'."},
|
||
|
|
"why": {"type": "string", "description": "One or two sentences for the human: what running it does and why."}
|
||
|
|
},
|
||
|
|
"required": ["paths", "command", "why"]
|
||
|
|
}`),
|
||
|
|
Handler: func(ctx context.Context, raw json.RawMessage) (string, error) {
|
||
|
|
return m.runOnClient(ctx, l, raw)
|
||
|
|
},
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (m *Manager) runOnClient(ctx context.Context, l *live, raw json.RawMessage) (string, error) {
|
||
|
|
args := runOnClientArgs{}
|
||
|
|
err := json.Unmarshal(raw, &args)
|
||
|
|
if err != nil {
|
||
|
|
return "", fmt.Errorf("malformed run_on_client call: %w", err)
|
||
|
|
}
|
||
|
|
if strings.TrimSpace(args.Command) == "" {
|
||
|
|
return "", errors.New("command is empty")
|
||
|
|
}
|
||
|
|
if strings.TrimSpace(args.Why) == "" {
|
||
|
|
return "", errors.New("why is empty: say what running it does and why")
|
||
|
|
}
|
||
|
|
|
||
|
|
paths, err := checkBundlePaths(l.dir, args.Paths)
|
||
|
|
if err != nil {
|
||
|
|
return "", err
|
||
|
|
}
|
||
|
|
|
||
|
|
id := store.NewID()
|
||
|
|
size, err := m.writeBundle(ctx, l.dir, paths, m.store.BundlePath(id))
|
||
|
|
if err != nil {
|
||
|
|
return "", err
|
||
|
|
}
|
||
|
|
|
||
|
|
payload := map[string]any{
|
||
|
|
"why": args.Why,
|
||
|
|
"command": args.Command,
|
||
|
|
"paths": paths,
|
||
|
|
"bytes": size,
|
||
|
|
}
|
||
|
|
err = m.store.CreateInboxItem(ctx, m.newItem(l, id, id, api.ClientRun, payload))
|
||
|
|
if err != nil {
|
||
|
|
os.Remove(m.store.BundlePath(id))
|
||
|
|
return "", err
|
||
|
|
}
|
||
|
|
return fmt.Sprintf("Run request %s filed in the human's inbox with a %d-byte bundle. Each Mac's run will reach you as a message.", id, size), nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func checkBundlePaths(dir string, paths []string) ([]string, error) {
|
||
|
|
root, err := os.OpenRoot(dir)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
defer root.Close()
|
||
|
|
|
||
|
|
out := []string{}
|
||
|
|
for _, p := range paths {
|
||
|
|
clean := filepath.Clean(p)
|
||
|
|
if filepath.IsAbs(clean) || clean == "." || strings.HasPrefix(clean, "-") {
|
||
|
|
return nil, fmt.Errorf("path %q must be relative to your worktree and inside it", p)
|
||
|
|
}
|
||
|
|
_, err = root.Lstat(clean)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("path %q: %w", p, err)
|
||
|
|
}
|
||
|
|
out = append(out, clean)
|
||
|
|
}
|
||
|
|
return out, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (m *Manager) writeBundle(ctx context.Context, dir string, paths []string, dest string) (int64, error) {
|
||
|
|
err := os.MkdirAll(filepath.Dir(dest), 0o755)
|
||
|
|
if err != nil {
|
||
|
|
return 0, err
|
||
|
|
}
|
||
|
|
tmp := dest + ".tmp"
|
||
|
|
|
||
|
|
cctx, cancel := context.WithTimeout(ctx, bundleTimeout)
|
||
|
|
defer cancel()
|
||
|
|
// -T /dev/null lets tar write an empty archive when paths is empty.
|
||
|
|
cmd := exec.CommandContext(cctx, "tar", append([]string{"-czf", tmp, "-C", dir, "-T", "/dev/null", "--"}, paths...)...)
|
||
|
|
out, err := cmd.CombinedOutput()
|
||
|
|
if err != nil {
|
||
|
|
os.Remove(tmp)
|
||
|
|
return 0, fmt.Errorf("pack bundle: %w: %s", err, out)
|
||
|
|
}
|
||
|
|
|
||
|
|
err = os.Rename(tmp, dest)
|
||
|
|
if err != nil {
|
||
|
|
return 0, err
|
||
|
|
}
|
||
|
|
info, err := os.Stat(dest)
|
||
|
|
if err != nil {
|
||
|
|
return 0, err
|
||
|
|
}
|
||
|
|
return info.Size(), nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (m *Manager) ReportRun(ctx context.Context, user, itemID string, report api.RunReport) (api.InboxItem, error) {
|
||
|
|
item, err := m.store.GetInboxItem(ctx, itemID)
|
||
|
|
if err != nil {
|
||
|
|
return item, err
|
||
|
|
}
|
||
|
|
if item.Kind != api.ClientRun {
|
||
|
|
return item, fmt.Errorf("%w: item %s is a %s, not a client_run", ErrInvalid, itemID, item.Kind)
|
||
|
|
}
|
||
|
|
client := strings.TrimSpace(report.Client)
|
||
|
|
if client == "" {
|
||
|
|
return item, fmt.Errorf("%w: client is empty", ErrInvalid)
|
||
|
|
}
|
||
|
|
if strings.TrimSpace(report.RunId) == "" {
|
||
|
|
return item, fmt.Errorf("%w: run_id is empty", ErrInvalid)
|
||
|
|
}
|
||
|
|
|
||
|
|
output := report.Output
|
||
|
|
if len(output) > MaxRunOutput {
|
||
|
|
output = strings.ToValidUTF8(output[len(output)-MaxRunOutput:], "")
|
||
|
|
}
|
||
|
|
resp := api.InboxResponse{
|
||
|
|
Id: report.RunId,
|
||
|
|
ItemId: itemID,
|
||
|
|
User: user,
|
||
|
|
Type: api.Run,
|
||
|
|
Text: &output,
|
||
|
|
Client: &client,
|
||
|
|
ExitCode: &report.ExitCode,
|
||
|
|
CreatedAt: time.Now().UTC(),
|
||
|
|
}
|
||
|
|
added, err := m.store.AddRunResponse(ctx, resp)
|
||
|
|
if err != nil {
|
||
|
|
return item, err
|
||
|
|
}
|
||
|
|
if !added {
|
||
|
|
slog.Info("client run already reported", "item", itemID, "response", resp.Id, "client", client)
|
||
|
|
return m.store.GetInboxItem(ctx, itemID)
|
||
|
|
}
|
||
|
|
slog.Info("client run reported", "item", itemID, "response", resp.Id, "user", user, "client", client, "exit", report.ExitCode)
|
||
|
|
|
||
|
|
go func() {
|
||
|
|
err := m.Deliver(context.Background(), item.AgentSessionId)
|
||
|
|
if err != nil {
|
||
|
|
slog.Error("deliver run report", "session", item.AgentSessionId, "err", err)
|
||
|
|
}
|
||
|
|
}()
|
||
|
|
return m.store.GetInboxItem(ctx, itemID)
|
||
|
|
}
|