Add run_on_client: agents run commands with files on the user's Macs
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user