324 lines
8.8 KiB
Go
324 lines
8.8 KiB
Go
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)
|
|
}
|