Remove WASM plugin support
This commit is contained in:
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -49,13 +50,11 @@ func (m *Manager) Load(ctx context.Context, manifest PluginManifest, binary []by
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
var np PluginRuntime
|
||||
var err error
|
||||
if manifest.RuntimeKind == "wasm" || manifest.WASM != "" {
|
||||
np, err = loadWASMPlugin(ctx, manifest, binary)
|
||||
} else {
|
||||
np, err = loadNativePlugin(manifest, binary)
|
||||
if strings.EqualFold(strings.TrimSpace(manifest.RuntimeKind), "wasm") {
|
||||
return errors.New("WASM plugins are not supported")
|
||||
}
|
||||
|
||||
np, err := loadNativePlugin(manifest, binary)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package plugins
|
||||
|
||||
import "errors"
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type PluginAssets struct {
|
||||
HTML string `msgpack:"html" json:"html"`
|
||||
@@ -19,16 +22,10 @@ type PluginManifest struct {
|
||||
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 NativeEntrypoints struct {
|
||||
OnLoad string `msgpack:"onLoad,omitempty" json:"onLoad,omitempty"`
|
||||
OnEvent string `msgpack:"onEvent,omitempty" json:"onEvent,omitempty"`
|
||||
@@ -37,12 +34,6 @@ type NativeEntrypoints struct {
|
||||
GetRuntime string `msgpack:"getRuntime,omitempty" json:"getRuntime,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"`
|
||||
@@ -72,6 +63,10 @@ type PluginRuntime interface {
|
||||
type NativePlugin = PluginRuntime
|
||||
|
||||
func ManifestFromMap(m map[string]interface{}) (PluginManifest, error) {
|
||||
if runtime := stringVal(m["runtime"]); strings.EqualFold(strings.TrimSpace(runtime), "wasm") || strings.TrimSpace(stringVal(m["wasm"])) != "" {
|
||||
return PluginManifest{}, errors.New("WASM plugins are not supported")
|
||||
}
|
||||
|
||||
manifest := PluginManifest{}
|
||||
manifest.ID = stringVal(m["id"])
|
||||
manifest.Name = stringVal(m["name"])
|
||||
@@ -81,7 +76,6 @@ func ManifestFromMap(m map[string]interface{}) (PluginManifest, error) {
|
||||
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 entryRaw, ok := m["nativeEntrypoints"].(map[string]interface{}); ok {
|
||||
manifest.NativeEntrypoints = NativeEntrypoints{
|
||||
@@ -109,28 +103,6 @@ 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")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestManifestFromMapRejectsWASM(t *testing.T) {
|
||||
for _, manifest := range []map[string]interface{}{
|
||||
{"id": "demo", "runtime": "wasm"},
|
||||
{"id": "demo", "wasm": "demo.wasm"},
|
||||
} {
|
||||
_, err := ManifestFromMap(manifest)
|
||||
if err == nil || !strings.Contains(err.Error(), "not supported") {
|
||||
t.Fatalf("expected unsupported runtime error, got %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestManifestFromMapAcceptsNative(t *testing.T) {
|
||||
manifest, err := ManifestFromMap(map[string]interface{}{
|
||||
"id": "demo",
|
||||
"name": "Demo",
|
||||
"runtime": "native",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected native manifest to be accepted: %v", err)
|
||||
}
|
||||
if manifest.RuntimeKind != "native" {
|
||||
t.Fatalf("expected native runtime, got %q", manifest.RuntimeKind)
|
||||
}
|
||||
}
|
||||
@@ -1,511 +0,0 @@
|
||||
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))
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,6 @@ require (
|
||||
github.com/pion/rtcp v1.2.15
|
||||
github.com/pion/rtp v1.8.10
|
||||
github.com/pion/webrtc/v4 v4.0.7
|
||||
github.com/tetratelabs/wazero v1.12.0
|
||||
github.com/thesyncim/gopus v0.1.1
|
||||
golang.org/x/crypto v0.49.0
|
||||
golang.org/x/sys v0.44.0
|
||||
|
||||
@@ -77,8 +77,6 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU=
|
||||
github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0=
|
||||
github.com/thesyncim/gopus v0.1.1 h1:SM58S7VZGnEZ3ZR0pX5AoC33//LBxJhKkvm15MbBmik=
|
||||
github.com/thesyncim/gopus v0.1.1/go.mod h1:orRqwrGs5gqYRRnhqwI0Y3liqQTeDkreUpra+Kv9bQc=
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8=
|
||||
|
||||
@@ -146,24 +146,19 @@ function renderPlugins(plugins) {
|
||||
titleRow.appendChild(trustBadge);
|
||||
|
||||
const runtimeBadge = document.createElement("span");
|
||||
const isWasm = plugin.runtime === "wasm";
|
||||
const isV2 = Number(plugin.apiVersion || 1) >= 2;
|
||||
runtimeBadge.className = `inline-flex items-center gap-1 text-[10px] uppercase tracking-wider px-2 py-0.5 rounded-full border ${
|
||||
isServerOnly
|
||||
? "text-fuchsia-200 border-fuchsia-700 bg-fuchsia-950/50"
|
||||
: isWasm
|
||||
? "text-cyan-300 border-cyan-700 bg-cyan-950/40"
|
||||
: isV2
|
||||
? "text-emerald-300 border-emerald-700 bg-emerald-950/40"
|
||||
: "text-slate-300 border-slate-700 bg-slate-900/60"
|
||||
}`;
|
||||
runtimeBadge.innerHTML = isServerOnly
|
||||
? '<i class="fa-solid fa-server"></i> Server Extension'
|
||||
: isWasm
|
||||
? '<i class="fa-solid fa-cube"></i> WASM 2.0'
|
||||
: isV2
|
||||
? '<i class="fa-solid fa-code"></i> Plugin 2.0'
|
||||
: '<i class="fa-solid fa-puzzle-piece"></i> Native Legacy';
|
||||
: '<i class="fa-solid fa-puzzle-piece"></i> Native';
|
||||
titleRow.appendChild(runtimeBadge);
|
||||
|
||||
const subtitle = document.createElement("div");
|
||||
|
||||
@@ -343,7 +343,7 @@ export type PluginManifest = {
|
||||
id: string;
|
||||
name: string;
|
||||
apiVersion?: number;
|
||||
runtime?: "native" | "wasm" | "server" | string;
|
||||
runtime?: "native" | "server" | string;
|
||||
nativeLoader?: "memory" | "os" | string;
|
||||
autoLoadByDefault?: boolean;
|
||||
nativeEntrypoints?: PluginNativeEntrypoints;
|
||||
@@ -351,7 +351,6 @@ export type PluginManifest = {
|
||||
description?: string;
|
||||
binary?: string;
|
||||
binaries?: Record<string, string>;
|
||||
wasm?: string;
|
||||
needs?: PluginNeeds;
|
||||
entry?: string;
|
||||
assets?: {
|
||||
|
||||
@@ -45,7 +45,7 @@ describe("loadPluginBundle", () => {
|
||||
expect(bundle.size).toBe("test-binary".length);
|
||||
});
|
||||
|
||||
test("loads wasm manifest independently of client platform", async () => {
|
||||
test("rejects an extracted WASM manifest", async () => {
|
||||
const root = await createTempRoot();
|
||||
const pluginDir = join(root, "wasm-demo");
|
||||
await mkdir(pluginDir, { recursive: true });
|
||||
@@ -62,13 +62,23 @@ describe("loadPluginBundle", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const bundle = await loadPluginBundle(root, "wasm-demo", async () => {}, "Windows 11", "amd64");
|
||||
await expect(loadPluginBundle(root, "wasm-demo", async () => {}, "Windows 11", "amd64"))
|
||||
.rejects.toThrow("WASM plugins are not supported");
|
||||
});
|
||||
|
||||
expect(bundle.manifest.apiVersion).toBe(2);
|
||||
expect(bundle.manifest.runtime).toBe("wasm");
|
||||
expect(bundle.manifest.wasm).toBe("plugin.wasm");
|
||||
expect(bundle.binaryPath).toBe(join(root, "wasm-demo", "plugin.wasm"));
|
||||
expect(bundle.size).toBe(4);
|
||||
test("rejects a bundle containing a WASM module", async () => {
|
||||
const root = await createTempRoot();
|
||||
const AdmZip = require("adm-zip");
|
||||
const zip = new AdmZip();
|
||||
zip.addFile("config.json", Buffer.from(JSON.stringify({ name: "WASM Demo", runtime: "wasm" })));
|
||||
zip.addFile("wasm-demo.html", Buffer.from("<div></div>"));
|
||||
zip.addFile("wasm-demo.css", Buffer.from("body {}"));
|
||||
zip.addFile("wasm-demo.js", Buffer.from("console.log('demo')"));
|
||||
zip.addFile("wasm-demo.wasm", Buffer.from([0x00, 0x61, 0x73, 0x6d]));
|
||||
zip.writeZip(join(root, "wasm-demo.zip"));
|
||||
|
||||
await expect(ensurePluginExtracted(root, "wasm-demo", (name) => name))
|
||||
.rejects.toThrow("WASM plugins are not supported");
|
||||
});
|
||||
|
||||
test("keeps helper DLLs out of the native platform binary map", async () => {
|
||||
|
||||
@@ -66,11 +66,15 @@ export async function ensurePluginExtracted(
|
||||
}
|
||||
|
||||
if (!zipStat) {
|
||||
if (manifestStat) return;
|
||||
if (manifestStat) {
|
||||
await rejectUnsupportedWasmManifest(manifestPath, safeId);
|
||||
return;
|
||||
}
|
||||
throw new Error(`Plugin bundle not found: ${safeId}`);
|
||||
}
|
||||
|
||||
if (manifestStat && manifestStat.mtimeMs >= zipStat.mtimeMs) {
|
||||
await rejectUnsupportedWasmManifest(manifestPath, safeId);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -101,7 +105,6 @@ export async function ensurePluginExtracted(
|
||||
let jsEntry: Buffer | null = null;
|
||||
let serverEntry: Buffer | null = null;
|
||||
let configEntry: Buffer | null = null;
|
||||
let wasmEntry: { filename: string; data: Buffer } | null = null;
|
||||
const sourceEntries: Map<string, Buffer> = new Map();
|
||||
const nativeBinaries: Map<string, Buffer> = new Map();
|
||||
|
||||
@@ -113,7 +116,7 @@ export async function ensurePluginExtracted(
|
||||
if (lower.endsWith(".so") || lower.endsWith(".dll") || lower.endsWith(".dylib")) {
|
||||
nativeBinaries.set(base, entry.getData());
|
||||
} else if (lower.endsWith(".wasm")) {
|
||||
wasmEntry = { filename: base, data: entry.getData() };
|
||||
throw new Error(`Invalid plugin bundle: ${safeId} (WASM plugins are not supported)`);
|
||||
} else if (lower === "server.js") {
|
||||
serverEntry = entry.getData();
|
||||
} else if (lower.endsWith(".html")) {
|
||||
@@ -139,6 +142,10 @@ export async function ensurePluginExtracted(
|
||||
}
|
||||
}
|
||||
|
||||
if (declaresWasmPlugin(extraConfig)) {
|
||||
throw new Error(`Invalid plugin bundle: ${safeId} (WASM plugins are not supported)`);
|
||||
}
|
||||
|
||||
const uiTsEntry = getConfiguredSourceEntry(extraConfig, ["uiEntry", "ui.entry", "build.ui", "build.uiEntry"], sourceEntries)
|
||||
|| (sourceEntries.has("src/ui.ts") ? "src/ui.ts" : "")
|
||||
|| (sourceEntries.has("src/index.ts") ? "src/index.ts" : "");
|
||||
@@ -163,11 +170,6 @@ export async function ensurePluginExtracted(
|
||||
binariesMap[platformKey] = filename;
|
||||
}
|
||||
}
|
||||
if (wasmEntry) {
|
||||
await fs.writeFile(path.join(pluginDir, wasmEntry.filename), wasmEntry.data);
|
||||
binariesMap["wasm32-wasi"] = wasmEntry.filename;
|
||||
}
|
||||
|
||||
if (sourceEntries.size > 0) {
|
||||
await fs.rm(path.join(pluginDir, "src"), { recursive: true, force: true });
|
||||
for (const [relPath, data] of sourceEntries) {
|
||||
@@ -199,7 +201,7 @@ export async function ensurePluginExtracted(
|
||||
try { await fs.rm(serverScriptPath, { force: true }); } catch {}
|
||||
}
|
||||
|
||||
const runtime = normalizePluginRuntime(extraConfig.runtime, wasmEntry !== null);
|
||||
const runtime = normalizePluginRuntime(extraConfig.runtime);
|
||||
const nativeLoader = normalizeNativeLoader(extraConfig.nativeLoader);
|
||||
const nativeEntrypoints = normalizeNativeEntrypoints(extraConfig.nativeEntrypoints);
|
||||
const needs = normalizePluginNeeds(extraConfig.needs);
|
||||
@@ -207,7 +209,7 @@ export async function ensurePluginExtracted(
|
||||
const manifest: PluginManifest = {
|
||||
id: safeId,
|
||||
name: extraConfig.name || safeId,
|
||||
apiVersion: Number(extraConfig.apiVersion) === 2 || runtime === "wasm" ? 2 : 1,
|
||||
apiVersion: Number(extraConfig.apiVersion) === 2 ? 2 : 1,
|
||||
runtime,
|
||||
...(nativeLoader && { nativeLoader }),
|
||||
...(extraConfig.autoLoadByDefault === true && { autoLoadByDefault: true }),
|
||||
@@ -215,7 +217,6 @@ export async function ensurePluginExtracted(
|
||||
version: extraConfig.version || "1.0.0",
|
||||
description: extraConfig.description,
|
||||
...(typeof extraConfig.binary === "string" && extraConfig.binary && { binary: extraConfig.binary }),
|
||||
...(runtime === "wasm" && { wasm: String(extraConfig.wasm || wasmEntry?.filename || binariesMap["wasm32-wasi"] || "") }),
|
||||
...(needs && { needs }),
|
||||
binaries: binariesMap,
|
||||
entry: `${safeId}.html`,
|
||||
@@ -280,6 +281,10 @@ export async function listPluginManifests(
|
||||
try {
|
||||
const raw = await fs.readFile(manifestPath, "utf-8");
|
||||
const manifest = JSON.parse(raw) as PluginManifest;
|
||||
if (declaresWasmPlugin(manifest)) {
|
||||
warnPlugin(`[plugin] skipped ${ent.name}: WASM plugins are not supported`);
|
||||
continue;
|
||||
}
|
||||
const id = manifest.id || ent.name;
|
||||
const name = manifest.name || ent.name;
|
||||
if (pluginState.enabled[id] === undefined) {
|
||||
@@ -318,14 +323,8 @@ export async function loadPluginBundle(
|
||||
manifest.id = manifest.id || pluginId;
|
||||
manifest.name = manifest.name || pluginId;
|
||||
|
||||
if (manifest.runtime === "wasm" || manifest.wasm || manifest.binaries?.["wasm32-wasi"]) {
|
||||
const wasmName = manifest.wasm || manifest.binaries?.["wasm32-wasi"];
|
||||
if (!wasmName) {
|
||||
throw new Error(`No WASM module configured for ${pluginId}`);
|
||||
}
|
||||
const binaryPath = path.join(dir, wasmName);
|
||||
const stat = await fs.stat(binaryPath);
|
||||
return { manifest: { ...manifest, runtime: "wasm", apiVersion: 2 }, binaryPath, size: stat.size };
|
||||
if (declaresWasmPlugin(manifest)) {
|
||||
throw new Error(`WASM plugins are not supported: ${pluginId}`);
|
||||
}
|
||||
|
||||
manifest.runtime = manifest.runtime || "native";
|
||||
@@ -632,13 +631,30 @@ export function normalizePluginNeeds(raw: any): PluginNeeds | undefined {
|
||||
return files.length > 0 ? { files } : undefined;
|
||||
}
|
||||
|
||||
function normalizePluginRuntime(runtime: any, hasWasm: boolean): "native" | "wasm" | "server" {
|
||||
function normalizePluginRuntime(runtime: any): "native" | "server" {
|
||||
const value = typeof runtime === "string" ? runtime.trim().toLowerCase() : "";
|
||||
if (value === "wasm" || hasWasm) return "wasm";
|
||||
if (value === "server") return "server";
|
||||
return "native";
|
||||
}
|
||||
|
||||
function declaresWasmPlugin(value: any): boolean {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
if (typeof value.runtime === "string" && value.runtime.trim().toLowerCase() === "wasm") return true;
|
||||
if (typeof value.wasm === "string" && value.wasm.trim()) return true;
|
||||
if (!value.binaries || typeof value.binaries !== "object" || Array.isArray(value.binaries)) return false;
|
||||
return Object.entries(value.binaries).some(([key, filename]) =>
|
||||
key.trim().toLowerCase().includes("wasm")
|
||||
|| (typeof filename === "string" && filename.trim().toLowerCase().endsWith(".wasm"))
|
||||
);
|
||||
}
|
||||
|
||||
async function rejectUnsupportedWasmManifest(manifestPath: string, pluginId: string): Promise<void> {
|
||||
const manifest = JSON.parse(await fs.readFile(manifestPath, "utf-8"));
|
||||
if (declaresWasmPlugin(manifest)) {
|
||||
throw new Error(`WASM plugins are not supported: ${pluginId}`);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeNativeLoader(loader: any): "memory" | "os" | undefined {
|
||||
const value = typeof loader === "string" ? loader.trim().toLowerCase() : "";
|
||||
if (["memory", "mem", "reflective", "manual"].includes(value)) return "memory";
|
||||
|
||||
+5
-11
@@ -1,6 +1,6 @@
|
||||
# Overlord Plugins
|
||||
|
||||
This is the short entry point for plugin authors. The detailed docs are split by topic so server-only build extensions, WASM agent plugins, legacy native plugins, signing, and examples can evolve independently.
|
||||
This is the short entry point for plugin authors. The detailed docs are split by topic so server-only build extensions, native agent plugins, signing, and examples can evolve independently.
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -12,7 +12,7 @@ A Plugin 2.0 bundle is a zip with a root `config.json`, browser assets, and opti
|
||||
<pluginId>.html
|
||||
<pluginId>.css
|
||||
<pluginId>.js optional when src/ui.ts exists
|
||||
<pluginId>.wasm optional agent-side WASM module
|
||||
<pluginId>-<os>-<arch>.* optional native agent library
|
||||
server.js optional server-side runtime
|
||||
src/ optional TypeScript source
|
||||
```
|
||||
@@ -25,19 +25,16 @@ Upload the zip from the Plugins page, or place it under `Overlord-Server/plugins
|
||||
|-------|-----------|
|
||||
| Bundle layout, `config.json`, TypeScript UI/server entries | [docs/bundle-format.md](docs/bundle-format.md) |
|
||||
| Server-only plugins, plugin data, RPC/SSE, build hooks, build settings/buttons | [docs/server-side-plugins.md](docs/server-side-plugins.md) |
|
||||
| WASM Plugin 2.0 agent modules and permissioned file bridges | [docs/wasm-plugins.md](docs/wasm-plugins.md) |
|
||||
| Install/upload flow, Plugin Manager behavior, auto-load rules | [docs/install-and-manage.md](docs/install-and-manage.md) |
|
||||
| Sample plugin directory guide | [docs/samples.md](docs/samples.md) |
|
||||
| Legacy native plugins: OS APIs, native ABI, build scripts, Linux static-agent shim | [docs/legacy-native-plugins.md](docs/legacy-native-plugins.md) |
|
||||
| Native plugins: OS APIs, native ABI, build scripts, Linux static-agent shim | [docs/legacy-native-plugins.md](docs/legacy-native-plugins.md) |
|
||||
| Plugin signing and trusted keys | [docs/signing.md](docs/signing.md) |
|
||||
|
||||
## Runtime Choices
|
||||
|
||||
Use `runtime: "server"` for server-side extensions. These can add server UI, store files, expose RPCs, subscribe to plugin events, and hook the build pipeline. Server-only plugins are shown as server extensions in the Plugin Manager, are not sent to clients, and do not use auto-load.
|
||||
|
||||
Use `runtime: "wasm"` for Plugin 2.0 agent code. WASM plugins are sandboxed on the agent and access host features only through declared needs and Overlord bridge APIs.
|
||||
|
||||
Use legacy native plugins when you need direct OS APIs, native libraries, platform-specific behavior, or other privileged agent-side functionality that the WASM sandbox does not expose. Native plugins run with the agent's privileges and require one binary per OS/architecture.
|
||||
Use `runtime: "native"` for agent-side plugins that need direct OS APIs, native libraries, or platform-specific behavior. Native plugins run with the agent's privileges and require one binary per OS/architecture.
|
||||
|
||||
## Current Samples
|
||||
|
||||
@@ -45,9 +42,6 @@ Use legacy native plugins when you need direct OS APIs, native libraries, platfo
|
||||
|-----------|---------|
|
||||
| `sample-build-hooks` | Server-only build plugin with its own Build page button and artifact replacement example. |
|
||||
| `sample-ts-fullstack` | TypeScript UI plus TypeScript server runtime. |
|
||||
| `sample-wasm` | C/WASI Plugin 2.0 WASM example. |
|
||||
| `sample-wasm-hostinfo` | TinyGo WASM HostInfo example. |
|
||||
| `sample-wasm-platform-note` | Rust WASM platform-aware example. |
|
||||
| `sample-c`, `sample-cpp`, `sample-go`, `sample-rust` | Legacy native v1 examples. |
|
||||
| `sample-c`, `sample-cpp`, `sample-go`, `sample-rust` | Native agent plugin examples. |
|
||||
|
||||
Start with [docs/server-side-plugins.md](docs/server-side-plugins.md) if you are building uploaders, custom build buttons, artifact post-processing, or internal release hooks.
|
||||
|
||||
@@ -6,8 +6,7 @@ These docs are split from `plugins/PLUGINS.md` so each plugin area has a smaller
|
||||
|------|--------|
|
||||
| [bundle-format.md](bundle-format.md) | Bundle layout, manifest fields, nav entries, TypeScript entrypoints. |
|
||||
| [server-side-plugins.md](server-side-plugins.md) | Server-only plugin runtime, RPC/SSE, persistent data, build hooks, build UI settings/actions. |
|
||||
| [wasm-plugins.md](wasm-plugins.md) | Plugin 2.0 WASM runtime and agent-side host bridges. |
|
||||
| [install-and-manage.md](install-and-manage.md) | Uploading, opening, enabling, auto-load, Plugin Manager behavior. |
|
||||
| [samples.md](samples.md) | Included sample plugins and what each demonstrates. |
|
||||
| [legacy-native-plugins.md](legacy-native-plugins.md) | Legacy native plugins, direct OS/API use, native ABI, build scripts, static Linux shim. |
|
||||
| [legacy-native-plugins.md](legacy-native-plugins.md) | Native plugins, direct OS/API use, native ABI, build scripts, static Linux shim. |
|
||||
| [signing.md](signing.md) | Ed25519 plugin signing and trusted key management. |
|
||||
|
||||
@@ -10,7 +10,7 @@ Plugin bundles are zip files named after the plugin ID. `config.json` must be at
|
||||
<pluginId>.html
|
||||
<pluginId>.css
|
||||
<pluginId>.js optional when src/ui.ts exists
|
||||
<pluginId>.wasm optional agent-side WASM module
|
||||
<pluginId>-<os>-<arch>.* optional native agent library
|
||||
server.js optional server runtime
|
||||
src/
|
||||
ui.ts compiled to assets/<pluginId>.js
|
||||
@@ -25,8 +25,7 @@ After extraction the server stores assets under `Overlord-Server/plugins/<plugin
|
||||
```json
|
||||
{
|
||||
"apiVersion": 2,
|
||||
"runtime": "wasm",
|
||||
"wasm": "sample-wasm.wasm"
|
||||
"runtime": "server"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -35,8 +34,7 @@ Use:
|
||||
| Runtime | Use case |
|
||||
|---------|----------|
|
||||
| `server` | Server-only extension, build plugin, dashboard plugin, RPC-backed plugin. |
|
||||
| `wasm` | Sandboxed agent-side Plugin 2.0 module. |
|
||||
| `native` or omitted v1 fields | Legacy shared-library agent plugin. |
|
||||
| `native` or omitted v1 fields | Shared-library agent plugin. |
|
||||
|
||||
Server-only plugins are not sent to clients and do not use auto-load.
|
||||
|
||||
|
||||
@@ -36,8 +36,7 @@ Navbar plugins appear in the Plugin Apps group when enabled and configured with:
|
||||
| Plugin kind | Manager behavior |
|
||||
|-------------|------------------|
|
||||
| Server-only (`runtime: "server"`) | Shown as a server extension. Auto-load controls are hidden. It is not sent to clients. |
|
||||
| WASM (`runtime: "wasm"`) | Shows declared needs and approval state. Can be loaded on compatible clients after needs approval. |
|
||||
| Legacy native | Shows native binary/runtime compatibility. Requires matching OS/architecture binary. |
|
||||
| Native (`runtime: "native"`) | Shows native binary/runtime compatibility. Requires a matching OS/architecture binary. |
|
||||
|
||||
## Auto-Load
|
||||
|
||||
@@ -50,16 +49,15 @@ Auto-load notes:
|
||||
| Rule | Detail |
|
||||
|------|--------|
|
||||
| Enabled flag | Disabled plugins are never auto-loaded. |
|
||||
| WASM needs | WASM plugins with unapproved needs are blocked. |
|
||||
| Persistence | Auto-load state is saved in `.plugin-state.json`. |
|
||||
| Platform matching | WASM is universal; legacy native requires a matching binary. |
|
||||
| Platform matching | Native plugins require a matching binary. |
|
||||
| Bundle default | `autoLoadByDefault: true` enables auto-load only after the plugin passes trusted enablement or an administrator explicitly confirms enablement. |
|
||||
|
||||
## Useful APIs
|
||||
|
||||
| Endpoint | Purpose |
|
||||
|----------|---------|
|
||||
| `GET /api/plugins` | List installed plugins, runtime, needs, enabled state, server state, and auto-load state. |
|
||||
| `GET /api/plugins` | List installed plugins, runtime, enabled state, server state, and auto-load state. |
|
||||
| `POST /api/plugins/<id>/autoload` | Configure auto-load for client-side plugins. Server-only plugins reject this. |
|
||||
| `POST /api/plugins/<id>/rpc` | Call a server-side plugin RPC method. |
|
||||
| `GET /api/plugins/<id>/stream` | Subscribe to server-side plugin broadcasts. |
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
# Legacy Native Plugins
|
||||
# Native Plugins
|
||||
|
||||
Legacy native v1 plugins run as shared libraries inside the agent. They are still supported and still useful when you need capabilities that the WASM runtime intentionally does not expose.
|
||||
Native plugins run as shared libraries inside the agent.
|
||||
|
||||
Use legacy native plugins when you need direct OS APIs, native libraries, custom process/thread behavior, platform-specific integrations, or code that cannot reasonably be compiled to the Plugin 2.0 WASM ABI.
|
||||
Use native plugins when you need direct OS APIs, native libraries, custom process/thread behavior, or platform-specific integrations.
|
||||
|
||||
Native plugins run with the same privileges as the agent. They are not sandboxed.
|
||||
|
||||
## What Legacy Plugins Can Do
|
||||
## What Native Plugins Can Do
|
||||
|
||||
| Capability | Notes |
|
||||
|------------|-------|
|
||||
@@ -16,11 +16,9 @@ Native plugins run with the same privileges as the agent. They are not sandboxed
|
||||
| Emit plugin events | Send events back to the UI/server through the host callback. |
|
||||
| Receive UI/server commands | Handle `PluginOnEvent` calls from plugin UI actions or server-side routing. |
|
||||
|
||||
WASM Plugin 2.0 is preferred for sandboxed, portable agent modules. Legacy native plugins are the escape hatch for privileged native behavior.
|
||||
|
||||
## Bundle Layout
|
||||
|
||||
A legacy bundle is a zip with web assets plus one or more platform binaries:
|
||||
A native bundle is a zip with web assets plus one or more platform binaries:
|
||||
|
||||
```text
|
||||
<pluginId>.zip
|
||||
@@ -253,7 +251,7 @@ Most plugin authors can ignore this, but it is useful when debugging Linux nativ
|
||||
|
||||
## Build Scripts
|
||||
|
||||
Legacy samples include `build.bat` and `build.sh`. They compile native binaries and zip them with web assets.
|
||||
Native samples include `build.bat` and `build.sh`. They compile native binaries and zip them with web assets.
|
||||
|
||||
Default usage:
|
||||
|
||||
@@ -316,4 +314,4 @@ The native plugin can reply by invoking the host callback. Those callback events
|
||||
|
||||
## Security Notes
|
||||
|
||||
Legacy native plugins are trusted executable code. They can call OS APIs, read/write files available to the agent process, spawn subprocesses, open sockets, and interact with native libraries. Use plugin signing, per-user plugin access, and code review before deploying third-party native plugins.
|
||||
Native plugins are trusted executable code. They can call OS APIs, read/write files available to the agent process, spawn subprocesses, open sockets, and interact with native libraries. Use plugin signing, per-user plugin access, and code review before deploying third-party native plugins.
|
||||
|
||||
@@ -6,12 +6,9 @@ The `plugins/` directory includes small examples that exercise each plugin style
|
||||
|-----------|--------------|
|
||||
| `sample-build-hooks` | Server-only build plugin, custom Build page action, build settings, and replacing the produced artifact with a `.txt` file. |
|
||||
| `sample-ts-fullstack` | TypeScript UI and TypeScript server runtime with shared local modules. |
|
||||
| `sample-wasm` | Plugin 2.0 WASM module built from C/WASI. |
|
||||
| `sample-wasm-hostinfo` | TinyGo WASM plugin that queries HostInfo. |
|
||||
| `sample-wasm-platform-note` | Rust WASM plugin that branches by HostInfo/platform. |
|
||||
| `sample-c` | Legacy native C plugin for the smallest unloadable ABI surface. |
|
||||
| `sample-cpp` | Legacy native C++ plugin using exported C ABI functions. |
|
||||
| `sample-go` | Legacy native Go plugin. Go plugins are intentionally not unloaded because the Go runtime cannot be safely unloaded from shared libraries. |
|
||||
| `sample-rust` | Legacy native Rust plugin with C ABI exports and unload-friendly runtime handling. |
|
||||
| `sample-c` | Native C plugin for the smallest unloadable ABI surface. |
|
||||
| `sample-cpp` | Native C++ plugin using exported C ABI functions. |
|
||||
| `sample-go` | Native Go plugin. Go plugins are intentionally not unloaded because the Go runtime cannot be safely unloaded from shared libraries. |
|
||||
| `sample-rust` | Native Rust plugin with C ABI exports and unload-friendly runtime handling. |
|
||||
|
||||
Use `sample-build-hooks` as the starting point for uploaders, artifact publishers, or custom release buttons.
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
# WASM Plugins
|
||||
|
||||
Plugin 2.0 WASM plugins run inside the agent through a sandboxed runtime. They cannot access files or OS APIs directly; they declare needs in `config.json`, and the agent exposes approved host bridge operations.
|
||||
|
||||
## Config
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "My WASM Plugin",
|
||||
"version": "1.0.0",
|
||||
"apiVersion": 2,
|
||||
"runtime": "wasm",
|
||||
"wasm": "my-plugin.wasm",
|
||||
"needs": {
|
||||
"files": [
|
||||
{
|
||||
"bucket": "pluginData",
|
||||
"access": ["read", "write", "list", "mkdir"],
|
||||
"reason": "Store plugin state"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Supported file buckets are `home`, `desktop`, `documents`, `downloads`, `temp`, `appData`, `pluginData`, and `fullDisk`.
|
||||
|
||||
Supported operations are `read`, `write`, `list`, `delete`, and `mkdir`. Approval is tracked by a stable hash of normalized needs; changing needs requires approval again.
|
||||
|
||||
## Required Exports
|
||||
|
||||
| Export | Signature |
|
||||
|--------|-----------|
|
||||
| `overlord_alloc` | `ptr(uint32 size)` |
|
||||
| `overlord_free` | `void(ptr, uint32 size)` |
|
||||
| `overlord_on_load` | `int32(ptr hostInfo, uint32 hostInfoLen)` |
|
||||
| `overlord_on_event` | `int32(ptr event, uint32 eventLen, ptr payload, uint32 payloadLen)` |
|
||||
| `overlord_on_unload` | `void()` |
|
||||
|
||||
## Host Imports
|
||||
|
||||
Imports are available from both `env` and `overlord`.
|
||||
|
||||
| Import | Behavior |
|
||||
|--------|----------|
|
||||
| `overlord_emit(event, eventLen, payload, payloadLen)` | Sends an event through the plugin event channel. |
|
||||
| `overlord_host_info(out, outLen)` | Writes HostInfo JSON into `out`. |
|
||||
| `overlord_fs_stat(bucket, bucketLen, path, pathLen, out, outLen)` | Writes JSON file metadata into `out`. |
|
||||
| `overlord_fs_list(bucket, bucketLen, path, pathLen, out, outLen)` | Writes a JSON directory listing into `out`. |
|
||||
| `overlord_fs_read(bucket, bucketLen, path, pathLen, out, outLen)` | Writes file bytes into `out`, max 32 MB. |
|
||||
| `overlord_fs_write(bucket, bucketLen, path, pathLen, data, dataLen)` | Writes bytes to a bucket-relative path. |
|
||||
| `overlord_fs_delete(bucket, bucketLen, path, pathLen)` | Deletes a bucket-relative file or directory. |
|
||||
| `overlord_fs_mkdir(bucket, bucketLen, path, pathLen)` | Creates a bucket-relative directory. |
|
||||
|
||||
Bridge functions return non-negative byte counts or `0` on success. Negative return codes mean denied, invalid path/memory, not found, output buffer too small, file too large, or I/O failure.
|
||||
|
||||
## HostInfo
|
||||
|
||||
WASM plugins are universal across OS/architecture. To branch by platform, read HostInfo from `overlord_on_load(hostInfo, hostInfoLen)` or `overlord_host_info(out, outLen)`.
|
||||
|
||||
```json
|
||||
{
|
||||
"clientId": "abc123",
|
||||
"os": "windows",
|
||||
"arch": "amd64",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
```
|
||||
|
||||
## Samples
|
||||
|
||||
| Directory | Toolchain |
|
||||
|-----------|-----------|
|
||||
| `plugins/sample-wasm` | C/WASI |
|
||||
| `plugins/sample-wasm-hostinfo` | TinyGo |
|
||||
| `plugins/sample-wasm-platform-note` | Rust |
|
||||
@@ -4,8 +4,6 @@ setlocal enabledelayedexpansion
|
||||
set "PLUGIN_DIR=%~dp0"
|
||||
set "PLUGIN_NAME=sample-ts-fullstack"
|
||||
|
||||
set "NATIVE_DIR=%PLUGIN_DIR%\native"
|
||||
set "WASM_OUT=%PLUGIN_DIR%\%PLUGIN_NAME%.wasm"
|
||||
set "ZIP_OUT=%PLUGIN_DIR%\%PLUGIN_NAME%.zip"
|
||||
|
||||
if not exist "%PLUGIN_DIR%\config.json" (
|
||||
@@ -13,32 +11,10 @@ if not exist "%PLUGIN_DIR%\config.json" (
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
set "WASM_SOURCE="
|
||||
if exist "%NATIVE_DIR%" (
|
||||
for %%S in ("%NATIVE_DIR%\*.c") do (
|
||||
if not defined WASM_SOURCE set "WASM_SOURCE=%%~fS"
|
||||
)
|
||||
)
|
||||
|
||||
if defined WASM_SOURCE (
|
||||
where clang >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo [error] clang was not found. Install a WASI-capable clang or provide %WASM_OUT% yourself.
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo [build] clang --target=wasm32-wasi -O2 "!WASM_SOURCE!" ^> "%WASM_OUT%"
|
||||
clang --target=wasm32-wasi -O2 -Wl,--no-entry -Wl,--allow-undefined -Wl,--export=overlord_alloc -Wl,--export=overlord_free -Wl,--export=overlord_on_load -Wl,--export=overlord_on_event -Wl,--export=overlord_on_unload -o "%WASM_OUT%" "!WASM_SOURCE!"
|
||||
if errorlevel 1 (
|
||||
echo [error] WASM build failed
|
||||
exit /b 1
|
||||
)
|
||||
)
|
||||
|
||||
if exist "%ZIP_OUT%" del /f /q "%ZIP_OUT%"
|
||||
|
||||
pushd "%PLUGIN_DIR%"
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -Command "$zip = '%ZIP_OUT%'; $items = @(); foreach ($file in @('config.json', '%PLUGIN_NAME%.html', '%PLUGIN_NAME%.css', '%PLUGIN_NAME%.js', '%PLUGIN_NAME%.wasm', 'server.js')) { if (Test-Path -LiteralPath $file -PathType Leaf) { $items += $file } }; foreach ($dir in @('src', 'assets')) { if (Test-Path -LiteralPath $dir -PathType Container) { $items += $dir } }; if ($items.Count -eq 0) { throw 'No plugin files found to package' }; Compress-Archive -Path $items -DestinationPath $zip -Force"
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -Command "$zip = '%ZIP_OUT%'; $items = @(); foreach ($file in @('config.json', '%PLUGIN_NAME%.html', '%PLUGIN_NAME%.css', '%PLUGIN_NAME%.js', 'server.js')) { if (Test-Path -LiteralPath $file -PathType Leaf) { $items += $file } }; foreach ($dir in @('src', 'assets')) { if (Test-Path -LiteralPath $dir -PathType Container) { $items += $dir } }; if ($items.Count -eq 0) { throw 'No plugin files found to package' }; Compress-Archive -Path $items -DestinationPath $zip -Force"
|
||||
set "ZIP_STATUS=%ERRORLEVEL%"
|
||||
popd
|
||||
if not "%ZIP_STATUS%"=="0" exit /b %ZIP_STATUS%
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
# Sample WASM HostInfo Plugin
|
||||
|
||||
This Plugin 2.0 sample is written in Go for TinyGo. It demonstrates how a universal WASM module can read HostInfo and emit it back to the UI.
|
||||
|
||||
On Windows, install TinyGo 0.41.0 or newer, then run this sample's local `build.bat`. TinyGo 0.41.0 added Go 1.26 support. The builder runs `tinygo build -target=wasi`, writes `sample-wasm-hostinfo.wasm`, and creates `sample-wasm-hostinfo.zip`.
|
||||
@@ -1,55 +0,0 @@
|
||||
@echo off
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
set "PLUGIN_DIR=%~dp0"
|
||||
set "PLUGIN_NAME=sample-wasm-hostinfo"
|
||||
|
||||
set "NATIVE_DIR=%PLUGIN_DIR%\native"
|
||||
set "WASM_OUT=%PLUGIN_DIR%\%PLUGIN_NAME%.wasm"
|
||||
set "ZIP_OUT=%PLUGIN_DIR%\%PLUGIN_NAME%.zip"
|
||||
|
||||
if not exist "%PLUGIN_DIR%\config.json" (
|
||||
echo [error] config.json not found in %PLUGIN_DIR%
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
set "WASM_SOURCE=%NATIVE_DIR%\main.go"
|
||||
|
||||
if exist "%WASM_SOURCE%" (
|
||||
where tinygo >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo [error] tinygo was not found. Install TinyGo or provide %WASM_OUT% yourself.
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo [build] tinygo build -target=wasi -opt=2 -no-debug -o "%WASM_OUT%" "%WASM_SOURCE%"
|
||||
tinygo build -target=wasi -opt=2 -no-debug -o "%WASM_OUT%" "%WASM_SOURCE%"
|
||||
if errorlevel 1 (
|
||||
echo [error] WASM build failed
|
||||
exit /b 1
|
||||
)
|
||||
)
|
||||
|
||||
if exist "%ZIP_OUT%" del /f /q "%ZIP_OUT%"
|
||||
|
||||
pushd "%PLUGIN_DIR%"
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -Command "$zip = '%ZIP_OUT%'; $items = @(); foreach ($file in @('config.json', '%PLUGIN_NAME%.html', '%PLUGIN_NAME%.css', '%PLUGIN_NAME%.js', '%PLUGIN_NAME%.wasm', 'server.js')) { if (Test-Path -LiteralPath $file -PathType Leaf) { $items += $file } }; foreach ($dir in @('src', 'assets')) { if (Test-Path -LiteralPath $dir -PathType Container) { $items += $dir } }; if ($items.Count -eq 0) { throw 'No plugin files found to package' }; Compress-Archive -Path $items -DestinationPath $zip -Force"
|
||||
set "ZIP_STATUS=%ERRORLEVEL%"
|
||||
popd
|
||||
if not "%ZIP_STATUS%"=="0" exit /b %ZIP_STATUS%
|
||||
if errorlevel 1 (
|
||||
echo [error] zip failed
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
if defined PLUGIN_SIGN_KEY (
|
||||
where bun >nul 2>&1
|
||||
if not errorlevel 1 (
|
||||
echo [sign] Signing plugin with key: %PLUGIN_SIGN_KEY%
|
||||
bun run "%~dp0..\..\Overlord-Server\scripts\plugin-sign.ts" --key "%PLUGIN_SIGN_KEY%" "%ZIP_OUT%"
|
||||
) else (
|
||||
echo [warn] bun not found, skipping plugin signing
|
||||
)
|
||||
)
|
||||
|
||||
echo [ok] %ZIP_OUT%
|
||||
@@ -1,93 +0,0 @@
|
||||
package main
|
||||
|
||||
import "unsafe"
|
||||
|
||||
//go:wasmimport env overlord_emit
|
||||
func overlordEmit(eventPtr, eventLen, payloadPtr, payloadLen uint32) int32
|
||||
|
||||
//go:wasmimport env overlord_host_info
|
||||
func overlordHostInfo(outPtr, outLen uint32) int32
|
||||
|
||||
var heap = make([]byte, 64*1024)
|
||||
var heapOffset uint32 = 8
|
||||
|
||||
var hostInfo [1024]byte
|
||||
var hostInfoLen uint32
|
||||
|
||||
//go:export overlord_alloc
|
||||
func overlordAlloc(size uint32) uint32 {
|
||||
if size == 0 {
|
||||
size = 1
|
||||
}
|
||||
ptr := (heapOffset + 7) &^ 7
|
||||
next := ptr + size
|
||||
if next >= uint32(len(heap)) {
|
||||
return 0
|
||||
}
|
||||
heapOffset = next
|
||||
return uint32(uintptr(unsafe.Pointer(&heap[ptr])))
|
||||
}
|
||||
|
||||
//go:export overlord_free
|
||||
func overlordFree(ptr, size uint32) {
|
||||
_, _ = ptr, size
|
||||
}
|
||||
|
||||
//go:export overlord_on_load
|
||||
func overlordOnLoad(hostPtr, hostLen uint32) int32 {
|
||||
n := hostLen
|
||||
if n > uint32(len(hostInfo)) {
|
||||
n = uint32(len(hostInfo))
|
||||
}
|
||||
copy(hostInfo[:n], bytesAt(hostPtr, n))
|
||||
hostInfoLen = n
|
||||
emit("ready", hostInfo[:hostInfoLen])
|
||||
return 0
|
||||
}
|
||||
|
||||
//go:export overlord_on_event
|
||||
func overlordOnEvent(eventPtr, eventLen, payloadPtr, payloadLen uint32) int32 {
|
||||
_, _ = payloadPtr, payloadLen
|
||||
event := string(bytesAt(eventPtr, eventLen))
|
||||
if event != "query_host" {
|
||||
return 0
|
||||
}
|
||||
|
||||
var buf [1024]byte
|
||||
n := overlordHostInfo(uint32(uintptr(unsafe.Pointer(&buf[0]))), uint32(len(buf)))
|
||||
if n < 0 {
|
||||
emitString("host_info", `{"error":"host info buffer too small"}`)
|
||||
return 0
|
||||
}
|
||||
emit("host_info", buf[:uint32(n)])
|
||||
return 0
|
||||
}
|
||||
|
||||
//go:export overlord_on_unload
|
||||
func overlordOnUnload() {}
|
||||
|
||||
func emitString(event string, payload string) {
|
||||
emit(event, []byte(payload))
|
||||
}
|
||||
|
||||
func emit(event string, payload []byte) {
|
||||
eventBytes := []byte(event)
|
||||
var eventPtr uint32
|
||||
var payloadPtr uint32
|
||||
if len(eventBytes) > 0 {
|
||||
eventPtr = uint32(uintptr(unsafe.Pointer(&eventBytes[0])))
|
||||
}
|
||||
if len(payload) > 0 {
|
||||
payloadPtr = uint32(uintptr(unsafe.Pointer(&payload[0])))
|
||||
}
|
||||
overlordEmit(eventPtr, uint32(len(eventBytes)), payloadPtr, uint32(len(payload)))
|
||||
}
|
||||
|
||||
func bytesAt(ptr, length uint32) []byte {
|
||||
if length == 0 {
|
||||
return nil
|
||||
}
|
||||
return unsafe.Slice((*byte)(unsafe.Pointer(uintptr(ptr))), length)
|
||||
}
|
||||
|
||||
func main() {}
|
||||
@@ -1,22 +0,0 @@
|
||||
#sample-wasm-hostinfo-output {
|
||||
min-height: 8rem;
|
||||
}
|
||||
|
||||
.sample-wasm-hostinfo-field {
|
||||
border: 1px solid rgba(8, 145, 178, 0.45);
|
||||
background: rgba(8, 47, 73, 0.28);
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.sample-wasm-hostinfo-field span {
|
||||
display: block;
|
||||
color: rgb(148, 163, 184);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.sample-wasm-hostinfo-field strong {
|
||||
display: block;
|
||||
color: rgb(224, 242, 254);
|
||||
font-size: 0.95rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
<div class="space-y-3">
|
||||
<h2 class="text-lg font-semibold text-slate-100">Sample WASM Host Info</h2>
|
||||
<button id="sample-wasm-hostinfo-run" class="px-3 py-2 rounded-lg border border-cyan-700 bg-cyan-950/40 text-cyan-100">Query host info</button>
|
||||
<div id="sample-wasm-hostinfo-status" class="text-xs text-slate-400">Waiting for host info</div>
|
||||
<div class="grid gap-2 sm:grid-cols-2">
|
||||
<div class="sample-wasm-hostinfo-field"><span>Client</span><strong id="sample-wasm-hostinfo-client">-</strong></div>
|
||||
<div class="sample-wasm-hostinfo-field"><span>Version</span><strong id="sample-wasm-hostinfo-version">-</strong></div>
|
||||
<div class="sample-wasm-hostinfo-field"><span>OS</span><strong id="sample-wasm-hostinfo-os">-</strong></div>
|
||||
<div class="sample-wasm-hostinfo-field"><span>Arch</span><strong id="sample-wasm-hostinfo-arch">-</strong></div>
|
||||
</div>
|
||||
<pre id="sample-wasm-hostinfo-output" class="text-xs text-slate-300 whitespace-pre-wrap"></pre>
|
||||
</div>
|
||||
@@ -1,93 +0,0 @@
|
||||
(() => {
|
||||
const out = document.getElementById("sample-wasm-hostinfo-output");
|
||||
const btn = document.getElementById("sample-wasm-hostinfo-run");
|
||||
const status = document.getElementById("sample-wasm-hostinfo-status");
|
||||
const fields = {
|
||||
clientId: document.getElementById("sample-wasm-hostinfo-client"),
|
||||
version: document.getElementById("sample-wasm-hostinfo-version"),
|
||||
os: document.getElementById("sample-wasm-hostinfo-os"),
|
||||
arch: document.getElementById("sample-wasm-hostinfo-arch"),
|
||||
};
|
||||
const clientId = new URLSearchParams(window.location.search).get("clientId");
|
||||
const pluginId = "sample-wasm-hostinfo";
|
||||
let polling = false;
|
||||
|
||||
function log(message) {
|
||||
if (out) out.textContent = `${message}\n${out.textContent || ""}`;
|
||||
}
|
||||
|
||||
function setStatus(message) {
|
||||
if (status) status.textContent = message;
|
||||
}
|
||||
|
||||
function parsePayload(payload) {
|
||||
if (payload == null) return {};
|
||||
if (typeof payload === "string") {
|
||||
try { return JSON.parse(payload); } catch (_) { return { raw: payload }; }
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function renderHostInfo(payload) {
|
||||
const info = parsePayload(payload);
|
||||
if (info.error) {
|
||||
setStatus(info.error);
|
||||
log(JSON.stringify(info, null, 2));
|
||||
return;
|
||||
}
|
||||
if (fields.clientId) fields.clientId.textContent = info.clientId || "-";
|
||||
if (fields.version) fields.version.textContent = info.version || "-";
|
||||
if (fields.os) fields.os.textContent = info.os || "-";
|
||||
if (fields.arch) fields.arch.textContent = info.arch || "-";
|
||||
setStatus("Host info received");
|
||||
log(JSON.stringify(info, null, 2));
|
||||
}
|
||||
|
||||
function handlePluginEvent(event, payload) {
|
||||
if (event === "ready" || event === "host_info") renderHostInfo(payload);
|
||||
}
|
||||
|
||||
window.addEventListener("message", (e) => {
|
||||
if (!e.data || e.data.type !== "plugin_event") return;
|
||||
if (e.data.pluginId && e.data.pluginId !== pluginId) return;
|
||||
handlePluginEvent(e.data.event, e.data.payload);
|
||||
});
|
||||
|
||||
async function pollEvents() {
|
||||
if (!clientId || polling) return;
|
||||
polling = true;
|
||||
try {
|
||||
const res = await fetch(`/api/clients/${encodeURIComponent(clientId)}/plugins/${pluginId}/events`, {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (data.events) data.events.forEach((e) => handlePluginEvent(e.event, e.payload));
|
||||
}
|
||||
} catch (_) {
|
||||
// Polling is best-effort; the next interval can recover.
|
||||
} finally {
|
||||
polling = false;
|
||||
}
|
||||
}
|
||||
|
||||
btn?.addEventListener("click", async () => {
|
||||
if (!clientId) {
|
||||
setStatus("Open this plugin from a client row so clientId is present.");
|
||||
return;
|
||||
}
|
||||
setStatus("Requesting host info");
|
||||
const res = await fetch(`/api/clients/${encodeURIComponent(clientId)}/plugins/sample-wasm-hostinfo/event`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ event: "query_host", payload: {} }),
|
||||
});
|
||||
if (!res.ok) setStatus(`Send failed: ${res.status}`);
|
||||
setTimeout(pollEvents, 250);
|
||||
});
|
||||
|
||||
if (clientId) {
|
||||
setInterval(pollEvents, 1500);
|
||||
setTimeout(pollEvents, 300);
|
||||
}
|
||||
})();
|
||||
@@ -1,13 +0,0 @@
|
||||
# Sample WASM Platform Note Plugin
|
||||
|
||||
This Plugin 2.0 sample is written in Rust. It reads HostInfo, branches on `os` and `arch`, writes a small note into the approved `pluginData` bucket, then lists the user's Desktop and attempts to read a small sample from one regular file to prove the file bridge is working.
|
||||
|
||||
The manifest requests `pluginData` write/mkdir access and `desktop` list/read access, so changing this sample's permissions will require approving the new needs hash before load.
|
||||
|
||||
On Windows, install the Rust WASI target first:
|
||||
|
||||
```bat
|
||||
rustup target add wasm32-wasip1
|
||||
```
|
||||
|
||||
Then run this sample's local `build.bat`. The builder runs Cargo, writes `sample-wasm-platform-note.wasm`, and creates `sample-wasm-platform-note.zip`.
|
||||
@@ -1,76 +0,0 @@
|
||||
@echo off
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
set "PLUGIN_DIR=%~dp0"
|
||||
set "PLUGIN_NAME=sample-wasm-platform-note"
|
||||
|
||||
set "NATIVE_DIR=%PLUGIN_DIR%\native"
|
||||
set "WASM_OUT=%PLUGIN_DIR%\%PLUGIN_NAME%.wasm"
|
||||
set "ZIP_OUT=%PLUGIN_DIR%\%PLUGIN_NAME%.zip"
|
||||
|
||||
if not exist "%PLUGIN_DIR%\config.json" (
|
||||
echo [error] config.json not found in %PLUGIN_DIR%
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
set "RUST_MANIFEST=%NATIVE_DIR%\Cargo.toml"
|
||||
|
||||
if exist "%RUST_MANIFEST%" (
|
||||
where cargo >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo [error] cargo was not found. Install Rust or provide %WASM_OUT% yourself.
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
where rustup >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo [error] rustup was not found. Install the Rust target manually: rustup target add wasm32-wasip1
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
rustup target list --installed | findstr /x "wasm32-wasip1" >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo [setup] Installing Rust target wasm32-wasip1
|
||||
rustup target add wasm32-wasip1
|
||||
if errorlevel 1 (
|
||||
echo [error] Could not install wasm32-wasip1. Run this manually: rustup target add wasm32-wasip1
|
||||
exit /b 1
|
||||
)
|
||||
)
|
||||
|
||||
echo [build] cargo build --manifest-path "%RUST_MANIFEST%" --release --target wasm32-wasip1
|
||||
cargo build --manifest-path "%RUST_MANIFEST%" --release --target wasm32-wasip1
|
||||
if errorlevel 1 (
|
||||
echo [error] WASM build failed
|
||||
exit /b 1
|
||||
)
|
||||
copy /Y "%NATIVE_DIR%\target\wasm32-wasip1\release\sample_wasm_platform_note.wasm" "%WASM_OUT%" >nul
|
||||
if errorlevel 1 (
|
||||
echo [error] could not copy built WASM to %WASM_OUT%
|
||||
exit /b 1
|
||||
)
|
||||
)
|
||||
|
||||
if exist "%ZIP_OUT%" del /f /q "%ZIP_OUT%"
|
||||
|
||||
pushd "%PLUGIN_DIR%"
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -Command "$zip = '%ZIP_OUT%'; $items = @(); foreach ($file in @('config.json', '%PLUGIN_NAME%.html', '%PLUGIN_NAME%.css', '%PLUGIN_NAME%.js', '%PLUGIN_NAME%.wasm', 'server.js')) { if (Test-Path -LiteralPath $file -PathType Leaf) { $items += $file } }; foreach ($dir in @('src', 'assets')) { if (Test-Path -LiteralPath $dir -PathType Container) { $items += $dir } }; if ($items.Count -eq 0) { throw 'No plugin files found to package' }; Compress-Archive -Path $items -DestinationPath $zip -Force"
|
||||
set "ZIP_STATUS=%ERRORLEVEL%"
|
||||
popd
|
||||
if not "%ZIP_STATUS%"=="0" exit /b %ZIP_STATUS%
|
||||
if errorlevel 1 (
|
||||
echo [error] zip failed
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
if defined PLUGIN_SIGN_KEY (
|
||||
where bun >nul 2>&1
|
||||
if not errorlevel 1 (
|
||||
echo [sign] Signing plugin with key: %PLUGIN_SIGN_KEY%
|
||||
bun run "%~dp0..\..\Overlord-Server\scripts\plugin-sign.ts" --key "%PLUGIN_SIGN_KEY%" "%ZIP_OUT%"
|
||||
) else (
|
||||
echo [warn] bun not found, skipping plugin signing
|
||||
)
|
||||
)
|
||||
|
||||
echo [ok] %ZIP_OUT%
|
||||
@@ -1,7 +0,0 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "sample_wasm_platform_note"
|
||||
version = "0.1.0"
|
||||
@@ -1,8 +0,0 @@
|
||||
[package]
|
||||
name = "sample_wasm_platform_note"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
path = "src/lib.rs"
|
||||
@@ -1,301 +0,0 @@
|
||||
use std::alloc::{alloc, dealloc, Layout};
|
||||
use std::ptr;
|
||||
use std::slice;
|
||||
use std::str;
|
||||
|
||||
extern "C" {
|
||||
fn overlord_emit(event: *const u8, event_len: i32, payload: *const u8, payload_len: i32) -> i32;
|
||||
fn overlord_host_info(out: *mut u8, out_len: i32) -> i32;
|
||||
fn overlord_fs_list(
|
||||
bucket: *const u8,
|
||||
bucket_len: i32,
|
||||
path: *const u8,
|
||||
path_len: i32,
|
||||
out: *mut u8,
|
||||
out_len: i32,
|
||||
) -> i32;
|
||||
fn overlord_fs_read(
|
||||
bucket: *const u8,
|
||||
bucket_len: i32,
|
||||
path: *const u8,
|
||||
path_len: i32,
|
||||
out: *mut u8,
|
||||
out_len: i32,
|
||||
) -> i32;
|
||||
fn overlord_fs_mkdir(bucket: *const u8, bucket_len: i32, path: *const u8, path_len: i32) -> i32;
|
||||
fn overlord_fs_write(
|
||||
bucket: *const u8,
|
||||
bucket_len: i32,
|
||||
path: *const u8,
|
||||
path_len: i32,
|
||||
data: *const u8,
|
||||
data_len: i32,
|
||||
) -> i32;
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn overlord_alloc(size: u32) -> *mut u8 {
|
||||
let size = size.max(1) as usize;
|
||||
let layout = Layout::from_size_align(size, 8).unwrap();
|
||||
unsafe { alloc(layout) }
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn overlord_free(ptr: *mut u8, size: u32) {
|
||||
if ptr.is_null() {
|
||||
return;
|
||||
}
|
||||
let layout = Layout::from_size_align((size.max(1)) as usize, 8).unwrap();
|
||||
unsafe { dealloc(ptr, layout) };
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn overlord_on_load(_host: *const u8, _host_len: u32) -> i32 {
|
||||
emit("ready", br#"{"sample":"platform-note"}"#);
|
||||
0
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn overlord_on_event(
|
||||
event: *const u8,
|
||||
event_len: u32,
|
||||
_payload: *const u8,
|
||||
_payload_len: u32,
|
||||
) -> i32 {
|
||||
let event = unsafe { slice::from_raw_parts(event, event_len as usize) };
|
||||
if event != b"write_note" {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let mut host = [0u8; 1024];
|
||||
let host_len = unsafe { overlord_host_info(host.as_mut_ptr(), host.len() as i32) };
|
||||
let host = if host_len > 0 {
|
||||
str::from_utf8(&host[..host_len as usize]).unwrap_or("")
|
||||
} else {
|
||||
""
|
||||
};
|
||||
|
||||
let os = if host.contains(r#""os":"windows""#) {
|
||||
"windows"
|
||||
} else if host.contains(r#""os":"darwin""#) {
|
||||
"darwin"
|
||||
} else if host.contains(r#""os":"linux""#) {
|
||||
"linux"
|
||||
} else {
|
||||
"unknown"
|
||||
};
|
||||
|
||||
let arch = if host.contains(r#""arch":"arm64""#) {
|
||||
"arm64"
|
||||
} else if host.contains(r#""arch":"amd64""#) {
|
||||
"amd64"
|
||||
} else if host.contains(r#""arch":"386""#) {
|
||||
"386"
|
||||
} else {
|
||||
"unknown"
|
||||
};
|
||||
|
||||
let bucket = "pluginData";
|
||||
let dir = "platform";
|
||||
let file = format!("platform/{os}-{arch}.txt");
|
||||
let note = format!("single WASM plugin running on os={os} arch={arch}\n");
|
||||
|
||||
let mkdir_result = unsafe {
|
||||
overlord_fs_mkdir(
|
||||
bucket.as_ptr(),
|
||||
bucket.len() as i32,
|
||||
dir.as_ptr(),
|
||||
dir.len() as i32,
|
||||
)
|
||||
};
|
||||
let write_result = unsafe {
|
||||
overlord_fs_write(
|
||||
bucket.as_ptr(),
|
||||
bucket.len() as i32,
|
||||
file.as_ptr(),
|
||||
file.len() as i32,
|
||||
note.as_ptr(),
|
||||
note.len() as i32,
|
||||
)
|
||||
};
|
||||
let desktop = probe_desktop(host);
|
||||
|
||||
let response = format!(
|
||||
r#"{{"os":"{os}","arch":"{arch}","mkdir":{mkdir_result},"write":{write_result},"desktopList":{},"desktopRead":{},"desktopFile":"{}","desktopSize":{}}}"#,
|
||||
desktop.list_result,
|
||||
desktop.read_result,
|
||||
json_escape(&desktop.file),
|
||||
desktop.size,
|
||||
);
|
||||
emit("platform_note", response.as_bytes());
|
||||
0
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn overlord_on_unload() {}
|
||||
|
||||
fn emit(event: &str, payload: &[u8]) {
|
||||
unsafe {
|
||||
overlord_emit(
|
||||
event.as_ptr(),
|
||||
event.len() as i32,
|
||||
payload.as_ptr(),
|
||||
payload.len() as i32,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
struct DesktopProbe {
|
||||
file: String,
|
||||
size: i64,
|
||||
list_result: i32,
|
||||
read_result: i32,
|
||||
}
|
||||
|
||||
fn probe_desktop(seed: &str) -> DesktopProbe {
|
||||
let bucket = "desktop";
|
||||
let path = "";
|
||||
let mut listing = Vec::new();
|
||||
let mut list_result = -4;
|
||||
for size in [8 * 1024, 64 * 1024, 256 * 1024, 1024 * 1024] {
|
||||
listing.resize(size, 0);
|
||||
list_result = unsafe {
|
||||
overlord_fs_list(
|
||||
bucket.as_ptr(),
|
||||
bucket.len() as i32,
|
||||
path.as_ptr(),
|
||||
path.len() as i32,
|
||||
listing.as_mut_ptr(),
|
||||
listing.len() as i32,
|
||||
)
|
||||
};
|
||||
if list_result != -4 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if list_result <= 0 {
|
||||
return DesktopProbe {
|
||||
file: String::new(),
|
||||
size: -1,
|
||||
list_result,
|
||||
read_result: -3,
|
||||
};
|
||||
}
|
||||
|
||||
let listing = str::from_utf8(&listing[..list_result as usize]).unwrap_or("");
|
||||
let files = desktop_files_from_listing(listing);
|
||||
if files.is_empty() {
|
||||
return DesktopProbe {
|
||||
file: String::new(),
|
||||
size: -1,
|
||||
list_result,
|
||||
read_result: -3,
|
||||
};
|
||||
}
|
||||
|
||||
let seed_sum = seed.bytes().fold(0usize, |acc, b| acc.wrapping_add(b as usize));
|
||||
let selected = &files[seed_sum % files.len()];
|
||||
let mut buf = [0u8; 128];
|
||||
let read_result = unsafe {
|
||||
overlord_fs_read(
|
||||
bucket.as_ptr(),
|
||||
bucket.len() as i32,
|
||||
selected.path.as_ptr(),
|
||||
selected.path.len() as i32,
|
||||
buf.as_mut_ptr(),
|
||||
buf.len() as i32,
|
||||
)
|
||||
};
|
||||
|
||||
DesktopProbe {
|
||||
file: selected.path.clone(),
|
||||
size: selected.size,
|
||||
list_result,
|
||||
read_result,
|
||||
}
|
||||
}
|
||||
|
||||
struct DesktopFile {
|
||||
path: String,
|
||||
size: i64,
|
||||
}
|
||||
|
||||
fn desktop_files_from_listing(listing: &str) -> Vec<DesktopFile> {
|
||||
let mut files = Vec::new();
|
||||
let mut rest = listing;
|
||||
while let Some(path_key) = rest.find(r#""path":"#) {
|
||||
rest = &rest[path_key + 7..];
|
||||
let Some((path, after_path)) = parse_json_string(rest) else {
|
||||
break;
|
||||
};
|
||||
rest = after_path;
|
||||
let next_path = rest.find(r#""path":"#).unwrap_or(rest.len());
|
||||
let entry = &rest[..next_path];
|
||||
if !entry.contains(r#""isDir":false"#) {
|
||||
continue;
|
||||
}
|
||||
let size = parse_json_i64_after(entry, r#""size":"#).unwrap_or(-1);
|
||||
files.push(DesktopFile { path, size });
|
||||
}
|
||||
files
|
||||
}
|
||||
|
||||
fn parse_json_string(input: &str) -> Option<(String, &str)> {
|
||||
let input = input.strip_prefix('"')?;
|
||||
let mut out = String::new();
|
||||
let mut escaped = false;
|
||||
for (idx, ch) in input.char_indices() {
|
||||
if escaped {
|
||||
match ch {
|
||||
'"' => out.push('"'),
|
||||
'\\' => out.push('\\'),
|
||||
'/' => out.push('/'),
|
||||
'b' => out.push('\u{0008}'),
|
||||
'f' => out.push('\u{000c}'),
|
||||
'n' => out.push('\n'),
|
||||
'r' => out.push('\r'),
|
||||
't' => out.push('\t'),
|
||||
other => out.push(other),
|
||||
}
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
match ch {
|
||||
'\\' => escaped = true,
|
||||
'"' => return Some((out, &input[idx + 1..])),
|
||||
other => out.push(other),
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn parse_json_i64_after(input: &str, key: &str) -> Option<i64> {
|
||||
let start = input.find(key)? + key.len();
|
||||
let bytes = input.as_bytes();
|
||||
let mut end = start;
|
||||
while end < bytes.len() && (bytes[end].is_ascii_digit() || bytes[end] == b'-') {
|
||||
end += 1;
|
||||
}
|
||||
input[start..end].parse().ok()
|
||||
}
|
||||
|
||||
fn json_escape(input: &str) -> String {
|
||||
let mut out = String::with_capacity(input.len());
|
||||
for ch in input.chars() {
|
||||
match ch {
|
||||
'"' => out.push_str("\\\""),
|
||||
'\\' => out.push_str("\\\\"),
|
||||
'\n' => out.push_str("\\n"),
|
||||
'\r' => out.push_str("\\r"),
|
||||
'\t' => out.push_str("\\t"),
|
||||
other => out.push(other),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn memset(dest: *mut u8, value: i32, len: usize) -> *mut u8 {
|
||||
unsafe { ptr::write_bytes(dest, value as u8, len) };
|
||||
dest
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
{"rustc_fingerprint":2568544898921049753,"outputs":{"17747080675513052775":{"success":true,"status":"","code":0,"stdout":"rustc 1.94.0 (4a4ef493e 2026-03-02)\nbinary: rustc\ncommit-hash: 4a4ef493e3a1488c6e321570238084b38948f6db\ncommit-date: 2026-03-02\nhost: x86_64-pc-windows-msvc\nrelease: 1.94.0\nLLVM version: 21.1.8\n","stderr":""},"7971740275564407648":{"success":true,"status":"","code":0,"stdout":"___.exe\nlib___.rlib\n___.dll\n___.dll\n___.lib\n___.dll\nC:\\Users\\this1\\.rustup\\toolchains\\stable-x86_64-pc-windows-msvc\npacked\n___\ndebug_assertions\npanic=\"unwind\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"x86_64\"\ntarget_endian=\"little\"\ntarget_env=\"msvc\"\ntarget_family=\"windows\"\ntarget_feature=\"cmpxchg16b\"\ntarget_feature=\"fxsr\"\ntarget_feature=\"sse\"\ntarget_feature=\"sse2\"\ntarget_feature=\"sse3\"\ntarget_has_atomic=\"128\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_os=\"windows\"\ntarget_pointer_width=\"64\"\ntarget_vendor=\"pc\"\nwindows\n","stderr":""},"5946400966674136012":{"success":true,"status":"","code":0,"stdout":"___.wasm\nlib___.rlib\n___.wasm\nlib___.a\nC:\\Users\\this1\\.rustup\\toolchains\\stable-x86_64-pc-windows-msvc\noff\n___\ndebug_assertions\npanic=\"abort\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"wasm32\"\ntarget_endian=\"little\"\ntarget_env=\"p1\"\ntarget_family=\"wasm\"\ntarget_feature=\"bulk-memory\"\ntarget_feature=\"multivalue\"\ntarget_feature=\"mutable-globals\"\ntarget_feature=\"nontrapping-fptoint\"\ntarget_feature=\"reference-types\"\ntarget_feature=\"sign-ext\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_os=\"wasi\"\ntarget_pointer_width=\"32\"\ntarget_vendor=\"unknown\"\n","stderr":"warning: dropping unsupported crate type `dylib` for target `wasm32-wasip1`\n\nwarning: dropping unsupported crate type `proc-macro` for target `wasm32-wasip1`\n\nwarning: 2 warnings emitted\n\n"}},"successes":{}}
|
||||
@@ -1,3 +0,0 @@
|
||||
Signature: 8a477f597d28d172789f06886806bc55
|
||||
# This file is a cache directory tag created by cargo.
|
||||
# For information about cache directory tags see https://bford.info/cachedir/
|
||||
@@ -1,3 +0,0 @@
|
||||
Signature: 8a477f597d28d172789f06886806bc55
|
||||
# This file is a cache directory tag created by cargo.
|
||||
# For information about cache directory tags see https://bford.info/cachedir/
|
||||
@@ -1,22 +0,0 @@
|
||||
#sample-wasm-platform-note-output {
|
||||
min-height: 8rem;
|
||||
}
|
||||
|
||||
.sample-wasm-platform-note-field {
|
||||
border: 1px solid rgba(5, 150, 105, 0.45);
|
||||
background: rgba(6, 78, 59, 0.25);
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.sample-wasm-platform-note-field span {
|
||||
display: block;
|
||||
color: rgb(148, 163, 184);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.sample-wasm-platform-note-field strong {
|
||||
display: block;
|
||||
color: rgb(209, 250, 229);
|
||||
font-size: 0.95rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
<div class="space-y-3">
|
||||
<h2 class="text-lg font-semibold text-slate-100">Sample WASM Platform Note</h2>
|
||||
<button id="sample-wasm-platform-note-run" class="px-3 py-2 rounded-lg border border-emerald-700 bg-emerald-950/40 text-emerald-100">Write platform note</button>
|
||||
<div id="sample-wasm-platform-note-status" class="text-xs text-slate-400">Waiting to write platform note</div>
|
||||
<div class="grid gap-2 sm:grid-cols-2">
|
||||
<div class="sample-wasm-platform-note-field"><span>OS</span><strong id="sample-wasm-platform-note-os">-</strong></div>
|
||||
<div class="sample-wasm-platform-note-field"><span>Arch</span><strong id="sample-wasm-platform-note-arch">-</strong></div>
|
||||
<div class="sample-wasm-platform-note-field"><span>mkdir</span><strong id="sample-wasm-platform-note-mkdir">-</strong></div>
|
||||
<div class="sample-wasm-platform-note-field"><span>write</span><strong id="sample-wasm-platform-note-write">-</strong></div>
|
||||
<div class="sample-wasm-platform-note-field"><span>Desktop file</span><strong id="sample-wasm-platform-note-desktop-file">-</strong></div>
|
||||
<div class="sample-wasm-platform-note-field"><span>Desktop size</span><strong id="sample-wasm-platform-note-desktop-size">-</strong></div>
|
||||
<div class="sample-wasm-platform-note-field"><span>Desktop list</span><strong id="sample-wasm-platform-note-desktop-list">-</strong></div>
|
||||
<div class="sample-wasm-platform-note-field"><span>Desktop read</span><strong id="sample-wasm-platform-note-desktop-read">-</strong></div>
|
||||
</div>
|
||||
<pre id="sample-wasm-platform-note-output" class="text-xs text-slate-300 whitespace-pre-wrap"></pre>
|
||||
</div>
|
||||
@@ -1,101 +0,0 @@
|
||||
(() => {
|
||||
const out = document.getElementById("sample-wasm-platform-note-output");
|
||||
const btn = document.getElementById("sample-wasm-platform-note-run");
|
||||
const status = document.getElementById("sample-wasm-platform-note-status");
|
||||
const fields = {
|
||||
os: document.getElementById("sample-wasm-platform-note-os"),
|
||||
arch: document.getElementById("sample-wasm-platform-note-arch"),
|
||||
mkdir: document.getElementById("sample-wasm-platform-note-mkdir"),
|
||||
write: document.getElementById("sample-wasm-platform-note-write"),
|
||||
desktopFile: document.getElementById("sample-wasm-platform-note-desktop-file"),
|
||||
desktopSize: document.getElementById("sample-wasm-platform-note-desktop-size"),
|
||||
desktopList: document.getElementById("sample-wasm-platform-note-desktop-list"),
|
||||
desktopRead: document.getElementById("sample-wasm-platform-note-desktop-read"),
|
||||
};
|
||||
const clientId = new URLSearchParams(window.location.search).get("clientId");
|
||||
const pluginId = "sample-wasm-platform-note";
|
||||
let polling = false;
|
||||
|
||||
function log(message) {
|
||||
if (out) out.textContent = `${message}\n${out.textContent || ""}`;
|
||||
}
|
||||
|
||||
function setStatus(message) {
|
||||
if (status) status.textContent = message;
|
||||
}
|
||||
|
||||
function parsePayload(payload) {
|
||||
if (payload == null) return {};
|
||||
if (typeof payload === "string") {
|
||||
try { return JSON.parse(payload); } catch (_) { return { raw: payload }; }
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function renderPlatformNote(payload) {
|
||||
const info = parsePayload(payload);
|
||||
if (fields.os) fields.os.textContent = info.os || "-";
|
||||
if (fields.arch) fields.arch.textContent = info.arch || "-";
|
||||
if (fields.mkdir) fields.mkdir.textContent = String(info.mkdir ?? "-");
|
||||
if (fields.write) fields.write.textContent = String(info.write ?? "-");
|
||||
if (fields.desktopFile) fields.desktopFile.textContent = info.desktopFile || "(no readable file found)";
|
||||
if (fields.desktopSize) fields.desktopSize.textContent = Number.isFinite(info.desktopSize) && info.desktopSize >= 0 ? `${info.desktopSize} bytes` : "-";
|
||||
if (fields.desktopList) fields.desktopList.textContent = String(info.desktopList ?? "-");
|
||||
if (fields.desktopRead) fields.desktopRead.textContent = String(info.desktopRead ?? "-");
|
||||
setStatus(info.write === 0 ? "Platform note written" : "Platform note result received");
|
||||
log(JSON.stringify(info, null, 2));
|
||||
}
|
||||
|
||||
function handlePluginEvent(event, payload) {
|
||||
if (event === "ready") {
|
||||
setStatus("Plugin loaded");
|
||||
log(JSON.stringify(parsePayload(payload), null, 2));
|
||||
return;
|
||||
}
|
||||
if (event === "platform_note") renderPlatformNote(payload);
|
||||
}
|
||||
|
||||
window.addEventListener("message", (e) => {
|
||||
if (!e.data || e.data.type !== "plugin_event") return;
|
||||
if (e.data.pluginId && e.data.pluginId !== pluginId) return;
|
||||
handlePluginEvent(e.data.event, e.data.payload);
|
||||
});
|
||||
|
||||
async function pollEvents() {
|
||||
if (!clientId || polling) return;
|
||||
polling = true;
|
||||
try {
|
||||
const res = await fetch(`/api/clients/${encodeURIComponent(clientId)}/plugins/${pluginId}/events`, {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (data.events) data.events.forEach((e) => handlePluginEvent(e.event, e.payload));
|
||||
}
|
||||
} catch (_) {
|
||||
// Polling is best-effort; the next interval can recover.
|
||||
} finally {
|
||||
polling = false;
|
||||
}
|
||||
}
|
||||
|
||||
btn?.addEventListener("click", async () => {
|
||||
if (!clientId) {
|
||||
setStatus("Open this plugin from a client row so clientId is present.");
|
||||
return;
|
||||
}
|
||||
setStatus("Writing platform note");
|
||||
const res = await fetch(`/api/clients/${encodeURIComponent(clientId)}/plugins/sample-wasm-platform-note/event`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ event: "write_note", payload: {} }),
|
||||
});
|
||||
if (!res.ok) setStatus(`Send failed: ${res.status}`);
|
||||
setTimeout(pollEvents, 250);
|
||||
});
|
||||
|
||||
if (clientId) {
|
||||
setInterval(pollEvents, 1500);
|
||||
setTimeout(pollEvents, 300);
|
||||
}
|
||||
})();
|
||||
@@ -1,9 +0,0 @@
|
||||
# Sample WASM Plugin
|
||||
|
||||
This is a Plugin 2.0 sample. It declares broad file needs in `config.json`, then uses the agent WASM host imports to write to `pluginData` and list `downloads`.
|
||||
|
||||
On Windows, run this sample's local `build.bat`. The builder uses a WASI-capable `clang` to compile `native/sample_wasm.c`, writes `sample-wasm.wasm`, and creates `sample-wasm.zip`.
|
||||
|
||||
You can also build `native/sample_wasm.c` manually with a WASI-capable clang, copy the resulting `sample-wasm.wasm` into this folder, then zip the folder contents.
|
||||
|
||||
The server will show the declared needs before the module can be sent to an agent.
|
||||
@@ -1,60 +0,0 @@
|
||||
@echo off
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
set "PLUGIN_DIR=%~dp0"
|
||||
set "PLUGIN_NAME=sample-wasm"
|
||||
|
||||
set "NATIVE_DIR=%PLUGIN_DIR%\native"
|
||||
set "WASM_OUT=%PLUGIN_DIR%\%PLUGIN_NAME%.wasm"
|
||||
set "ZIP_OUT=%PLUGIN_DIR%\%PLUGIN_NAME%.zip"
|
||||
|
||||
if not exist "%PLUGIN_DIR%\config.json" (
|
||||
echo [error] config.json not found in %PLUGIN_DIR%
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
set "WASM_SOURCE="
|
||||
if exist "%NATIVE_DIR%" (
|
||||
for %%S in ("%NATIVE_DIR%\*.c") do (
|
||||
if not defined WASM_SOURCE set "WASM_SOURCE=%%~fS"
|
||||
)
|
||||
)
|
||||
|
||||
if defined WASM_SOURCE (
|
||||
where clang >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo [error] clang was not found. Install a WASI-capable clang or provide %WASM_OUT% yourself.
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo [build] clang --target=wasm32-wasi -O2 "!WASM_SOURCE!" ^> "%WASM_OUT%"
|
||||
clang --target=wasm32-wasi -O2 -Wl,--no-entry -Wl,--allow-undefined -Wl,--export=overlord_alloc -Wl,--export=overlord_free -Wl,--export=overlord_on_load -Wl,--export=overlord_on_event -Wl,--export=overlord_on_unload -o "%WASM_OUT%" "!WASM_SOURCE!"
|
||||
if errorlevel 1 (
|
||||
echo [error] WASM build failed
|
||||
exit /b 1
|
||||
)
|
||||
)
|
||||
|
||||
if exist "%ZIP_OUT%" del /f /q "%ZIP_OUT%"
|
||||
|
||||
pushd "%PLUGIN_DIR%"
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -Command "$zip = '%ZIP_OUT%'; $items = @(); foreach ($file in @('config.json', '%PLUGIN_NAME%.html', '%PLUGIN_NAME%.css', '%PLUGIN_NAME%.js', '%PLUGIN_NAME%.wasm', 'server.js')) { if (Test-Path -LiteralPath $file -PathType Leaf) { $items += $file } }; foreach ($dir in @('src', 'assets')) { if (Test-Path -LiteralPath $dir -PathType Container) { $items += $dir } }; if ($items.Count -eq 0) { throw 'No plugin files found to package' }; Compress-Archive -Path $items -DestinationPath $zip -Force"
|
||||
set "ZIP_STATUS=%ERRORLEVEL%"
|
||||
popd
|
||||
if not "%ZIP_STATUS%"=="0" exit /b %ZIP_STATUS%
|
||||
if errorlevel 1 (
|
||||
echo [error] zip failed
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
if defined PLUGIN_SIGN_KEY (
|
||||
where bun >nul 2>&1
|
||||
if not errorlevel 1 (
|
||||
echo [sign] Signing plugin with key: %PLUGIN_SIGN_KEY%
|
||||
bun run "%~dp0..\..\Overlord-Server\scripts\plugin-sign.ts" --key "%PLUGIN_SIGN_KEY%" "%ZIP_OUT%"
|
||||
) else (
|
||||
echo [warn] bun not found, skipping plugin signing
|
||||
)
|
||||
)
|
||||
|
||||
echo [ok] %ZIP_OUT%
|
||||
@@ -1,69 +0,0 @@
|
||||
// Build with a WASI-capable clang:
|
||||
// clang --target=wasm32-wasi -O2 -Wl,--no-entry -Wl,--export=overlord_alloc \
|
||||
// -Wl,--export=overlord_free -Wl,--export=overlord_on_load \
|
||||
// -Wl,--export=overlord_on_event -Wl,--export=overlord_on_unload \
|
||||
// -o sample-wasm.wasm sample_wasm.c
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
__attribute__((import_module("env"), import_name("overlord_emit")))
|
||||
extern int32_t overlord_emit(const char *event, int32_t event_len, const char *payload, int32_t payload_len);
|
||||
|
||||
__attribute__((import_module("env"), import_name("overlord_host_info")))
|
||||
extern int32_t overlord_host_info(uint8_t *out, int32_t out_len);
|
||||
|
||||
__attribute__((import_module("env"), import_name("overlord_fs_mkdir")))
|
||||
extern int32_t overlord_fs_mkdir(const char *bucket, int32_t bucket_len, const char *path, int32_t path_len);
|
||||
|
||||
__attribute__((import_module("env"), import_name("overlord_fs_write")))
|
||||
extern int32_t overlord_fs_write(const char *bucket, int32_t bucket_len, const char *path, int32_t path_len, const uint8_t *data, int32_t data_len);
|
||||
|
||||
__attribute__((import_module("env"), import_name("overlord_fs_list")))
|
||||
extern int32_t overlord_fs_list(const char *bucket, int32_t bucket_len, const char *path, int32_t path_len, uint8_t *out, int32_t out_len);
|
||||
|
||||
void *overlord_alloc(uint32_t size) {
|
||||
return malloc(size ? size : 1);
|
||||
}
|
||||
|
||||
void overlord_free(void *ptr, uint32_t size) {
|
||||
(void)size;
|
||||
free(ptr);
|
||||
}
|
||||
|
||||
int32_t overlord_on_load(const char *host_info, int32_t host_info_len) {
|
||||
const char *event = "ready";
|
||||
overlord_emit(event, 5, host_info, host_info_len);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int32_t overlord_on_event(const char *event, int32_t event_len, const char *payload, int32_t payload_len) {
|
||||
(void)payload;
|
||||
(void)payload_len;
|
||||
if (event_len != 10 || memcmp(event, "run_sample", 10) != 0) return 0;
|
||||
|
||||
const char *plugin_data = "pluginData";
|
||||
const char *downloads = "downloads";
|
||||
const char *dir = "notes";
|
||||
const char *file = "notes/sample.txt";
|
||||
const char *note = "sample wasm plugin wrote this file\n";
|
||||
|
||||
int32_t mkdir_result = overlord_fs_mkdir(plugin_data, 10, dir, 5);
|
||||
int32_t write_result = overlord_fs_write(plugin_data, 10, file, 16, (const uint8_t *)note, (int32_t)strlen(note));
|
||||
|
||||
uint8_t list_buf[4096];
|
||||
int32_t list_result = overlord_fs_list(downloads, 9, "", 0, list_buf, sizeof(list_buf));
|
||||
uint8_t host_buf[1024];
|
||||
int32_t host_result = overlord_host_info(host_buf, sizeof(host_buf));
|
||||
|
||||
char response[256];
|
||||
int n = snprintf(response, sizeof(response),
|
||||
"{\"mkdir\":%d,\"write\":%d,\"downloadsListBytes\":%d,\"hostInfoBytes\":%d}",
|
||||
mkdir_result, write_result, list_result, host_result);
|
||||
overlord_emit("sample_result", 13, response, n);
|
||||
return 0;
|
||||
}
|
||||
|
||||
void overlord_on_unload(void) {}
|
||||
@@ -1,3 +0,0 @@
|
||||
#sample-wasm-output {
|
||||
min-height: 7rem;
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
<div class="space-y-3">
|
||||
<h2 class="text-lg font-semibold text-slate-100">Sample WASM</h2>
|
||||
<p class="text-sm text-slate-400">Plugin 2.0 runtime smoke test.</p>
|
||||
<button id="sample-wasm-run" class="px-3 py-2 rounded-lg border border-cyan-700 bg-cyan-950/40 text-cyan-100">Run sample event</button>
|
||||
<pre id="sample-wasm-output" class="text-xs text-slate-300 whitespace-pre-wrap"></pre>
|
||||
</div>
|
||||
@@ -1,22 +0,0 @@
|
||||
(() => {
|
||||
const out = document.getElementById("sample-wasm-output");
|
||||
const btn = document.getElementById("sample-wasm-run");
|
||||
const clientId = new URLSearchParams(window.location.search).get("clientId");
|
||||
|
||||
function log(message) {
|
||||
if (out) out.textContent = `${new Date().toLocaleTimeString()} ${message}\n${out.textContent || ""}`;
|
||||
}
|
||||
|
||||
btn?.addEventListener("click", async () => {
|
||||
if (!clientId) {
|
||||
log("Open this plugin from a client row so clientId is present.");
|
||||
return;
|
||||
}
|
||||
const res = await fetch(`/api/clients/${encodeURIComponent(clientId)}/plugins/sample-wasm/event`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ event: "run_sample", payload: { note: "hello from UI" } }),
|
||||
});
|
||||
log(res.ok ? "sent run_sample" : `send failed: ${res.status}`);
|
||||
});
|
||||
})();
|
||||
Reference in New Issue
Block a user