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