Files
byre/internal/agents/clientrun.go
T

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)
}