Remove WASM plugin support

This commit is contained in:
KingKDot
2026-08-18 21:41:28 -05:00
parent 4b8de29d32
commit 7800c24c65
42 changed files with 125 additions and 1773 deletions
+5 -6
View File
@@ -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
}
+8 -36
View File
@@ -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)
}
}
-1
View File
@@ -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
-2
View File
@@ -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=
+1 -6
View File
@@ -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");
+1 -2
View File
@@ -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
View File
@@ -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.
+1 -2
View File
@@ -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. |
+3 -5
View File
@@ -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.
+3 -5
View File
@@ -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. |
+7 -9
View File
@@ -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.
+4 -7
View File
@@ -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.
-76
View File
@@ -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 |
+1 -25
View File
@@ -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%
-5
View File
@@ -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`.
-55
View File
@@ -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%
-7
View File
@@ -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);
}
})();
-9
View File
@@ -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.
-60
View File
@@ -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%
-69
View File
@@ -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) {}
-3
View File
@@ -1,3 +0,0 @@
#sample-wasm-output {
min-height: 7rem;
}
-6
View File
@@ -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>
-22
View File
@@ -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}`);
});
})();