297 lines
10 KiB
Go
297 lines
10 KiB
Go
// Package manifest reads and checks plugin.json, and holds the permission
|
|
// table: which host methods each permission unlocks (docs/specification.md,
|
|
// "Permissions"; docs/host-api.md).
|
|
package manifest
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// HostAPI is the host API version this SDK targets.
|
|
const HostAPI = 2
|
|
|
|
// Plugin is plugin.json.
|
|
type Plugin struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
Version string `json:"version"`
|
|
MinAppVersion string `json:"min_app_version"`
|
|
HostAPI int `json:"host_api"`
|
|
Color string `json:"color,omitempty"`
|
|
Permissions []string `json:"permissions,omitempty"`
|
|
FileTypes []string `json:"file_types,omitempty"`
|
|
OpensFolders bool `json:"opens_folders,omitempty"`
|
|
Tools []Tool `json:"tools,omitempty"`
|
|
Provides map[string]Service `json:"provides,omitempty"`
|
|
Uses []string `json:"uses,omitempty"`
|
|
Dependencies []string `json:"dependencies,omitempty"`
|
|
|
|
// Fields a publisher may not set, or that wait for a later release.
|
|
UI json.RawMessage `json:"ui,omitempty"`
|
|
UIHostAPI json.RawMessage `json:"ui_host_api,omitempty"`
|
|
Free *bool `json:"free,omitempty"`
|
|
Capability string `json:"capability,omitempty"`
|
|
HostSeams []string `json:"host_seams,omitempty"`
|
|
}
|
|
|
|
// Tool is one entry of plugin.json "tools".
|
|
type Tool struct {
|
|
Name string `json:"name"`
|
|
Mutating bool `json:"mutating"`
|
|
RequiresApproval bool `json:"requires_approval,omitempty"`
|
|
}
|
|
|
|
// Service is one entry of plugin.json "provides".
|
|
type Service struct {
|
|
Methods map[string]Method `json:"methods"`
|
|
}
|
|
|
|
// Method is one method of a provided service.
|
|
type Method struct {
|
|
Description string `json:"description"`
|
|
Params json.RawMessage `json:"params"`
|
|
Result json.RawMessage `json:"result"`
|
|
Mutating bool `json:"mutating,omitempty"`
|
|
Long bool `json:"long,omitempty"`
|
|
}
|
|
|
|
// Read reads and decodes plugin.json, refusing unknown fields.
|
|
func Read(path string) (*Plugin, error) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
dec := json.NewDecoder(strings.NewReader(string(data)))
|
|
dec.DisallowUnknownFields()
|
|
var p Plugin
|
|
if err := dec.Decode(&p); err != nil {
|
|
return nil, fmt.Errorf("%s: %w", path, err)
|
|
}
|
|
return &p, nil
|
|
}
|
|
|
|
// Always are the host methods every plugin may call.
|
|
var Always = []string{
|
|
"host.auth.has_capability",
|
|
"host.data.publish", "host.data.revoke", "host.data.leases", "host.data.remove",
|
|
"host.event", "host.screen.open",
|
|
"host.dialog.open", "host.dialog.folder", "host.dialog.save", "host.dialog.message",
|
|
"host.plugin.call",
|
|
}
|
|
|
|
// Permission is one permission a plugin may declare.
|
|
type Permission struct {
|
|
Words string // shown to the admin and the user
|
|
Methods []string // host methods it unlocks (empty: enforced by review)
|
|
}
|
|
|
|
// Permissions are the fixed permission tokens. net:<domain> is separate.
|
|
var Permissions = map[string]Permission{
|
|
"files": {"Open and save files you choose", []string{"host.files.resolve", "host.files.can_write"}},
|
|
"files.computer": {"Read and write other files on this computer", nil},
|
|
"workspace.read": {"Read files in shared folders you're a member of", []string{"host.workspace.folders", "host.workspace.list", "host.workspace.fetch", "host.workspace.read"}},
|
|
"workspace.write": {"Add, change and delete files in shared folders", []string{"host.workspace.put", "host.workspace.delete", "host.workspace.create"}},
|
|
"agents": {"Run Cletus in the background", []string{"host.agent.start", "host.agent.status", "host.agent.cancel"}},
|
|
"chat.handoff": {"Send work to chat", nil},
|
|
"identity": {"See your name and organization", []string{"host.auth.whoami"}},
|
|
"net.configured": {"Connect to devices and servers you set up in this plugin", nil},
|
|
"processes": {"Run programs on this computer", nil},
|
|
}
|
|
|
|
// NetPrefix starts a net:<domain> permission.
|
|
const NetPrefix = "net:"
|
|
|
|
// MaxNetDomains bounds the net:<domain> permissions.
|
|
const MaxNetDomains = 16
|
|
|
|
var (
|
|
idRE = regexp.MustCompile(`^[a-z][a-z0-9-]{1,31}\.[a-z][a-z0-9-]{0,47}$`)
|
|
semverRE = regexp.MustCompile(`^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$`)
|
|
toolRE = regexp.MustCompile(`^[a-z][a-z0-9_]{0,63}$`)
|
|
serviceRE = regexp.MustCompile(`^[a-z][a-z0-9]*(\.[a-z][a-z0-9]*)*$`)
|
|
methodRE = regexp.MustCompile(`^[a-z][a-z0-9_]*$`)
|
|
useRE = regexp.MustCompile(`^[a-z0-9.-]+:[a-z][a-z0-9]*(\.[a-z][a-z0-9]*)*\.([a-z][a-z0-9_]*|\*)$`)
|
|
domainRE = regexp.MustCompile(`^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$`)
|
|
extRE = regexp.MustCompile(`^\.[a-z0-9]{1,16}$`)
|
|
)
|
|
|
|
var colors = map[string]bool{"": true, "red": true, "orange": true, "amber": true, "yellow": true, "green": true,
|
|
"teal": true, "blue": true, "indigo": true, "violet": true, "pink": true, "grey": true}
|
|
|
|
// Publisher returns the publisher half of the id.
|
|
func (p *Plugin) Publisher() string { publisher, _, _ := strings.Cut(p.ID, "."); return publisher }
|
|
|
|
// Validate checks the manifest. Every problem is a plain sentence.
|
|
func (p *Plugin) Validate() []error {
|
|
var errs []error
|
|
add := func(format string, args ...any) { errs = append(errs, fmt.Errorf(format, args...)) }
|
|
if !idRE.MatchString(p.ID) {
|
|
add("id %q must be <publisher>.<name>: lowercase letters, digits and hyphens, starting with a letter", p.ID)
|
|
}
|
|
if strings.TrimSpace(p.Name) == "" {
|
|
add("name is empty")
|
|
}
|
|
if strings.TrimSpace(p.Description) == "" {
|
|
add("description is empty")
|
|
}
|
|
if !semverRE.MatchString(p.Version) {
|
|
add("version %q must be a version like 1.2.0", p.Version)
|
|
}
|
|
if !semverRE.MatchString(p.MinAppVersion) {
|
|
add("min_app_version %q must be a version like 0.5.0", p.MinAppVersion)
|
|
}
|
|
if p.HostAPI != HostAPI {
|
|
add("host_api must be %d", HostAPI)
|
|
}
|
|
if !colors[p.Color] {
|
|
add("color %q is not one of the Cletus colours", p.Color)
|
|
}
|
|
if p.Free != nil || p.Capability != "" {
|
|
add("free and capability are for Eidonix plugins; organizations turn your plugin on instead")
|
|
}
|
|
if len(p.HostSeams) > 0 {
|
|
add("host_seams is for Eidonix plugins; use permissions")
|
|
}
|
|
if len(p.UI) > 0 || len(p.UIHostAPI) > 0 {
|
|
add("screens (ui, ui_host_api) aren't available yet; deliver the feature as chat tools")
|
|
}
|
|
if len(p.FileTypes) > 0 {
|
|
add("file_types need a screen, which isn't available yet")
|
|
}
|
|
seen := map[string]bool{}
|
|
nets := 0
|
|
for _, perm := range p.Permissions {
|
|
if seen[perm] {
|
|
add("permission %q is listed twice", perm)
|
|
}
|
|
seen[perm] = true
|
|
if domain, ok := strings.CutPrefix(perm, NetPrefix); ok {
|
|
nets++
|
|
if !domainRE.MatchString(domain) {
|
|
add("%q: a net permission names one host, like net:api.example.com (no wildcards, ports or IP addresses)", perm)
|
|
}
|
|
continue
|
|
}
|
|
if _, ok := Permissions[perm]; !ok {
|
|
add("unknown permission %q; see docs/specification.md, Permissions", perm)
|
|
}
|
|
}
|
|
if nets > MaxNetDomains {
|
|
add("at most %d net:<domain> permissions", MaxNetDomains)
|
|
}
|
|
if seen["chat.handoff"] {
|
|
add("chat.handoff is for screens, which aren't available yet")
|
|
}
|
|
tools := map[string]bool{}
|
|
for _, t := range p.Tools {
|
|
if !toolRE.MatchString(t.Name) {
|
|
add("tool name %q must be lowercase letters, digits and underscores", t.Name)
|
|
}
|
|
if tools[t.Name] {
|
|
add("tool %q is listed twice", t.Name)
|
|
}
|
|
tools[t.Name] = true
|
|
}
|
|
for name, svc := range p.Provides {
|
|
if !serviceRE.MatchString(name) {
|
|
add("service name %q must be lowercase, optionally dotted", name)
|
|
}
|
|
if name == "screen" {
|
|
add("the screen service needs a screen, which isn't available yet")
|
|
}
|
|
for method, m := range svc.Methods {
|
|
if !methodRE.MatchString(method) {
|
|
add("method %s.%s: names are lowercase letters, digits and underscores", name, method)
|
|
}
|
|
for label, schema := range map[string]json.RawMessage{"params": m.Params, "result": m.Result} {
|
|
var v map[string]any
|
|
if len(schema) == 0 || json.Unmarshal(schema, &v) != nil {
|
|
add("method %s.%s: %s must be a JSON schema object", name, method, label)
|
|
} else if len(schema) > 16<<10 {
|
|
add("method %s.%s: %s is larger than 16 KiB", name, method, label)
|
|
} else if strings.Contains(string(schema), `"$ref":"http`) || strings.Contains(string(schema), `"$ref": "http`) {
|
|
add("method %s.%s: %s may not use a remote $ref", name, method, label)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
for _, use := range p.Uses {
|
|
if !useRE.MatchString(use) {
|
|
add("use %q must be <plugin>:<service>.<method> or <plugin>:<service>.*", use)
|
|
} else if strings.HasPrefix(use, p.ID+":") {
|
|
add("a plugin can't use its own service (%s)", use)
|
|
}
|
|
}
|
|
for _, ext := range p.FileTypes {
|
|
if !extRE.MatchString(ext) {
|
|
add("file type %q must look like .ext", ext)
|
|
}
|
|
}
|
|
return errs
|
|
}
|
|
|
|
// Allowed reports whether the plugin may call method, and if not, the
|
|
// permission that would allow it.
|
|
func (p *Plugin) Allowed(method string) (bool, string) {
|
|
for _, m := range Always {
|
|
if m == method {
|
|
return true, ""
|
|
}
|
|
}
|
|
for _, perm := range p.Permissions {
|
|
for _, m := range Permissions[perm].Methods {
|
|
if m == method {
|
|
return true, ""
|
|
}
|
|
}
|
|
}
|
|
for perm, def := range Permissions {
|
|
for _, m := range def.Methods {
|
|
if m == method {
|
|
return false, perm
|
|
}
|
|
}
|
|
}
|
|
return false, ""
|
|
}
|
|
|
|
// Has reports whether the plugin declares perm.
|
|
func (p *Plugin) Has(perm string) bool {
|
|
for _, have := range p.Permissions {
|
|
if have == perm {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// HasNet reports whether any net permission is declared.
|
|
func (p *Plugin) HasNet() bool {
|
|
for _, have := range p.Permissions {
|
|
if have == "net.configured" || strings.HasPrefix(have, NetPrefix) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// UsedMethods returns, sorted, the host methods the declared permissions unlock.
|
|
func (p *Plugin) UsedMethods() []string {
|
|
var out []string
|
|
for _, perm := range p.Permissions {
|
|
out = append(out, Permissions[perm].Methods...)
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
// ErrNoManifest is returned when there is no plugin.json.
|
|
var ErrNoManifest = errors.New("no plugin.json here; run cletus-plugin in the plugin's folder")
|