Files
overlord/Overlord-Client/cmd/agent/plugins/wasm_runtime.go
T
2026-06-22 10:52:27 -05:00

512 lines
14 KiB
Go

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