Cletus plugin kit: rules for coding agents, specification, host API, style guide, worker SDK and cletus-plugin tool

This commit is contained in:
Eidonix committed 2026-11-10 19:50:09 -05:00
commit 9b19d3a8be
41 files changed
+4121

No files matched your search

+51
View File
@@ -0,0 +1,51 @@
package main
import (
"bytes"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
)
// goEnv is the environment every build and test runs with: no cgo, no
// enclosing go.work, the plugin's vendor folder.
func goEnv(goos, goarch string) []string {
env := append(os.Environ(), "CGO_ENABLED=0", "GOWORK=off")
if goos != "" {
env = append(env, "GOOS="+goos, "GOARCH="+goarch)
}
return env
}
// goRun runs a go command in dir and returns its combined output.
func goRun(dir string, env []string, args ...string) (string, error) {
cmd := exec.Command("go", args...)
cmd.Dir = dir
cmd.Env = env
var out bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &out
err := cmd.Run()
return out.String(), err
}
// buildWorker builds ./cmd/worker for this machine into a temporary folder.
func buildWorker(dir string) (string, func(), error) {
tmp, err := os.MkdirTemp("", "cletus-plugin-")
if err != nil {
return "", nil, err
}
cleanup := func() { os.RemoveAll(tmp) }
name := "worker"
if runtime.GOOS == "windows" {
name += ".exe"
}
bin := filepath.Join(tmp, name)
if out, err := goRun(dir, goEnv("", ""), "build", "-trimpath", "-o", bin, "./cmd/worker"); err != nil {
cleanup()
return "", nil, fmt.Errorf("the worker doesn't build:\n%s", out)
}
return bin, cleanup, nil
}
+483
View File
@@ -0,0 +1,483 @@
package main
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"regexp"
"sort"
"strings"
"sync"
"time"
"git.eidonix.com/eidonix/cletus-plugins/sdk/manifest"
)
// report collects the outcome of each check.
type report struct {
failures, warnings int
}
func (r *report) ok(name string) { fmt.Printf("ok %s\n", name) }
func (r *report) fail(name, detail string) {
r.failures++
fmt.Printf("FAIL %s\n%s", name, indent(detail))
}
func (r *report) warn(name, detail string) {
r.warnings++
fmt.Printf("warn %s\n%s", name, indent(detail))
}
func indent(s string) string {
s = strings.TrimRight(s, "\n")
if s == "" {
return ""
}
return " " + strings.ReplaceAll(s, "\n", "\n ") + "\n"
}
func runCheck(args []string) error {
if len(args) > 0 {
return errors.New("check takes no arguments; run it in the plugin's folder")
}
r := &report{}
plugin, err := readPlugin(".")
if err != nil {
return err
}
if errs := plugin.Validate(); len(errs) > 0 {
var b strings.Builder
for _, e := range errs {
fmt.Fprintln(&b, e)
}
r.fail("plugin.json", b.String())
} else {
r.ok("plugin.json")
}
checkFiles(r, plugin)
own := ownGoFiles()
checkSources(r, own)
checkGoMod(r)
if out, err := goRun(".", goEnv("", ""), "vet", "./..."); err != nil {
r.fail("go vet", out)
} else {
r.ok("go vet")
}
checkImports(r, plugin)
checkDependencies(r)
for _, target := range [][2]string{{"darwin", "arm64"}, {"windows", "amd64"}} {
name := "build " + target[0] + "/" + target[1]
if out, err := goRun(".", goEnv(target[0], target[1]), "build", "-trimpath", "-o", os.DevNull, "./cmd/worker"); err != nil {
r.fail(name, out)
} else {
r.ok(name)
}
}
if out, err := goRun(".", goEnv("", ""), "test", "-count=1", "./..."); err != nil {
r.fail("go test", out)
} else {
r.ok("go test")
}
optionalTool(r, "staticcheck", "./...")
optionalTool(r, "govulncheck", "./...")
checkRuntime(r, plugin)
fmt.Println()
switch {
case r.failures > 0:
return fmt.Errorf("%d check(s) failed, %d warning(s)", r.failures, r.warnings)
case r.warnings > 0:
fmt.Printf("All checks passed with %d warning(s). Explain each in docs/plan.md; reviewers read them.\n", r.warnings)
default:
fmt.Println("All checks passed.")
}
return nil
}
func checkFiles(r *report, plugin *manifest.Plugin) {
var problems []string
changelog, err := os.ReadFile("CHANGELOG.md")
if err != nil {
problems = append(problems, "CHANGELOG.md is missing")
} else if !regexp.MustCompile(`(?m)^#+\s*v?` + regexp.QuoteMeta(plugin.Version) + `\b`).Match(changelog) {
problems = append(problems, "CHANGELOG.md has no heading for version "+plugin.Version)
}
for _, path := range []string{"docs/plan.md", "docs/guide.md", "go.mod", "cmd/worker"} {
if _, err := os.Stat(path); err != nil {
problems = append(problems, path+" is missing")
}
}
if plan, err := os.ReadFile("docs/plan.md"); err == nil {
for _, perm := range plugin.Permissions {
if !strings.Contains(string(plan), perm) {
problems = append(problems, "docs/plan.md doesn't explain the "+perm+" permission")
}
}
}
if len(problems) > 0 {
r.fail("repository files", strings.Join(problems, "\n"))
} else {
r.ok("repository files")
}
}
// ownGoFiles lists the plugin's own Go files (not vendor/, not hidden folders).
func ownGoFiles() []string {
var files []string
_ = filepath.WalkDir(".", func(path string, d os.DirEntry, err error) error {
if err != nil {
return nil
}
if d.IsDir() && path != "." && (d.Name() == "vendor" || strings.HasPrefix(d.Name(), ".") || d.Name() == "node_modules") {
return filepath.SkipDir
}
if !d.IsDir() && strings.HasSuffix(path, ".go") {
files = append(files, path)
}
return nil
})
return files
}
var (
secretRE = regexp.MustCompile(`-----BEGIN [A-Z ]*PRIVATE KEY-----|AKIA[0-9A-Z]{16}|\bsk-[A-Za-z0-9_-]{20,}|\bghp_[A-Za-z0-9]{30,}|\bxox[abp]-[A-Za-z0-9-]{10,}`)
fileOpRE = regexp.MustCompile(`\bos\.(Open|OpenFile|ReadFile|WriteFile|Create|Remove|RemoveAll|ReadDir|Rename|Mkdir|MkdirAll)\(`)
linknameRE = regexp.MustCompile(`//go:linkname`)
)
func checkSources(r *report, files []string) {
var gofmtBad, cgo, secrets, linkname, fileOps []string
if len(files) > 0 {
out, _ := exec.Command("gofmt", append([]string{"-l"}, files...)...).Output()
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
if line != "" {
gofmtBad = append(gofmtBad, line)
}
}
}
_ = filepath.WalkDir(".", func(path string, d os.DirEntry, err error) error {
if err != nil || d.IsDir() {
if d != nil && d.IsDir() && path != "." && strings.HasPrefix(d.Name(), ".") {
return filepath.SkipDir
}
return nil
}
info, err := d.Info()
if err != nil || info.Size() > 4<<20 {
return nil
}
data, err := os.ReadFile(path)
if err != nil {
return nil
}
text := string(data)
vendored := strings.HasPrefix(filepath.ToSlash(path), "vendor/")
if strings.HasSuffix(path, ".go") && regexp.MustCompile(`(?m)^import\s+"C"`).MatchString(text) {
cgo = append(cgo, path)
}
if !vendored && secretRE.MatchString(text) {
secrets = append(secrets, path)
}
if strings.HasSuffix(path, ".go") && !vendored {
if linknameRE.MatchString(text) {
linkname = append(linkname, path)
}
if !strings.HasSuffix(path, "_test.go") && fileOpRE.MatchString(text) {
fileOps = append(fileOps, path)
}
}
return nil
})
if len(gofmtBad) > 0 {
r.fail("gofmt", "not formatted (run gofmt -w):\n"+strings.Join(gofmtBad, "\n"))
} else {
r.ok("gofmt")
}
if len(cgo) > 0 {
r.fail("no cgo", "these files use cgo, which Cletus plugins can't:\n"+strings.Join(cgo, "\n"))
} else {
r.ok("no cgo")
}
if len(secrets) > 0 {
r.fail("no secrets", "these files look like they hold a key or token:\n"+strings.Join(secrets, "\n"))
} else {
r.ok("no secrets")
}
if len(linkname) > 0 {
r.fail("no go:linkname", strings.Join(linkname, "\n"))
}
if len(fileOps) > 0 {
r.warn("direct file access", "these files open, write or delete files directly. Each path must come from\nhost.files.resolve, a dialog, or pluginsdk.DataDir(); anything else needs files.computer:\n"+strings.Join(fileOps, "\n"))
}
}
func checkGoMod(r *report) {
data, err := os.ReadFile("go.mod")
if err != nil {
return
}
text := string(data)
var problems []string
if regexp.MustCompile(`(?m)^\s*replace\b|=>`).MatchString(text) {
problems = append(problems, "go.mod has replace directives; the store builds only from go.mod and vendor/")
}
if strings.Contains(text, "require") {
if _, err := os.Stat("vendor/modules.txt"); err != nil {
problems = append(problems, "vendor/ is missing; run go mod vendor and commit it")
} else if out, err := goRun(".", goEnv("", ""), "build", "-mod=vendor", "-o", os.DevNull, "./cmd/worker"); err != nil {
problems = append(problems, "vendor/ doesn't match go.mod; run go mod vendor:\n"+out)
}
}
if len(problems) > 0 {
r.fail("go.mod and vendor", strings.Join(problems, "\n"))
} else {
r.ok("go.mod and vendor")
}
}
type goPackage struct {
ImportPath string
Imports []string
Standard bool
Module *struct{ Path string }
}
func listPackages(args ...string) ([]goPackage, error) {
cmd := exec.Command("go", append([]string{"list", "-json"}, args...)...)
cmd.Env = goEnv("", "")
out, err := cmd.Output()
if err != nil {
var exit *exec.ExitError
if errors.As(err, &exit) {
return nil, fmt.Errorf("%s", exit.Stderr)
}
return nil, err
}
var pkgs []goPackage
dec := json.NewDecoder(strings.NewReader(string(out)))
for {
var p goPackage
if err := dec.Decode(&p); err == io.EOF {
break
} else if err != nil {
return nil, err
}
pkgs = append(pkgs, p)
}
return pkgs, nil
}
var netPackages = map[string]bool{"net": true, "net/http": true, "net/rpc": true, "net/smtp": true, "crypto/tls": true, "net/http/httputil": true}
func checkImports(r *report, plugin *manifest.Plugin) {
own, err := listPackages("./...")
if err != nil {
r.fail("imports", err.Error())
return
}
var failures, notes []string
for _, p := range own {
for _, imp := range p.Imports {
switch {
case imp == "plugin":
failures = append(failures, p.ImportPath+" imports plugin (loading code at run time is not allowed)")
case imp == "os/exec" && !plugin.Has("processes"):
failures = append(failures, p.ImportPath+" imports os/exec but the plugin doesn't declare processes")
case netPackages[imp] && !plugin.HasNet():
failures = append(failures, p.ImportPath+" imports "+imp+" but the plugin declares no net permission")
case imp == "unsafe" || imp == "reflect" || imp == "syscall" || strings.HasPrefix(imp, "golang.org/x/sys/"):
notes = append(notes, p.ImportPath+" imports "+imp)
}
}
}
if len(failures) > 0 {
r.fail("imports match permissions", strings.Join(failures, "\n"))
} else {
r.ok("imports match permissions")
}
if len(notes) > 0 {
r.warn("low-level imports", "explain each in docs/plan.md:\n"+strings.Join(notes, "\n"))
}
deps, err := listPackages("-deps", "./cmd/worker")
if err != nil {
return
}
var depNotes []string
for _, p := range deps {
if p.Standard || p.Module == nil || isOwnModule(p.Module.Path) {
continue
}
for _, imp := range p.Imports {
if imp == "os/exec" && !plugin.Has("processes") || netPackages[imp] && !plugin.HasNet() {
depNotes = append(depNotes, p.ImportPath+" imports "+imp)
}
}
}
if len(depNotes) > 0 {
sort.Strings(depNotes)
r.warn("dependencies that can start programs or connect", "your plugin doesn't declare this; make sure these code paths are never used:\n"+strings.Join(depNotes, "\n"))
}
}
func isOwnModule(path string) bool {
data, err := os.ReadFile("go.mod")
if err != nil {
return false
}
m := regexp.MustCompile(`(?m)^module\s+(\S+)`).FindStringSubmatch(string(data))
return len(m) == 2 && m[1] == path
}
func checkDependencies(r *report) {
f, err := os.Open("vendor/modules.txt")
if err != nil {
return
}
defer f.Close()
var missing []string
sc := bufio.NewScanner(f)
for sc.Scan() {
fields := strings.Fields(sc.Text())
if len(fields) < 3 || fields[0] != "#" || fields[2] == "=>" {
continue
}
dir := filepath.Join("vendor", filepath.FromSlash(fields[1]))
if _, err := os.Stat(dir); err != nil {
continue // a module with no packages in the build
}
if !hasLicense(dir) {
missing = append(missing, fields[1]+" "+fields[2])
}
}
if len(missing) > 0 {
r.warn("dependency licences", "no licence file found; the store must be able to confirm each licence allows distribution:\n"+strings.Join(missing, "\n"))
} else {
r.ok("dependency licences present")
}
}
func hasLicense(dir string) bool {
entries, err := os.ReadDir(dir)
if err != nil {
return false
}
for _, e := range entries {
name := strings.ToUpper(e.Name())
if strings.HasPrefix(name, "LICENSE") || strings.HasPrefix(name, "LICENCE") || strings.HasPrefix(name, "COPYING") {
return true
}
}
return false
}
func optionalTool(r *report, tool string, args ...string) {
if _, err := exec.LookPath(tool); err != nil {
fmt.Printf("skip %s (not installed; the store runs it)\n", tool)
return
}
cmd := exec.Command(tool, args...)
cmd.Env = goEnv("", "")
out, err := cmd.CombinedOutput()
if err != nil {
r.fail(tool, string(out))
} else {
r.ok(tool)
}
}
// checkRuntime starts the worker and compares its tools with plugin.json.
func checkRuntime(r *report, plugin *manifest.Plugin) {
wd, _ := os.Getwd()
bin, cleanup, err := buildWorker(wd)
if err != nil {
r.fail("worker starts", err.Error())
return
}
defer cleanup()
tmp, err := os.MkdirTemp("", "cletus-plugin-check-")
if err != nil {
r.fail("worker starts", err.Error())
return
}
defer os.RemoveAll(tmp)
log := &syncBuffer{}
host := &testHost{plugin: plugin, filesDir: tmp, dataDir: filepath.Join(tmp, "data"), capabilities: map[string]bool{}, log: log}
if err := host.start(bin); err != nil {
r.fail("worker starts", err.Error()+"\n"+log.String())
return
}
defer host.stop()
r.ok("worker starts and answers the handshake")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
var tools []struct {
Def struct {
Function struct {
Name string `json:"name"`
Description string `json:"description"`
} `json:"function"`
} `json:"def"`
Mutating bool `json:"mutating"`
RequiresApproval bool `json:"requires_approval"`
}
if err := host.call(ctx, "tools.list", nil, &tools); err != nil {
r.fail("tools match plugin.json", "tools.list: "+err.Error())
return
}
declared := map[string]manifest.Tool{}
for _, t := range plugin.Tools {
declared[t.Name] = t
}
var problems []string
for _, t := range tools {
name := t.Def.Function.Name
d, ok := declared[name]
switch {
case !ok:
problems = append(problems, "the worker offers "+name+", which plugin.json doesn't list")
case d.Mutating != t.Mutating || d.RequiresApproval != t.RequiresApproval:
problems = append(problems, name+": mutating or requires_approval differs between plugin.json and the worker")
}
if strings.TrimSpace(t.Def.Function.Description) == "" {
problems = append(problems, name+" has no description")
}
delete(declared, name)
}
for name := range declared {
problems = append(problems, "plugin.json lists "+name+", which the worker doesn't offer")
}
if len(problems) > 0 {
sort.Strings(problems)
r.fail("tools match plugin.json", strings.Join(problems, "\n"))
} else {
r.ok("tools match plugin.json")
}
}
// syncBuffer is a strings.Builder safe for the worker's log and the host's
// call log to write at once.
type syncBuffer struct {
mu sync.Mutex
b strings.Builder
}
func (s *syncBuffer) Write(p []byte) (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
return s.b.Write(p)
}
func (s *syncBuffer) String() string {
s.mu.Lock()
defer s.mu.Unlock()
return s.b.String()
}
+323
View File
@@ -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)
}
+101
View File
@@ -0,0 +1,101 @@
package main
import (
"encoding/base64"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"git.eidonix.com/eidonix/cletus-plugins/sdk/manifest"
)
func newTestHost(t *testing.T, perms ...string) *testHost {
t.Helper()
root := t.TempDir()
files, ws := filepath.Join(root, "files"), filepath.Join(root, "ws")
for _, d := range []string{files, ws} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
}
if err := os.WriteFile(filepath.Join(files, "a.txt"), []byte("a"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(ws, "ref.csv"), []byte("x,y"), 0o644); err != nil {
t.Fatal(err)
}
return &testHost{plugin: &manifest.Plugin{ID: "acme.demo", Permissions: perms}, filesDir: files, workspaceDir: ws,
dataDir: filepath.Join(root, "data"), capabilities: map[string]bool{}}
}
func ask(t *testing.T, h *testHost, method string, params any) (any, error) {
t.Helper()
raw, _ := json.Marshal(params)
return h.answer(method, raw)
}
func TestPermissionsGateHostCalls(t *testing.T) {
h := newTestHost(t)
_, err := ask(t, h, "host.files.resolve", map[string]string{"path": "a.txt"})
if err == nil || !strings.Contains(err.Error(), "needs the files permission") {
t.Fatalf("undeclared files call = %v", err)
}
if _, err := ask(t, h, "host.code.llm.start", nil); err == nil || !strings.Contains(err.Error(), "unknown plugin host operation") {
t.Fatalf("first-party call = %v", err)
}
if _, err := ask(t, h, "host.event", map[string]any{"name": "other:x"}); err == nil {
t.Fatal("an event outside the plugin's prefix was allowed")
}
if _, err := ask(t, h, "host.event", map[string]any{"name": "acme.demo:x"}); err != nil {
t.Fatalf("own event = %v", err)
}
}
func TestFilesStayInsideTheOpenedFolder(t *testing.T) {
h := newTestHost(t, "files")
got, err := ask(t, h, "host.files.resolve", map[string]string{"path": "a.txt"})
if err != nil || got != filepath.Join(h.filesDir, "a.txt") {
t.Fatalf("resolve = %v, %v", got, err)
}
for _, path := range []string{"../escape.txt", "/etc/hosts"} {
if _, err := ask(t, h, "host.files.resolve", map[string]string{"path": path}); err == nil {
t.Errorf("resolve(%s) was allowed", path)
}
}
if got, _ := ask(t, h, "host.files.can_write", map[string]string{"path": "new.txt"}); got != false {
t.Fatal("can_write without -edit should be false")
}
h.edit = true
if got, _ := ask(t, h, "host.files.can_write", map[string]string{"path": "new.txt"}); got != true {
t.Fatal("can_write with -edit should be true")
}
}
func TestWorkspaceSimulation(t *testing.T) {
h := newTestHost(t, "files", "workspace.read", "workspace.write")
listed, err := ask(t, h, "host.workspace.list", map[string]string{"workspace": testWorkspaceName})
if err != nil || len(listed.([]map[string]any)) != 1 {
t.Fatalf("list = %v, %v", listed, err)
}
key := testWorkspaceID + "/files/ref.csv"
fetched, err := ask(t, h, "host.workspace.fetch", map[string]string{"key": key})
if err != nil {
t.Fatal(err)
}
path := fetched.(map[string]string)["path"]
if _, err := ask(t, h, "host.files.resolve", map[string]string{"path": path}); err != nil {
t.Fatalf("a fetched copy should resolve: %v", err)
}
data := base64.StdEncoding.EncodeToString([]byte("new"))
if _, err := ask(t, h, "host.workspace.put", map[string]string{"workspace": testWorkspaceID, "key": "out/r.txt", "data": data}); err != nil {
t.Fatal(err)
}
if _, err := ask(t, h, "host.workspace.put", map[string]string{"workspace": testWorkspaceID, "key": "../x", "data": data}); err == nil {
t.Fatal("a key with .. was accepted")
}
if _, err := ask(t, h, "host.workspace.read", map[string]string{"key": testWorkspaceID + "/files/out/r.txt"}); err != nil {
t.Fatal(err)
}
}
+61
View File
@@ -0,0 +1,61 @@
// Command cletus-plugin creates, checks and runs Cletus plugins.
//
// cletus-plugin new <publisher>.<name> [folder]
// cletus-plugin check
// cletus-plugin run [flags] tools | invoke <tool> [json] | ui <method> [json array] | service <service.method> [json]
package main
import (
"fmt"
"os"
)
// sdkVersion is the SDK release new plugins require (tag sdk/<version>).
const sdkVersion = "v0.1.0"
func main() {
if len(os.Args) < 2 {
usage()
os.Exit(2)
}
var err error
switch os.Args[1] {
case "new":
err = runNew(os.Args[2:])
case "check":
err = runCheck(os.Args[2:])
case "run":
err = runRun(os.Args[2:])
case "version":
fmt.Println("cletus-plugin", sdkVersion)
case "help", "-h", "--help":
usage()
default:
usage()
os.Exit(2)
}
if err != nil {
fmt.Fprintln(os.Stderr, "cletus-plugin:", err)
os.Exit(1)
}
}
func usage() {
fmt.Fprint(os.Stderr, `cletus-plugin builds Cletus plugins.
cletus-plugin new <publisher>.<name> [folder] create a plugin from the template
cletus-plugin check run the store's automated checks here
cletus-plugin run [flags] <command> run the worker under the test host
tools list the chat tools
invoke <tool> [json] call a chat tool
ui <method> [json array] call a screen method
service <service.method> [json] call a provided service
flags: -files <dir> the folder the user "opened" (default: testdata)
-workspace <dir> simulate one shared folder backed by this directory
-edit the user allows edits (default: read-only)
-capability <name> a Cletus feature the user has (repeatable)
cletus-plugin version
Docs: https://git.eidonix.com/eidonix/cletus-plugins
`)
}
+115
View File
@@ -0,0 +1,115 @@
package main
import (
"bytes"
"embed"
"errors"
"flag"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
"text/template"
"unicode"
"git.eidonix.com/eidonix/cletus-plugins/sdk/manifest"
)
//go:embed all:template
var templateFS embed.FS
const sdkModule = "git.eidonix.com/eidonix/cletus-plugins/sdk"
type templateData struct {
ID, Publisher, Name, Title, Short, Module, SDKModule, SDKVersion string
}
func runNew(args []string) error {
fs := flag.NewFlagSet("new", flag.ContinueOnError)
sdkDir := fs.String("sdk-dir", "", "use a local copy of the SDK (for developing the SDK itself)")
if err := fs.Parse(args); err != nil {
return err
}
if fs.NArg() < 1 || fs.NArg() > 2 {
return errors.New("usage: cletus-plugin new <publisher>.<name> [folder]")
}
id := fs.Arg(0)
probe := manifest.Plugin{ID: id, Name: "x", Description: "x", Version: "0.1.0", MinAppVersion: "0.5.0", HostAPI: manifest.HostAPI}
if errs := probe.Validate(); len(errs) > 0 {
return errs[0]
}
publisher, name, _ := strings.Cut(id, ".")
dir := name
if fs.NArg() == 2 {
dir = fs.Arg(1)
}
if _, err := os.Stat(dir); err == nil {
return fmt.Errorf("%s already exists", dir)
}
data := templateData{ID: id, Publisher: publisher, Name: name, Title: title(name),
Short: strings.ReplaceAll(name, "-", "_"), Module: publisher + ".plugins/" + name,
SDKModule: sdkModule, SDKVersion: sdkVersion}
if err := writeTemplate(dir, data); err != nil {
return err
}
fmt.Printf("Created %s in %s.\n", id, dir)
if *sdkDir != "" {
abs, err := filepath.Abs(*sdkDir)
if err != nil {
return err
}
// Only for developing the SDK: check refuses replace directives.
if out, err := goRun(dir, goEnv("", ""), "mod", "edit", "-replace", sdkModule+"="+abs); err != nil {
return fmt.Errorf("go mod edit: %s", out)
}
}
for _, step := range [][]string{{"mod", "tidy"}, {"mod", "vendor"}} {
if out, err := goRun(dir, goEnv("", ""), step...); err != nil {
return fmt.Errorf("go %s failed. Fix the problem below and run it in %s yourself:\n%s", strings.Join(step, " "), dir, out)
}
}
fmt.Printf("\nNext:\n cd %s\n cletus-plugin check\n cletus-plugin run tools\n cletus-plugin run invoke %s_count_lines '{\"path\":\"sample.txt\"}'\n", dir, data.Short)
return nil
}
func writeTemplate(dir string, data templateData) error {
return fs.WalkDir(templateFS, "template", func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return err
}
rel := strings.TrimPrefix(path, "template/")
rel = strings.TrimSuffix(rel, ".tmpl")
rel = strings.ReplaceAll(rel, "_dot_", ".")
src, err := templateFS.ReadFile(path)
if err != nil {
return err
}
t, err := template.New(rel).Delims("[[", "]]").Parse(string(src))
if err != nil {
return fmt.Errorf("template %s: %w", rel, err)
}
var out bytes.Buffer
if err := t.Execute(&out, data); err != nil {
return fmt.Errorf("template %s: %w", rel, err)
}
dst := filepath.Join(dir, filepath.FromSlash(rel))
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
return os.WriteFile(dst, out.Bytes(), 0o644)
})
}
func title(name string) string {
words := strings.Split(name, "-")
for i, w := range words {
if w != "" {
r := []rune(w)
r[0] = unicode.ToUpper(r[0])
words[i] = string(r)
}
}
return strings.Join(words, " ")
}
+134
View File
@@ -0,0 +1,134 @@
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
"git.eidonix.com/eidonix/cletus-plugins/sdk/manifest"
)
type listFlag []string
func (l *listFlag) String() string { return strings.Join(*l, ",") }
func (l *listFlag) Set(v string) error { *l = append(*l, v); return nil }
func runRun(args []string) error {
fs := flag.NewFlagSet("run", flag.ContinueOnError)
files := fs.String("files", "testdata", "the folder the user opened")
workspace := fs.String("workspace", "", "a directory to serve as one shared folder")
edit := fs.Bool("edit", false, "the user allows edits")
timeout := fs.Duration("timeout", 2*time.Minute, "how long to wait for the answer")
var caps listFlag
fs.Var(&caps, "capability", "a Cletus feature the user has (repeatable)")
if err := fs.Parse(args); err != nil {
return err
}
rest := fs.Args()
if len(rest) == 0 {
return errors.New("run needs a command: tools, invoke, ui or service")
}
plugin, err := readPlugin(".")
if err != nil {
return err
}
wd, _ := os.Getwd()
filesDir, err := filepath.Abs(*files)
if err != nil {
return err
}
if err := os.MkdirAll(filesDir, 0o755); err != nil {
return err
}
wsDir := ""
if *workspace != "" {
if wsDir, err = filepath.Abs(*workspace); err != nil {
return err
}
}
bin, cleanup, err := buildWorker(wd)
if err != nil {
return err
}
defer cleanup()
host := &testHost{plugin: plugin, filesDir: filesDir, workspaceDir: wsDir, edit: *edit,
dataDir: filepath.Join(wd, ".cletus-dev", "data"), capabilities: map[string]bool{}, log: os.Stderr}
for _, c := range caps {
host.capabilities[c] = true
}
if err := host.start(bin); err != nil {
return err
}
defer host.stop()
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
defer cancel()
arg := func(i int, fallback string) string {
if len(rest) > i {
return rest[i]
}
return fallback
}
var out json.RawMessage
switch rest[0] {
case "tools":
err = host.call(ctx, "tools.list", nil, &out)
case "invoke":
if len(rest) < 2 {
return errors.New("invoke needs a tool name")
}
err = host.call(ctx, "tools.invoke", map[string]any{"name": rest[1], "args": arg(2, "{}"), "working_dir": filesDir, "edit_mode": *edit}, &out)
case "ui":
if len(rest) < 2 {
return errors.New("ui needs a method name")
}
err = host.call(ctx, "ui."+strings.TrimPrefix(rest[1], "ui."), json.RawMessage(arg(2, "[]")), &out)
case "service":
if len(rest) < 2 {
return errors.New("service needs <service>.<method>")
}
err = host.call(ctx, "svc."+rest[1], map[string]any{"caller": "cletus-plugin", "mutating": false, "args": json.RawMessage(arg(2, "{}"))}, &out)
default:
return fmt.Errorf("unknown run command %q", rest[0])
}
if err != nil {
return err
}
var pretty bytes.Buffer
if json.Indent(&pretty, out, "", " ") != nil {
pretty.Write(out)
}
fmt.Println(pretty.String())
return nil
}
func readPlugin(dir string) (*manifest.Plugin, error) {
path := filepath.Join(dir, "plugin.json")
if _, err := os.Stat(path); err != nil {
return nil, manifest.ErrNoManifest
}
return manifest.Read(path)
}
// prefixWriter prefixes every line written to w.
type prefixWriter struct {
w io.Writer
prefix string
}
func (p prefixWriter) Write(b []byte) (int, error) {
for _, line := range strings.SplitAfter(string(b), "\n") {
if line != "" {
fmt.Fprint(p.w, p.prefix+line)
}
}
return len(b), nil
}
@@ -0,0 +1,21 @@
# Rules for coding agents
This is a Cletus plugin. Before changing anything, read the plugin kit and
follow its rules exactly:
- Rules: https://git.eidonix.com/eidonix/cletus-plugins/src/branch/main/AGENTS.md
- Specification: https://git.eidonix.com/eidonix/cletus-plugins/src/branch/main/docs/specification.md
- Host API: https://git.eidonix.com/eidonix/cletus-plugins/src/branch/main/docs/host-api.md
- Style guide: https://git.eidonix.com/eidonix/cletus-plugins/src/branch/main/docs/style-guide.md
- Review checklist: https://git.eidonix.com/eidonix/cletus-plugins/src/branch/main/docs/review-checklist.md
If you can't read them, stop and ask the user for a copy of the kit.
Commands:
cletus-plugin check # must pass before you say you're done
cletus-plugin run tools # list the chat tools
cletus-plugin run invoke <tool> '<json>'
go test ./...
Keep docs/plan.md, plugin.json and CHANGELOG.md in step with the code.
@@ -0,0 +1,5 @@
# Changelog
## 0.1.0
- First version: count the lines in a text file, and save a summary next to it.
@@ -0,0 +1 @@
@AGENTS.md
@@ -0,0 +1,2 @@
/.cletus-dev/
/testdata/.cletus/
@@ -0,0 +1,18 @@
// Command worker is the [[.Title]] plugin's worker.
package main
import (
"fmt"
"os"
"[[.SDKModule]]/pluginsdk"
"[[.Module]]/internal/tools"
)
func main() {
if err := pluginsdk.Serve(pluginsdk.Handlers{Tools: tools.All()}); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
@@ -0,0 +1,8 @@
# [[.Title]]
Ask Cletus to count the lines in a file, or to save a summary of it.
- "How many lines are in report.txt?"
- "Save a line-count summary next to report.txt."
Saving needs Cletus to allow edits in the folder.
@@ -0,0 +1,25 @@
# [[.Title]]: plan
## What it does
Counts the lines in a text file the user chooses, and saves a short summary
next to it.
## Chat tools
- `[[.Short]]_count_lines`: reads a file and returns its line count. Read-only.
- `[[.Short]]_save_summary`: writes `<file>.summary.txt` next to the file.
Mutating; checks `host.files.can_write` first.
## Permissions
- `files`: to read the file the user chose and write the summary beside it.
## Dependencies
- `[[.SDKModule]]`: the Cletus plugin SDK.
## Notes for review
- Direct file access: `internal/tools` reads and writes only paths returned
by `host.files.resolve`, after `host.files.can_write` for writes.
@@ -0,0 +1,5 @@
module [[.Module]]
go 1.24
require [[.SDKModule]] [[.SDKVersion]]
@@ -0,0 +1,88 @@
// Package tools holds the plugin's chat tools.
package tools
import (
"bytes"
"context"
"fmt"
"os"
"[[.SDKModule]]/pluginsdk"
)
// All returns every chat tool. Each is also listed in plugin.json.
func All() []pluginsdk.Tool {
return []pluginsdk.Tool{countLines, saveSummary}
}
var pathParam = map[string]any{
"type": "object",
"properties": map[string]any{
"path": map[string]any{"type": "string", "description": "The text file, as the user named it."},
},
"required": []string{"path"},
}
var countLines = pluginsdk.Tool{
Name: "[[.Short]]_count_lines",
Description: "Count the lines in a text file. Use when the user asks how long a text file is. Returns the line count.",
Parameters: pathParam,
Run: func(ctx context.Context, h *pluginsdk.Host, call pluginsdk.ToolCall) pluginsdk.ToolResult {
path, lines, err := count(ctx, h, call)
if err != nil {
return pluginsdk.Fail("%v", err)
}
return pluginsdk.Text("%s has %d lines.", path, lines)
},
}
var saveSummary = pluginsdk.Tool{
Name: "[[.Short]]_save_summary",
Description: "Save a one-line summary (the line count) next to a text file, as <file>.summary.txt. Use only when the user asks to save it.",
Parameters: pathParam,
Mutating: true,
Run: func(ctx context.Context, h *pluginsdk.Host, call pluginsdk.ToolCall) pluginsdk.ToolResult {
path, lines, err := count(ctx, h, call)
if err != nil {
return pluginsdk.Fail("%v", err)
}
out := path + ".summary.txt"
allowed, err := h.CanWrite(ctx, out)
if err != nil {
return pluginsdk.Fail("%v", err)
}
if !allowed || !call.EditMode {
return pluginsdk.Fail("Cletus doesn't allow edits in this folder. Ask the user to allow edits, then try again.")
}
if err := os.WriteFile(out, []byte(fmt.Sprintf("%d lines\n", lines)), 0o644); err != nil {
return pluginsdk.Fail("Couldn't save the summary: %v", err)
}
return pluginsdk.Text("Saved %s.", out)
},
}
// count resolves the path through the host and counts its lines.
func count(ctx context.Context, h *pluginsdk.Host, call pluginsdk.ToolCall) (string, int, error) {
var args struct {
Path string `json:"path"`
}
if err := call.Decode(&args); err != nil {
return "", 0, err
}
if args.Path == "" {
return "", 0, fmt.Errorf("give the path of a text file")
}
path, err := h.ResolvePath(ctx, args.Path)
if err != nil {
return "", 0, err
}
data, err := os.ReadFile(path)
if err != nil {
return "", 0, fmt.Errorf("couldn't read %s: %v", args.Path, err)
}
lines := bytes.Count(data, []byte("\n"))
if len(data) > 0 && data[len(data)-1] != '\n' {
lines++
}
return path, lines, nil
}
@@ -0,0 +1,73 @@
package tools
import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"[[.SDKModule]]/pluginsdk"
)
// fakeHost answers host.files.* for files inside dir, like Cletus does for
// the folder the user opened.
func fakeHost(dir string, canWrite bool) *pluginsdk.Host {
return pluginsdk.NewTestHost(func(method string, params json.RawMessage) (any, error) {
var p struct{ Path string }
_ = json.Unmarshal(params, &p)
path := p.Path
if !filepath.IsAbs(path) {
path = filepath.Join(dir, path)
}
switch method {
case "host.files.resolve":
if _, err := os.Stat(path); err != nil {
return nil, errors.New(p.Path + " does not exist")
}
return path, nil
case "host.files.can_write":
return canWrite, nil
}
return nil, errors.New("unexpected host call " + method)
})
}
func run(t *testing.T, tool pluginsdk.Tool, h *pluginsdk.Host, args string, edit bool) pluginsdk.ToolResult {
t.Helper()
return tool.Run(context.Background(), h, pluginsdk.ToolCall{Args: json.RawMessage(args), EditMode: edit})
}
func TestCountLines(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "a.txt"), []byte("one\ntwo\nthree"), 0o644); err != nil {
t.Fatal(err)
}
got := run(t, countLines, fakeHost(dir, false), `{"path":"a.txt"}`, false)
if got.Error != "" || !strings.Contains(got.Text, "3 lines") {
t.Fatalf("got %+v", got)
}
if got := run(t, countLines, fakeHost(dir, false), `{"path":"missing.txt"}`, false); got.Error == "" {
t.Fatal("a missing file should fail")
}
}
func TestSaveSummaryNeedsEdits(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "a.txt"), []byte("one\n"), 0o644); err != nil {
t.Fatal(err)
}
if got := run(t, saveSummary, fakeHost(dir, false), `{"path":"a.txt"}`, false); got.Error == "" {
t.Fatal("saving without edits allowed should fail")
}
got := run(t, saveSummary, fakeHost(dir, true), `{"path":"a.txt"}`, true)
if got.Error != "" {
t.Fatalf("got %+v", got)
}
data, err := os.ReadFile(filepath.Join(dir, "a.txt.summary.txt"))
if err != nil || string(data) != "1 lines\n" {
t.Fatalf("summary = %q, %v", data, err)
}
}
@@ -0,0 +1,8 @@
{
"summary": "Counts the lines in a text file.",
"category": "Productivity",
"visibility": "store",
"organizations": [],
"support": "",
"price": "free"
}
@@ -0,0 +1,14 @@
{
"id": "[[.ID]]",
"name": "[[.Title]]",
"description": "Counts the lines in a text file and saves a summary next to it. Replace this with what your plugin does.",
"version": "0.1.0",
"min_app_version": "0.5.0",
"host_api": 2,
"color": "teal",
"permissions": ["files"],
"tools": [
{"name": "[[.Short]]_count_lines", "mutating": false},
{"name": "[[.Short]]_save_summary", "mutating": true}
]
}
@@ -0,0 +1,3 @@
first line
second line
third line
+190
View File
@@ -0,0 +1,190 @@
package main
import (
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path"
"path/filepath"
"strings"
)
const testWorkspaceName = "Test folder"
// workspace answers host.workspace.* from one directory, as one shared
// folder the user owns.
func (h *testHost) workspace(method, workspace, key, prefix, src, data string) (any, error) {
if h.workspaceDir == "" {
return nil, errors.New("no shared folders in the test host; pass -workspace <dir> to simulate one")
}
keyPath := func(key string) (string, error) {
rel, ok := strings.CutPrefix(key, testWorkspaceID+"/files/")
if !ok || rel == "" {
return "", fmt.Errorf("%q is not a file in a shared folder (keys look like %s/files/<path>)", key, testWorkspaceID)
}
return h.workspaceFile(rel)
}
folder := func() error {
if workspace != testWorkspaceID && workspace != testWorkspaceName {
return fmt.Errorf("you are not a member of a shared folder %q", workspace)
}
return nil
}
switch method {
case "host.workspace.folders":
return []map[string]string{{"id": testWorkspaceID, "name": testWorkspaceName, "role": "owner"}}, nil
case "host.workspace.list":
if err := folder(); err != nil {
return nil, err
}
var out []map[string]any
err := filepath.WalkDir(h.workspaceDir, func(p string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() || len(out) >= 1000 {
return err
}
rel := filepath.ToSlash(mustRel(h.workspaceDir, p))
if !strings.HasPrefix(rel, prefix) {
return nil
}
info, err := d.Info()
if err != nil {
return err
}
etag, err := fileETag(p)
if err != nil {
return err
}
out = append(out, map[string]any{"key": testWorkspaceID + "/files/" + rel, "size": info.Size(), "etag": etag, "modified": info.ModTime().UTC()})
return nil
})
return out, err
case "host.workspace.fetch":
file, err := keyPath(key)
if err != nil {
return nil, err
}
etag, err := fileETag(file)
if err != nil {
return nil, fmt.Errorf("%s does not exist", key)
}
// Like Cletus: a cached copy in the working folder's scratch area,
// which host.files.resolve accepts.
dst := filepath.Join(h.filesDir, ".cletus", "tmp", "workspace", filepath.FromSlash(strings.TrimPrefix(key, testWorkspaceID+"/files/")))
if err := copyFile(file, dst); err != nil {
return nil, err
}
return map[string]string{"path": dst, "etag": etag}, nil
case "host.workspace.read":
file, err := keyPath(key)
if err != nil {
return nil, err
}
info, err := os.Stat(file)
if err != nil {
return nil, fmt.Errorf("%s does not exist", key)
}
if info.Size() > 2<<20 {
return nil, fmt.Errorf("%s is larger than 2 MiB; use host.workspace.fetch", key)
}
body, err := os.ReadFile(file)
if err != nil {
return nil, err
}
etag, _ := fileETag(file)
return map[string]any{"data": base64.StdEncoding.EncodeToString(body), "etag": etag, "size": len(body)}, nil
case "host.workspace.put":
if err := folder(); err != nil {
return nil, err
}
if key == "" || strings.Contains(key, "..") || strings.HasPrefix(key, "/") || strings.Contains(key, "//") {
return nil, fmt.Errorf("key %q must be a relative path with no empty or .. segments", key)
}
dst, err := h.workspaceFile(key)
if err != nil {
return nil, err
}
var body []byte
if src != "" {
resolved, err := h.resolve(src)
if err != nil {
return nil, err
}
if body, err = os.ReadFile(resolved); err != nil {
return nil, err
}
} else if body, err = base64.StdEncoding.DecodeString(data); err != nil {
return nil, errors.New("data must be base64")
}
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return nil, err
}
if err := os.WriteFile(dst, body, 0o644); err != nil {
return nil, err
}
etag, _ := fileETag(dst)
return map[string]any{"key": testWorkspaceID + "/files/" + key, "size": len(body), "etag": etag}, nil
case "host.workspace.delete":
file, err := keyPath(key)
if err != nil {
return nil, err
}
if err := os.Remove(file); err != nil && !os.IsNotExist(err) {
return nil, err
}
return map[string]string{"key": key}, nil
case "host.workspace.create":
return nil, errors.New("the test host has one shared folder; it can't create another")
}
return nil, fmt.Errorf("unknown plugin host operation %q", method)
}
func (h *testHost) workspaceFile(rel string) (string, error) {
file := filepath.Join(h.workspaceDir, filepath.FromSlash(path.Clean("/"+rel)))
if !inside(h.workspaceDir, file) {
return "", errors.New("the key is outside the shared folder")
}
return file, nil
}
func mustRel(root, p string) string {
rel, _ := filepath.Rel(root, p)
return rel
}
func fileETag(p string) (string, error) {
f, err := os.Open(p)
if err != nil {
return "", err
}
defer f.Close()
sum := sha256.New()
if _, err := io.Copy(sum, f); err != nil {
return "", err
}
return hex.EncodeToString(sum.Sum(nil))[:32], nil
}
func copyFile(src, dst string) error {
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
return err
}
if _, err := io.Copy(out, in); err != nil {
out.Close()
return err
}
return out.Close()
}