Cletus plugin kit: rules for coding agents, specification, host API, style guide, worker SDK and cletus-plugin tool
This commit is contained in:
commit
9b19d3a8be
41 files changed
+4121
No files matched your search
@@ -0,0 +1,323 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.eidonix.com/eidonix/cletus-plugins/sdk/manifest"
|
||||
)
|
||||
|
||||
type message struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
ParentID string `json:"parent_id,omitempty"`
|
||||
Method string `json:"method,omitempty"`
|
||||
Params json.RawMessage `json:"params,omitempty"`
|
||||
Result json.RawMessage `json:"result,omitempty"`
|
||||
Error *struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// testHost runs a worker and answers its host calls the way Cletus does,
|
||||
// within the plugin's declared permissions. Every host call is logged.
|
||||
type testHost struct {
|
||||
plugin *manifest.Plugin
|
||||
filesDir string // the folder the user "opened"
|
||||
workspaceDir string // backs the one simulated shared folder ("" = none)
|
||||
dataDir string
|
||||
edit bool
|
||||
capabilities map[string]bool
|
||||
log io.Writer
|
||||
|
||||
cmd *exec.Cmd
|
||||
stdin io.WriteCloser
|
||||
writeMu sync.Mutex
|
||||
mu sync.Mutex
|
||||
pending map[string]chan message
|
||||
next int
|
||||
exited chan struct{}
|
||||
}
|
||||
|
||||
const testWorkspaceID = "ws:00000000-0000-0000-0000-000000000001"
|
||||
|
||||
func (h *testHost) start(bin string) error {
|
||||
if err := os.MkdirAll(h.dataDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
h.cmd = exec.Command(bin)
|
||||
h.cmd.Dir = h.dataDir
|
||||
h.cmd.Env = []string{"CLETUS_PLUGIN_ID=" + h.plugin.ID, "CLETUS_PLUGIN_DATA=" + h.dataDir}
|
||||
for _, name := range []string{"HOME", "USERPROFILE", "PATH", "TMPDIR", "TEMP", "TMP", "LANG", "SystemRoot"} {
|
||||
if v, ok := os.LookupEnv(name); ok {
|
||||
h.cmd.Env = append(h.cmd.Env, name+"="+v)
|
||||
}
|
||||
}
|
||||
h.cmd.Stderr = prefixWriter{w: h.log, prefix: "worker "}
|
||||
var err error
|
||||
if h.stdin, err = h.cmd.StdinPipe(); err != nil {
|
||||
return err
|
||||
}
|
||||
stdout, err := h.cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
h.pending = map[string]chan message{}
|
||||
h.exited = make(chan struct{})
|
||||
if err := h.cmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
go h.read(stdout)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
var hs struct {
|
||||
APIVersion int `json:"api_version"`
|
||||
}
|
||||
if err := h.call(ctx, "plugin.handshake", nil, &hs); err != nil {
|
||||
return fmt.Errorf("handshake: %w", err)
|
||||
}
|
||||
if hs.APIVersion != 1 {
|
||||
return fmt.Errorf("handshake: the worker speaks api_version %d; Cletus needs 1", hs.APIVersion)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *testHost) stop() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
_ = h.call(ctx, "plugin.shutdown", nil, nil)
|
||||
cancel()
|
||||
h.stdin.Close()
|
||||
select {
|
||||
case <-h.exited:
|
||||
case <-time.After(2 * time.Second):
|
||||
_ = h.cmd.Process.Kill()
|
||||
<-h.exited
|
||||
}
|
||||
}
|
||||
|
||||
func (h *testHost) read(stdout io.Reader) {
|
||||
defer close(h.exited)
|
||||
defer func() { _ = h.cmd.Wait() }()
|
||||
sc := bufio.NewScanner(stdout)
|
||||
sc.Buffer(make([]byte, 64<<10), 64<<20+1)
|
||||
for sc.Scan() {
|
||||
var m message
|
||||
if err := json.Unmarshal(sc.Bytes(), &m); err != nil {
|
||||
fmt.Fprintf(h.log, "host the worker wrote something that isn't a message (is it printing to stdout?): %.200s\n", sc.Text())
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case m.Method == "$/cancel":
|
||||
case m.Method != "":
|
||||
go h.serve(m)
|
||||
default:
|
||||
h.mu.Lock()
|
||||
ch := h.pending[m.ID]
|
||||
delete(h.pending, m.ID)
|
||||
h.mu.Unlock()
|
||||
if ch != nil {
|
||||
ch <- m
|
||||
}
|
||||
}
|
||||
}
|
||||
h.mu.Lock()
|
||||
for id, ch := range h.pending {
|
||||
close(ch)
|
||||
delete(h.pending, id)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
func (h *testHost) send(m message) error {
|
||||
data, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
h.writeMu.Lock()
|
||||
defer h.writeMu.Unlock()
|
||||
_, err = h.stdin.Write(append(data, '\n'))
|
||||
return err
|
||||
}
|
||||
|
||||
func (h *testHost) call(ctx context.Context, method string, params, out any) error {
|
||||
var raw json.RawMessage
|
||||
if params != nil {
|
||||
var err error
|
||||
if raw, err = json.Marshal(params); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
ch := make(chan message, 1)
|
||||
h.mu.Lock()
|
||||
h.next++
|
||||
id := fmt.Sprintf("h%d", h.next)
|
||||
h.pending[id] = ch
|
||||
h.mu.Unlock()
|
||||
if err := h.send(message{ID: id, Method: method, Params: raw}); err != nil {
|
||||
return fmt.Errorf("the worker has stopped: %w", err)
|
||||
}
|
||||
select {
|
||||
case reply, ok := <-ch:
|
||||
if !ok {
|
||||
return errors.New("the worker stopped before answering")
|
||||
}
|
||||
if reply.Error != nil {
|
||||
return errors.New(reply.Error.Message)
|
||||
}
|
||||
if out != nil {
|
||||
return json.Unmarshal(reply.Result, out)
|
||||
}
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
cancelID, _ := json.Marshal(id)
|
||||
_ = h.send(message{Method: "$/cancel", Params: cancelID})
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
// serve answers one host call from the worker.
|
||||
func (h *testHost) serve(m message) {
|
||||
result, err := h.answer(m.Method, m.Params)
|
||||
reply := message{ID: m.ID}
|
||||
outcome := "ok"
|
||||
if err != nil {
|
||||
reply.Error = &struct {
|
||||
Message string `json:"message"`
|
||||
}{err.Error()}
|
||||
outcome = "error: " + err.Error()
|
||||
} else {
|
||||
reply.Result, _ = json.Marshal(result)
|
||||
}
|
||||
fmt.Fprintf(h.log, "host %s %s → %s\n", m.Method, clip(string(m.Params), 200), outcome)
|
||||
if m.ID != "" {
|
||||
_ = h.send(reply)
|
||||
}
|
||||
}
|
||||
|
||||
func clip(s string, n int) string {
|
||||
if len(s) > n {
|
||||
return s[:n] + "…"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (h *testHost) answer(method string, raw json.RawMessage) (any, error) {
|
||||
if ok, need := h.plugin.Allowed(method); !ok {
|
||||
if need != "" {
|
||||
return nil, fmt.Errorf("this plugin is not allowed to use %s; it needs the %s permission", method, need)
|
||||
}
|
||||
return nil, fmt.Errorf("unknown plugin host operation %q", method)
|
||||
}
|
||||
var p struct {
|
||||
Path, Key, Workspace, Prefix, Name, Data, ContentType string
|
||||
TTL int
|
||||
Args []any
|
||||
}
|
||||
_ = json.Unmarshal(raw, &p)
|
||||
switch method {
|
||||
case "host.auth.has_capability":
|
||||
var c struct{ Capability string }
|
||||
_ = json.Unmarshal(raw, &c)
|
||||
return map[string]bool{"granted": h.capabilities[c.Capability]}, nil
|
||||
case "host.auth.whoami":
|
||||
return map[string]any{"key_id": 1, "name": "Test User", "org": "Test Organization", "org_id": "00000000-0000-0000-0000-000000000000"}, nil
|
||||
case "host.files.resolve":
|
||||
return h.resolve(p.Path)
|
||||
case "host.files.can_write":
|
||||
path, err := h.resolve(p.Path)
|
||||
if err != nil {
|
||||
path, err = h.within(p.Path)
|
||||
}
|
||||
return err == nil && h.edit && path != "", nil
|
||||
case "host.event":
|
||||
if !strings.HasPrefix(p.Name, h.plugin.ID+":") {
|
||||
return nil, errors.New("plugin event is not permitted")
|
||||
}
|
||||
return nil, nil
|
||||
case "host.screen.open":
|
||||
return nil, errors.New("screens aren't available yet")
|
||||
case "host.dialog.open", "host.dialog.folder", "host.dialog.save", "host.dialog.message":
|
||||
return nil, errors.New("the test host has no dialogs; pass paths as tool arguments while testing")
|
||||
case "host.plugin.call":
|
||||
return nil, errors.New("the test host runs no other plugins")
|
||||
case "host.data.publish":
|
||||
path, err := h.inData(p.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if p.TTL < 1 || p.TTL > 3600 {
|
||||
return nil, errors.New("artifact ttl must be 1 to 3600 seconds")
|
||||
}
|
||||
return map[string]string{"token": "test-token", "url": "file://" + filepath.ToSlash(path), "expiresAt": time.Now().Add(time.Duration(p.TTL) * time.Second).UTC().Format(time.RFC3339)}, nil
|
||||
case "host.data.revoke":
|
||||
return nil, nil
|
||||
case "host.data.leases":
|
||||
return 0, nil
|
||||
case "host.data.remove":
|
||||
path, err := h.inData(p.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
return nil, err
|
||||
}
|
||||
return nil, nil
|
||||
case "host.agent.start", "host.agent.status", "host.agent.cancel":
|
||||
return nil, errors.New("background runs aren't available in the test host")
|
||||
}
|
||||
if strings.HasPrefix(method, "host.workspace.") {
|
||||
return h.workspace(method, p.Workspace, p.Key, p.Prefix, p.Path, p.Data)
|
||||
}
|
||||
return nil, fmt.Errorf("unknown plugin host operation %q", method)
|
||||
}
|
||||
|
||||
// resolve answers host.files.resolve: a path inside the opened folder.
|
||||
func (h *testHost) resolve(path string) (string, error) {
|
||||
resolved, err := h.within(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := os.Stat(resolved); err != nil {
|
||||
return "", fmt.Errorf("%s does not exist", path)
|
||||
}
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
func (h *testHost) within(path string) (string, error) {
|
||||
if path == "" {
|
||||
return "", errors.New("path is empty")
|
||||
}
|
||||
if !filepath.IsAbs(path) {
|
||||
path = filepath.Join(h.filesDir, path)
|
||||
}
|
||||
path = filepath.Clean(path)
|
||||
if !inside(h.filesDir, path) {
|
||||
return "", fmt.Errorf("%s is outside the files the user opened", path)
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func (h *testHost) inData(path string) (string, error) {
|
||||
if !filepath.IsAbs(path) {
|
||||
path = filepath.Join(h.dataDir, path)
|
||||
}
|
||||
path = filepath.Clean(path)
|
||||
if !inside(h.dataDir, path) {
|
||||
return "", errors.New("the path must be inside the plugin's data folder")
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func inside(root, path string) bool {
|
||||
rel, err := filepath.Rel(root, path)
|
||||
return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && !filepath.IsAbs(rel)
|
||||
}
|
||||
Reference in new issue
Block a user