plugins 2.0 update

This commit is contained in:
KingKDot
2026-06-22 10:52:27 -05:00
parent e1f5e4c800
commit 30dfdda3ed
55 changed files with 2534 additions and 128 deletions
+9 -3
View File
@@ -21,7 +21,7 @@ type Manager struct {
type pluginInstance struct {
id string
manifest PluginManifest
native NativePlugin
native PluginRuntime
}
func NewManager(writer wire.Writer, host HostInfo) *Manager {
@@ -49,7 +49,13 @@ func (m *Manager) Load(ctx context.Context, manifest PluginManifest, binary []by
}
m.mu.Unlock()
np, err := loadNativePlugin(binary)
var np PluginRuntime
var err error
if manifest.RuntimeKind == "wasm" || manifest.WASM != "" {
np, err = loadWASMPlugin(ctx, manifest, binary)
} else {
np, err = loadNativePlugin(binary)
}
if err != nil {
return err
}
@@ -107,7 +113,7 @@ func (m *Manager) Load(ctx context.Context, manifest PluginManifest, binary []by
rt := np.Runtime()
freeable := rt != "go"
log.Printf("[plugin] loaded %s (native, runtime=%s, freeable=%v)", pluginID, rt, freeable)
log.Printf("[plugin] loaded %s (runtime=%s, freeable=%v)", pluginID, rt, freeable)
return nil
}
+72 -1
View File
@@ -11,14 +11,28 @@ type PluginAssets struct {
type PluginManifest struct {
ID string `msgpack:"id" json:"id"`
Name string `msgpack:"name" json:"name"`
APIVersion int `msgpack:"apiVersion,omitempty" json:"apiVersion,omitempty"`
RuntimeKind string `msgpack:"runtime,omitempty" json:"runtime,omitempty"`
Version string `msgpack:"version,omitempty" json:"version,omitempty"`
Description string `msgpack:"description,omitempty" json:"description,omitempty"`
Binary string `msgpack:"binary,omitempty" json:"binary,omitempty"`
Binaries map[string]string `msgpack:"binaries,omitempty" json:"binaries,omitempty"`
WASM string `msgpack:"wasm,omitempty" json:"wasm,omitempty"`
Needs PluginNeeds `msgpack:"needs,omitempty" json:"needs,omitempty"`
Entry string `msgpack:"entry,omitempty" json:"entry,omitempty"`
Assets PluginAssets `msgpack:"assets,omitempty" json:"assets,omitempty"`
}
type PluginNeeds struct {
Files []PluginFileNeed `msgpack:"files,omitempty" json:"files,omitempty"`
}
type PluginFileNeed struct {
Bucket string `msgpack:"bucket" json:"bucket"`
Access []string `msgpack:"access" json:"access"`
Reason string `msgpack:"reason,omitempty" json:"reason,omitempty"`
}
type PluginMessage struct {
Type string `msgpack:"type"`
Event string `msgpack:"event,omitempty"`
@@ -33,7 +47,7 @@ type HostInfo struct {
Version string `msgpack:"version" json:"version"`
}
type NativePlugin interface {
type PluginRuntime interface {
Load(send func(event string, payload []byte), hostInfo []byte) error
Event(event string, payload []byte) error
@@ -45,13 +59,18 @@ type NativePlugin interface {
Runtime() string
}
type NativePlugin = PluginRuntime
func ManifestFromMap(m map[string]interface{}) (PluginManifest, error) {
manifest := PluginManifest{}
manifest.ID = stringVal(m["id"])
manifest.Name = stringVal(m["name"])
manifest.APIVersion = intVal(m["apiVersion"])
manifest.RuntimeKind = stringVal(m["runtime"])
manifest.Version = stringVal(m["version"])
manifest.Description = stringVal(m["description"])
manifest.Binary = stringVal(m["binary"])
manifest.WASM = stringVal(m["wasm"])
manifest.Entry = stringVal(m["entry"])
if binariesRaw, ok := m["binaries"].(map[string]interface{}); ok {
@@ -70,6 +89,27 @@ func ManifestFromMap(m map[string]interface{}) (PluginManifest, error) {
JS: stringVal(assetsRaw["js"]),
}
}
if needsRaw, ok := m["needs"].(map[string]interface{}); ok {
if filesRaw, ok := needsRaw["files"].([]interface{}); ok {
for _, item := range filesRaw {
fileRaw, ok := item.(map[string]interface{})
if !ok {
continue
}
need := PluginFileNeed{Bucket: stringVal(fileRaw["bucket"]), Reason: stringVal(fileRaw["reason"])}
if accessRaw, ok := fileRaw["access"].([]interface{}); ok {
for _, v := range accessRaw {
if s, ok := v.(string); ok && s != "" {
need.Access = append(need.Access, s)
}
}
}
if need.Bucket != "" && len(need.Access) > 0 {
manifest.Needs.Files = append(manifest.Needs.Files, need)
}
}
}
}
if manifest.ID == "" {
return PluginManifest{}, errors.New("missing plugin id")
@@ -84,3 +124,34 @@ func stringVal(v interface{}) string {
s, _ := v.(string)
return s
}
func intVal(v interface{}) int {
switch n := v.(type) {
case int:
return n
case int8:
return int(n)
case int16:
return int(n)
case int32:
return int(n)
case int64:
return int(n)
case uint:
return int(n)
case uint8:
return int(n)
case uint16:
return int(n)
case uint32:
return int(n)
case uint64:
return int(n)
case float32:
return int(n)
case float64:
return int(n)
default:
return 0
}
}
@@ -0,0 +1,511 @@
package plugins
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"github.com/tetratelabs/wazero"
"github.com/tetratelabs/wazero/api"
"github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1"
)
const maxWASMFileReadBytes = 32 * 1024 * 1024
type wasmPlugin struct {
ctx context.Context
cancel context.CancelFunc
runtime wazero.Runtime
module api.Module
manifest PluginManifest
hostInfo []byte
send func(string, []byte)
mu sync.Mutex
acl map[string]map[string]bool
}
func loadWASMPlugin(parent context.Context, manifest PluginManifest, data []byte) (PluginRuntime, error) {
if len(data) == 0 {
return nil, errors.New("empty wasm plugin module")
}
ctx, cancel := context.WithCancel(parent)
rt := wazero.NewRuntime(ctx)
wp := &wasmPlugin{
ctx: ctx,
cancel: cancel,
runtime: rt,
manifest: manifest,
acl: buildFileACL(manifest.Needs),
}
wasi_snapshot_preview1.MustInstantiate(ctx, rt)
if err := wp.instantiateHostModule("env"); err != nil {
cancel()
_ = rt.Close(ctx)
return nil, err
}
if err := wp.instantiateHostModule("overlord"); err != nil {
cancel()
_ = rt.Close(ctx)
return nil, err
}
mod, err := rt.InstantiateWithConfig(ctx, data, wazero.NewModuleConfig().
WithName(manifest.ID).
WithStartFunctions("_initialize"))
if err != nil {
cancel()
_ = rt.Close(ctx)
return nil, fmt.Errorf("wasm instantiate: %w", err)
}
wp.module = mod
return wp, nil
}
func (p *wasmPlugin) instantiateHostModule(name string) error {
builder := p.runtime.NewHostModuleBuilder(name)
export := func(name string, params []api.ValueType, results []api.ValueType, fn api.GoModuleFunc) {
builder.NewFunctionBuilder().WithGoModuleFunction(fn, params, results).Export(name)
}
export("overlord_emit",
[]api.ValueType{api.ValueTypeI32, api.ValueTypeI32, api.ValueTypeI32, api.ValueTypeI32},
[]api.ValueType{api.ValueTypeI32},
func(ctx context.Context, mod api.Module, stack []uint64) {
event, ok := readWASMBytes(mod, uint32(stack[0]), uint32(stack[1]))
if !ok {
stack[0] = wasmStatus(statusInvalid)
return
}
payload, ok := readWASMBytes(mod, uint32(stack[2]), uint32(stack[3]))
if !ok {
stack[0] = wasmStatus(statusInvalid)
return
}
if p.send != nil {
p.send(string(event), payload)
}
stack[0] = 0
})
export("overlord_host_info",
[]api.ValueType{api.ValueTypeI32, api.ValueTypeI32},
[]api.ValueType{api.ValueTypeI32},
func(ctx context.Context, mod api.Module, stack []uint64) {
stack[0] = wasmStatus(writeWASMOutput(mod, uint32(stack[0]), uint32(stack[1]), p.hostInfo))
})
export("overlord_fs_stat", fsReadParams(), []api.ValueType{api.ValueTypeI32}, p.fsStat)
export("overlord_fs_list", fsReadParams(), []api.ValueType{api.ValueTypeI32}, p.fsList)
export("overlord_fs_read", fsReadParams(), []api.ValueType{api.ValueTypeI32}, p.fsRead)
export("overlord_fs_write",
[]api.ValueType{api.ValueTypeI32, api.ValueTypeI32, api.ValueTypeI32, api.ValueTypeI32, api.ValueTypeI32, api.ValueTypeI32},
[]api.ValueType{api.ValueTypeI32},
p.fsWrite)
export("overlord_fs_delete",
[]api.ValueType{api.ValueTypeI32, api.ValueTypeI32, api.ValueTypeI32, api.ValueTypeI32},
[]api.ValueType{api.ValueTypeI32},
p.fsDelete)
export("overlord_fs_mkdir",
[]api.ValueType{api.ValueTypeI32, api.ValueTypeI32, api.ValueTypeI32, api.ValueTypeI32},
[]api.ValueType{api.ValueTypeI32},
p.fsMkdir)
_, err := builder.Instantiate(p.ctx)
return err
}
func fsReadParams() []api.ValueType {
return []api.ValueType{
api.ValueTypeI32, api.ValueTypeI32, // bucket
api.ValueTypeI32, api.ValueTypeI32, // path
api.ValueTypeI32, api.ValueTypeI32, // output buffer
}
}
func (p *wasmPlugin) Load(send func(string, []byte), hostInfo []byte) error {
p.mu.Lock()
defer p.mu.Unlock()
p.send = send
p.hostInfo = append(p.hostInfo[:0], hostInfo...)
return p.callBytes("overlord_on_load", hostInfo)
}
func (p *wasmPlugin) Event(event string, payload []byte) error {
p.mu.Lock()
defer p.mu.Unlock()
eventPtr, err := p.writeGuestBytes([]byte(event))
if err != nil {
return err
}
payloadPtr, err := p.writeGuestBytes(payload)
if err != nil {
p.freeGuestBytes(eventPtr, uint32(len(event)))
return err
}
fn := p.module.ExportedFunction("overlord_on_event")
if fn == nil {
p.freeGuestBytes(eventPtr, uint32(len(event)))
p.freeGuestBytes(payloadPtr, uint32(len(payload)))
return errors.New("missing export: overlord_on_event")
}
results, err := fn.Call(p.ctx, uint64(eventPtr), uint64(len(event)), uint64(payloadPtr), uint64(len(payload)))
p.freeGuestBytes(eventPtr, uint32(len(event)))
p.freeGuestBytes(payloadPtr, uint32(len(payload)))
if err != nil {
return err
}
if len(results) > 0 && int32(uint32(results[0])) != 0 {
return fmt.Errorf("overlord_on_event returned %d", int32(uint32(results[0])))
}
return nil
}
func (p *wasmPlugin) Unload() {
p.mu.Lock()
defer p.mu.Unlock()
if p.module == nil {
return
}
if fn := p.module.ExportedFunction("overlord_on_unload"); fn != nil {
_, _ = fn.Call(p.ctx)
}
}
func (p *wasmPlugin) Close() error {
p.Unload()
p.cancel()
if p.runtime != nil {
return p.runtime.Close(context.Background())
}
return nil
}
func (p *wasmPlugin) Runtime() string {
return "wasm"
}
func (p *wasmPlugin) callBytes(export string, data []byte) error {
ptr, err := p.writeGuestBytes(data)
if err != nil {
return err
}
defer p.freeGuestBytes(ptr, uint32(len(data)))
fn := p.module.ExportedFunction(export)
if fn == nil {
return fmt.Errorf("missing export: %s", export)
}
results, err := fn.Call(p.ctx, uint64(ptr), uint64(len(data)))
if err != nil {
return err
}
if len(results) > 0 && int32(uint32(results[0])) != 0 {
return fmt.Errorf("%s returned %d", export, int32(uint32(results[0])))
}
return nil
}
func (p *wasmPlugin) writeGuestBytes(data []byte) (uint32, error) {
alloc := p.module.ExportedFunction("overlord_alloc")
if alloc == nil {
return 0, errors.New("missing export: overlord_alloc")
}
results, err := alloc.Call(p.ctx, uint64(len(data)))
if err != nil {
return 0, err
}
if len(results) == 0 || uint32(results[0]) == 0 {
return 0, errors.New("overlord_alloc returned null")
}
ptr := uint32(results[0])
if len(data) > 0 && !p.module.Memory().Write(ptr, data) {
p.freeGuestBytes(ptr, uint32(len(data)))
return 0, errors.New("wasm memory write failed")
}
return ptr, nil
}
func (p *wasmPlugin) freeGuestBytes(ptr uint32, size uint32) {
if ptr == 0 {
return
}
if free := p.module.ExportedFunction("overlord_free"); free != nil {
_, _ = free.Call(p.ctx, uint64(ptr), uint64(size))
}
}
type wasmFileStat struct {
Path string `json:"path"`
IsDir bool `json:"isDir"`
Size int64 `json:"size"`
ModTime int64 `json:"modTime"`
Mode string `json:"mode"`
}
func (p *wasmPlugin) fsStat(ctx context.Context, mod api.Module, stack []uint64) {
target, status := p.resolveFromStack(mod, stack, "read")
if status != statusOK {
stack[0] = wasmStatus(status)
return
}
st, err := os.Stat(target)
if err != nil {
stack[0] = wasmStatus(statusNotFound)
return
}
info := wasmFileStat{Path: target, IsDir: st.IsDir(), Size: st.Size(), ModTime: st.ModTime().UnixMilli(), Mode: st.Mode().String()}
p.writeJSONResult(mod, stack, info)
}
func (p *wasmPlugin) fsList(ctx context.Context, mod api.Module, stack []uint64) {
target, status := p.resolveFromStack(mod, stack, "list")
if status != statusOK {
stack[0] = wasmStatus(status)
return
}
entries, err := os.ReadDir(target)
if err != nil {
stack[0] = wasmStatus(statusIO)
return
}
out := make([]wasmFileStat, 0, len(entries))
for _, ent := range entries {
st, err := ent.Info()
if err != nil {
continue
}
out = append(out, wasmFileStat{Path: ent.Name(), IsDir: ent.IsDir(), Size: st.Size(), ModTime: st.ModTime().UnixMilli(), Mode: st.Mode().String()})
}
p.writeJSONResult(mod, stack, out)
}
func (p *wasmPlugin) fsRead(ctx context.Context, mod api.Module, stack []uint64) {
target, status := p.resolveFromStack(mod, stack, "read")
if status != statusOK {
stack[0] = wasmStatus(status)
return
}
st, err := os.Stat(target)
if err != nil || st.IsDir() {
stack[0] = wasmStatus(statusNotFound)
return
}
if st.Size() > maxWASMFileReadBytes {
stack[0] = wasmStatus(statusTooLarge)
return
}
data, err := os.ReadFile(target)
if err != nil {
stack[0] = wasmStatus(statusIO)
return
}
stack[0] = wasmStatus(writeWASMOutput(mod, uint32(stack[4]), uint32(stack[5]), data))
}
func (p *wasmPlugin) fsWrite(ctx context.Context, mod api.Module, stack []uint64) {
target, status := p.resolvePath(mod, uint32(stack[0]), uint32(stack[1]), uint32(stack[2]), uint32(stack[3]), "write")
if status != statusOK {
stack[0] = wasmStatus(status)
return
}
data, ok := readWASMBytes(mod, uint32(stack[4]), uint32(stack[5]))
if !ok {
stack[0] = wasmStatus(statusInvalid)
return
}
if err := os.MkdirAll(filepath.Dir(target), 0o700); err != nil {
stack[0] = wasmStatus(statusIO)
return
}
if err := os.WriteFile(target, data, 0o600); err != nil {
stack[0] = wasmStatus(statusIO)
return
}
stack[0] = 0
}
func (p *wasmPlugin) fsDelete(ctx context.Context, mod api.Module, stack []uint64) {
target, status := p.resolvePath(mod, uint32(stack[0]), uint32(stack[1]), uint32(stack[2]), uint32(stack[3]), "delete")
if status != statusOK {
stack[0] = wasmStatus(status)
return
}
if err := os.RemoveAll(target); err != nil {
stack[0] = wasmStatus(statusIO)
return
}
stack[0] = 0
}
func (p *wasmPlugin) fsMkdir(ctx context.Context, mod api.Module, stack []uint64) {
target, status := p.resolvePath(mod, uint32(stack[0]), uint32(stack[1]), uint32(stack[2]), uint32(stack[3]), "mkdir")
if status != statusOK {
stack[0] = wasmStatus(status)
return
}
if err := os.MkdirAll(target, 0o700); err != nil {
stack[0] = wasmStatus(statusIO)
return
}
stack[0] = 0
}
func (p *wasmPlugin) resolveFromStack(mod api.Module, stack []uint64, op string) (string, int32) {
return p.resolvePath(mod, uint32(stack[0]), uint32(stack[1]), uint32(stack[2]), uint32(stack[3]), op)
}
func (p *wasmPlugin) resolvePath(mod api.Module, bucketPtr, bucketLen, pathPtr, pathLen uint32, op string) (string, int32) {
bucketBytes, ok := readWASMBytes(mod, bucketPtr, bucketLen)
if !ok {
return "", statusInvalid
}
pathBytes, ok := readWASMBytes(mod, pathPtr, pathLen)
if !ok {
return "", statusInvalid
}
bucket := string(bucketBytes)
rel := string(pathBytes)
if !p.acl[bucket][op] {
return "", statusDenied
}
return resolvePluginBucketPath(p.manifest.ID, bucket, rel)
}
func resolvePluginBucketPath(pluginID, bucket, rel string) (string, int32) {
if strings.ContainsRune(rel, 0) {
return "", statusInvalid
}
if bucket == "fullDisk" {
if rel == "" {
if filepath.Separator == '\\' {
return filepath.VolumeName(os.TempDir()) + string(filepath.Separator), statusOK
}
return string(filepath.Separator), statusOK
}
if !filepath.IsAbs(rel) {
if filepath.Separator == '\\' {
rel = filepath.VolumeName(os.TempDir()) + string(filepath.Separator) + rel
} else {
rel = string(filepath.Separator) + rel
}
}
return filepath.Clean(rel), statusOK
}
root, ok := bucketRoot(pluginID, bucket)
if !ok {
return "", statusDenied
}
if filepath.IsAbs(rel) || strings.HasPrefix(rel, string(filepath.Separator)) || strings.HasPrefix(rel, "/") || strings.HasPrefix(rel, `\`) {
return "", statusDenied
}
cleanRel := strings.TrimPrefix(filepath.Clean(string(filepath.Separator)+rel), string(filepath.Separator))
target := filepath.Join(root, cleanRel)
resolvedRoot, err := filepath.Abs(root)
if err != nil {
return "", statusIO
}
resolvedTarget, err := filepath.Abs(target)
if err != nil {
return "", statusIO
}
r, err := filepath.Rel(resolvedRoot, resolvedTarget)
if err != nil || r == ".." || strings.HasPrefix(r, ".."+string(filepath.Separator)) {
return "", statusDenied
}
return resolvedTarget, statusOK
}
func bucketRoot(pluginID, bucket string) (string, bool) {
home, _ := os.UserHomeDir()
switch bucket {
case "home":
return home, home != ""
case "desktop":
return filepath.Join(home, "Desktop"), home != ""
case "documents":
return filepath.Join(home, "Documents"), home != ""
case "downloads":
return filepath.Join(home, "Downloads"), home != ""
case "temp":
return os.TempDir(), true
case "appData":
dir, err := os.UserConfigDir()
if err != nil {
return "", false
}
return filepath.Join(dir, "Overlord"), true
case "pluginData":
dir, err := os.UserConfigDir()
if err != nil {
return "", false
}
root := filepath.Join(dir, "Overlord", "plugins", pluginID)
_ = os.MkdirAll(root, 0o700)
return root, true
default:
return "", false
}
}
func buildFileACL(needs PluginNeeds) map[string]map[string]bool {
acl := map[string]map[string]bool{}
for _, need := range needs.Files {
if acl[need.Bucket] == nil {
acl[need.Bucket] = map[string]bool{}
}
for _, op := range need.Access {
acl[need.Bucket][op] = true
}
}
return acl
}
func (p *wasmPlugin) writeJSONResult(mod api.Module, stack []uint64, v interface{}) {
data, err := json.Marshal(v)
if err != nil {
stack[0] = wasmStatus(statusIO)
return
}
stack[0] = wasmStatus(writeWASMOutput(mod, uint32(stack[4]), uint32(stack[5]), data))
}
func readWASMBytes(mod api.Module, ptr, size uint32) ([]byte, bool) {
if size == 0 {
return nil, true
}
data, ok := mod.Memory().Read(ptr, size)
if !ok {
return nil, false
}
out := make([]byte, len(data))
copy(out, data)
return out, true
}
func writeWASMOutput(mod api.Module, ptr, size uint32, data []byte) int32 {
if uint32(len(data)) > size {
return statusTooSmall
}
if len(data) > 0 && !mod.Memory().Write(ptr, data) {
return statusInvalid
}
return int32(len(data))
}
const (
statusOK int32 = 0
statusDenied int32 = -1
statusInvalid int32 = -2
statusNotFound int32 = -3
statusTooSmall int32 = -4
statusTooLarge int32 = -5
statusIO int32 = -6
)
func wasmStatus(status int32) uint64 {
return uint64(uint32(status))
}
@@ -0,0 +1,41 @@
package plugins
import (
"path/filepath"
"strings"
"testing"
)
func TestBuildFileACL(t *testing.T) {
acl := buildFileACL(PluginNeeds{Files: []PluginFileNeed{
{Bucket: "downloads", Access: []string{"read", "list"}},
}})
if !acl["downloads"]["read"] || !acl["downloads"]["list"] {
t.Fatalf("expected read/list permissions: %#v", acl)
}
if acl["downloads"]["write"] {
t.Fatalf("did not expect write permission")
}
}
func TestWriteWASMOutputStatusCodes(t *testing.T) {
status := statusTooSmall
if wasmStatus(statusTooSmall) != uint64(uint32(status)) {
t.Fatalf("negative status should be encoded as wasm i32")
}
}
func TestResolvePluginBucketPathRejectsTraversal(t *testing.T) {
target, status := resolvePluginBucketPath("demo", "pluginData", ".."+string(filepath.Separator)+"escape.txt")
if status != statusOK {
t.Fatalf("expected cleaned pluginData path, got status %d", status)
}
if strings.Contains(target, "..") {
t.Fatalf("expected traversal to be cleaned, got %q", target)
}
_, status = resolvePluginBucketPath("demo", "downloads", string(filepath.Separator)+"absolute.txt")
if status != statusDenied {
t.Fatalf("expected absolute path to be denied for scoped bucket, got %d", status)
}
}