plugins 2.0 update

This commit is contained in:
KingKDot
2026-06-22 10:52:27 -05:00
parent e1f5e4c800
commit 30dfdda3ed
55 changed files with 2534 additions and 128 deletions
+2
View File
@@ -513,3 +513,5 @@ FodyWeavers.xsd
*.exp *.exp
*.lib *.lib
save.txt save.txt
*.wasm
Albedo/*
@@ -7,6 +7,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"log"
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
@@ -89,6 +90,7 @@ func HandlePluginLoadHTTP(ctx context.Context, env *agentRuntime.Env, cmdID stri
} }
if err := env.Plugins.Load(ctx, manifest, binary); err != nil { if err := env.Plugins.Load(ctx, manifest, binary); err != nil {
log.Printf("[plugin] load failed %s: %v", manifest.ID, err)
_ = wire.WriteMsg(ctx, env.Conn, wire.PluginEvent{Type: "plugin_event", PluginID: manifest.ID, Event: "error", Error: err.Error()}) _ = wire.WriteMsg(ctx, env.Conn, wire.PluginEvent{Type: "plugin_event", PluginID: manifest.ID, Event: "error", Error: err.Error()})
return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{Type: "command_result", CommandID: cmdID, OK: false, Message: err.Error()}) return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{Type: "command_result", CommandID: cmdID, OK: false, Message: err.Error()})
} }
+9 -3
View File
@@ -21,7 +21,7 @@ type Manager struct {
type pluginInstance struct { type pluginInstance struct {
id string id string
manifest PluginManifest manifest PluginManifest
native NativePlugin native PluginRuntime
} }
func NewManager(writer wire.Writer, host HostInfo) *Manager { func NewManager(writer wire.Writer, host HostInfo) *Manager {
@@ -49,7 +49,13 @@ func (m *Manager) Load(ctx context.Context, manifest PluginManifest, binary []by
} }
m.mu.Unlock() m.mu.Unlock()
np, err := loadNativePlugin(binary) var np PluginRuntime
var err error
if manifest.RuntimeKind == "wasm" || manifest.WASM != "" {
np, err = loadWASMPlugin(ctx, manifest, binary)
} else {
np, err = loadNativePlugin(binary)
}
if err != nil { if err != nil {
return err return err
} }
@@ -107,7 +113,7 @@ func (m *Manager) Load(ctx context.Context, manifest PluginManifest, binary []by
rt := np.Runtime() rt := np.Runtime()
freeable := rt != "go" freeable := rt != "go"
log.Printf("[plugin] loaded %s (native, runtime=%s, freeable=%v)", pluginID, rt, freeable) log.Printf("[plugin] loaded %s (runtime=%s, freeable=%v)", pluginID, rt, freeable)
return nil return nil
} }
+72 -1
View File
@@ -11,14 +11,28 @@ type PluginAssets struct {
type PluginManifest struct { type PluginManifest struct {
ID string `msgpack:"id" json:"id"` ID string `msgpack:"id" json:"id"`
Name string `msgpack:"name" json:"name"` Name string `msgpack:"name" json:"name"`
APIVersion int `msgpack:"apiVersion,omitempty" json:"apiVersion,omitempty"`
RuntimeKind string `msgpack:"runtime,omitempty" json:"runtime,omitempty"`
Version string `msgpack:"version,omitempty" json:"version,omitempty"` Version string `msgpack:"version,omitempty" json:"version,omitempty"`
Description string `msgpack:"description,omitempty" json:"description,omitempty"` Description string `msgpack:"description,omitempty" json:"description,omitempty"`
Binary string `msgpack:"binary,omitempty" json:"binary,omitempty"` Binary string `msgpack:"binary,omitempty" json:"binary,omitempty"`
Binaries map[string]string `msgpack:"binaries,omitempty" json:"binaries,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"` Entry string `msgpack:"entry,omitempty" json:"entry,omitempty"`
Assets PluginAssets `msgpack:"assets,omitempty" json:"assets,omitempty"` Assets PluginAssets `msgpack:"assets,omitempty" json:"assets,omitempty"`
} }
type PluginNeeds struct {
Files []PluginFileNeed `msgpack:"files,omitempty" json:"files,omitempty"`
}
type PluginFileNeed struct {
Bucket string `msgpack:"bucket" json:"bucket"`
Access []string `msgpack:"access" json:"access"`
Reason string `msgpack:"reason,omitempty" json:"reason,omitempty"`
}
type PluginMessage struct { type PluginMessage struct {
Type string `msgpack:"type"` Type string `msgpack:"type"`
Event string `msgpack:"event,omitempty"` Event string `msgpack:"event,omitempty"`
@@ -33,7 +47,7 @@ type HostInfo struct {
Version string `msgpack:"version" json:"version"` Version string `msgpack:"version" json:"version"`
} }
type NativePlugin interface { type PluginRuntime interface {
Load(send func(event string, payload []byte), hostInfo []byte) error Load(send func(event string, payload []byte), hostInfo []byte) error
Event(event string, payload []byte) error Event(event string, payload []byte) error
@@ -45,13 +59,18 @@ type NativePlugin interface {
Runtime() string Runtime() string
} }
type NativePlugin = PluginRuntime
func ManifestFromMap(m map[string]interface{}) (PluginManifest, error) { func ManifestFromMap(m map[string]interface{}) (PluginManifest, error) {
manifest := PluginManifest{} manifest := PluginManifest{}
manifest.ID = stringVal(m["id"]) manifest.ID = stringVal(m["id"])
manifest.Name = stringVal(m["name"]) manifest.Name = stringVal(m["name"])
manifest.APIVersion = intVal(m["apiVersion"])
manifest.RuntimeKind = stringVal(m["runtime"])
manifest.Version = stringVal(m["version"]) manifest.Version = stringVal(m["version"])
manifest.Description = stringVal(m["description"]) manifest.Description = stringVal(m["description"])
manifest.Binary = stringVal(m["binary"]) manifest.Binary = stringVal(m["binary"])
manifest.WASM = stringVal(m["wasm"])
manifest.Entry = stringVal(m["entry"]) manifest.Entry = stringVal(m["entry"])
if binariesRaw, ok := m["binaries"].(map[string]interface{}); ok { if binariesRaw, ok := m["binaries"].(map[string]interface{}); ok {
@@ -70,6 +89,27 @@ func ManifestFromMap(m map[string]interface{}) (PluginManifest, error) {
JS: stringVal(assetsRaw["js"]), 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 == "" { if manifest.ID == "" {
return PluginManifest{}, errors.New("missing plugin id") return PluginManifest{}, errors.New("missing plugin id")
@@ -84,3 +124,34 @@ func stringVal(v interface{}) string {
s, _ := v.(string) s, _ := v.(string)
return s return s
} }
func intVal(v interface{}) int {
switch n := v.(type) {
case int:
return n
case int8:
return int(n)
case int16:
return int(n)
case int32:
return int(n)
case int64:
return int(n)
case uint:
return int(n)
case uint8:
return int(n)
case uint16:
return int(n)
case uint32:
return int(n)
case uint64:
return int(n)
case float32:
return int(n)
case float64:
return int(n)
default:
return 0
}
}
@@ -0,0 +1,511 @@
package plugins
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"github.com/tetratelabs/wazero"
"github.com/tetratelabs/wazero/api"
"github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1"
)
const maxWASMFileReadBytes = 32 * 1024 * 1024
type wasmPlugin struct {
ctx context.Context
cancel context.CancelFunc
runtime wazero.Runtime
module api.Module
manifest PluginManifest
hostInfo []byte
send func(string, []byte)
mu sync.Mutex
acl map[string]map[string]bool
}
func loadWASMPlugin(parent context.Context, manifest PluginManifest, data []byte) (PluginRuntime, error) {
if len(data) == 0 {
return nil, errors.New("empty wasm plugin module")
}
ctx, cancel := context.WithCancel(parent)
rt := wazero.NewRuntime(ctx)
wp := &wasmPlugin{
ctx: ctx,
cancel: cancel,
runtime: rt,
manifest: manifest,
acl: buildFileACL(manifest.Needs),
}
wasi_snapshot_preview1.MustInstantiate(ctx, rt)
if err := wp.instantiateHostModule("env"); err != nil {
cancel()
_ = rt.Close(ctx)
return nil, err
}
if err := wp.instantiateHostModule("overlord"); err != nil {
cancel()
_ = rt.Close(ctx)
return nil, err
}
mod, err := rt.InstantiateWithConfig(ctx, data, wazero.NewModuleConfig().
WithName(manifest.ID).
WithStartFunctions("_initialize"))
if err != nil {
cancel()
_ = rt.Close(ctx)
return nil, fmt.Errorf("wasm instantiate: %w", err)
}
wp.module = mod
return wp, nil
}
func (p *wasmPlugin) instantiateHostModule(name string) error {
builder := p.runtime.NewHostModuleBuilder(name)
export := func(name string, params []api.ValueType, results []api.ValueType, fn api.GoModuleFunc) {
builder.NewFunctionBuilder().WithGoModuleFunction(fn, params, results).Export(name)
}
export("overlord_emit",
[]api.ValueType{api.ValueTypeI32, api.ValueTypeI32, api.ValueTypeI32, api.ValueTypeI32},
[]api.ValueType{api.ValueTypeI32},
func(ctx context.Context, mod api.Module, stack []uint64) {
event, ok := readWASMBytes(mod, uint32(stack[0]), uint32(stack[1]))
if !ok {
stack[0] = wasmStatus(statusInvalid)
return
}
payload, ok := readWASMBytes(mod, uint32(stack[2]), uint32(stack[3]))
if !ok {
stack[0] = wasmStatus(statusInvalid)
return
}
if p.send != nil {
p.send(string(event), payload)
}
stack[0] = 0
})
export("overlord_host_info",
[]api.ValueType{api.ValueTypeI32, api.ValueTypeI32},
[]api.ValueType{api.ValueTypeI32},
func(ctx context.Context, mod api.Module, stack []uint64) {
stack[0] = wasmStatus(writeWASMOutput(mod, uint32(stack[0]), uint32(stack[1]), p.hostInfo))
})
export("overlord_fs_stat", fsReadParams(), []api.ValueType{api.ValueTypeI32}, p.fsStat)
export("overlord_fs_list", fsReadParams(), []api.ValueType{api.ValueTypeI32}, p.fsList)
export("overlord_fs_read", fsReadParams(), []api.ValueType{api.ValueTypeI32}, p.fsRead)
export("overlord_fs_write",
[]api.ValueType{api.ValueTypeI32, api.ValueTypeI32, api.ValueTypeI32, api.ValueTypeI32, api.ValueTypeI32, api.ValueTypeI32},
[]api.ValueType{api.ValueTypeI32},
p.fsWrite)
export("overlord_fs_delete",
[]api.ValueType{api.ValueTypeI32, api.ValueTypeI32, api.ValueTypeI32, api.ValueTypeI32},
[]api.ValueType{api.ValueTypeI32},
p.fsDelete)
export("overlord_fs_mkdir",
[]api.ValueType{api.ValueTypeI32, api.ValueTypeI32, api.ValueTypeI32, api.ValueTypeI32},
[]api.ValueType{api.ValueTypeI32},
p.fsMkdir)
_, err := builder.Instantiate(p.ctx)
return err
}
func fsReadParams() []api.ValueType {
return []api.ValueType{
api.ValueTypeI32, api.ValueTypeI32, // bucket
api.ValueTypeI32, api.ValueTypeI32, // path
api.ValueTypeI32, api.ValueTypeI32, // output buffer
}
}
func (p *wasmPlugin) Load(send func(string, []byte), hostInfo []byte) error {
p.mu.Lock()
defer p.mu.Unlock()
p.send = send
p.hostInfo = append(p.hostInfo[:0], hostInfo...)
return p.callBytes("overlord_on_load", hostInfo)
}
func (p *wasmPlugin) Event(event string, payload []byte) error {
p.mu.Lock()
defer p.mu.Unlock()
eventPtr, err := p.writeGuestBytes([]byte(event))
if err != nil {
return err
}
payloadPtr, err := p.writeGuestBytes(payload)
if err != nil {
p.freeGuestBytes(eventPtr, uint32(len(event)))
return err
}
fn := p.module.ExportedFunction("overlord_on_event")
if fn == nil {
p.freeGuestBytes(eventPtr, uint32(len(event)))
p.freeGuestBytes(payloadPtr, uint32(len(payload)))
return errors.New("missing export: overlord_on_event")
}
results, err := fn.Call(p.ctx, uint64(eventPtr), uint64(len(event)), uint64(payloadPtr), uint64(len(payload)))
p.freeGuestBytes(eventPtr, uint32(len(event)))
p.freeGuestBytes(payloadPtr, uint32(len(payload)))
if err != nil {
return err
}
if len(results) > 0 && int32(uint32(results[0])) != 0 {
return fmt.Errorf("overlord_on_event returned %d", int32(uint32(results[0])))
}
return nil
}
func (p *wasmPlugin) Unload() {
p.mu.Lock()
defer p.mu.Unlock()
if p.module == nil {
return
}
if fn := p.module.ExportedFunction("overlord_on_unload"); fn != nil {
_, _ = fn.Call(p.ctx)
}
}
func (p *wasmPlugin) Close() error {
p.Unload()
p.cancel()
if p.runtime != nil {
return p.runtime.Close(context.Background())
}
return nil
}
func (p *wasmPlugin) Runtime() string {
return "wasm"
}
func (p *wasmPlugin) callBytes(export string, data []byte) error {
ptr, err := p.writeGuestBytes(data)
if err != nil {
return err
}
defer p.freeGuestBytes(ptr, uint32(len(data)))
fn := p.module.ExportedFunction(export)
if fn == nil {
return fmt.Errorf("missing export: %s", export)
}
results, err := fn.Call(p.ctx, uint64(ptr), uint64(len(data)))
if err != nil {
return err
}
if len(results) > 0 && int32(uint32(results[0])) != 0 {
return fmt.Errorf("%s returned %d", export, int32(uint32(results[0])))
}
return nil
}
func (p *wasmPlugin) writeGuestBytes(data []byte) (uint32, error) {
alloc := p.module.ExportedFunction("overlord_alloc")
if alloc == nil {
return 0, errors.New("missing export: overlord_alloc")
}
results, err := alloc.Call(p.ctx, uint64(len(data)))
if err != nil {
return 0, err
}
if len(results) == 0 || uint32(results[0]) == 0 {
return 0, errors.New("overlord_alloc returned null")
}
ptr := uint32(results[0])
if len(data) > 0 && !p.module.Memory().Write(ptr, data) {
p.freeGuestBytes(ptr, uint32(len(data)))
return 0, errors.New("wasm memory write failed")
}
return ptr, nil
}
func (p *wasmPlugin) freeGuestBytes(ptr uint32, size uint32) {
if ptr == 0 {
return
}
if free := p.module.ExportedFunction("overlord_free"); free != nil {
_, _ = free.Call(p.ctx, uint64(ptr), uint64(size))
}
}
type wasmFileStat struct {
Path string `json:"path"`
IsDir bool `json:"isDir"`
Size int64 `json:"size"`
ModTime int64 `json:"modTime"`
Mode string `json:"mode"`
}
func (p *wasmPlugin) fsStat(ctx context.Context, mod api.Module, stack []uint64) {
target, status := p.resolveFromStack(mod, stack, "read")
if status != statusOK {
stack[0] = wasmStatus(status)
return
}
st, err := os.Stat(target)
if err != nil {
stack[0] = wasmStatus(statusNotFound)
return
}
info := wasmFileStat{Path: target, IsDir: st.IsDir(), Size: st.Size(), ModTime: st.ModTime().UnixMilli(), Mode: st.Mode().String()}
p.writeJSONResult(mod, stack, info)
}
func (p *wasmPlugin) fsList(ctx context.Context, mod api.Module, stack []uint64) {
target, status := p.resolveFromStack(mod, stack, "list")
if status != statusOK {
stack[0] = wasmStatus(status)
return
}
entries, err := os.ReadDir(target)
if err != nil {
stack[0] = wasmStatus(statusIO)
return
}
out := make([]wasmFileStat, 0, len(entries))
for _, ent := range entries {
st, err := ent.Info()
if err != nil {
continue
}
out = append(out, wasmFileStat{Path: ent.Name(), IsDir: ent.IsDir(), Size: st.Size(), ModTime: st.ModTime().UnixMilli(), Mode: st.Mode().String()})
}
p.writeJSONResult(mod, stack, out)
}
func (p *wasmPlugin) fsRead(ctx context.Context, mod api.Module, stack []uint64) {
target, status := p.resolveFromStack(mod, stack, "read")
if status != statusOK {
stack[0] = wasmStatus(status)
return
}
st, err := os.Stat(target)
if err != nil || st.IsDir() {
stack[0] = wasmStatus(statusNotFound)
return
}
if st.Size() > maxWASMFileReadBytes {
stack[0] = wasmStatus(statusTooLarge)
return
}
data, err := os.ReadFile(target)
if err != nil {
stack[0] = wasmStatus(statusIO)
return
}
stack[0] = wasmStatus(writeWASMOutput(mod, uint32(stack[4]), uint32(stack[5]), data))
}
func (p *wasmPlugin) fsWrite(ctx context.Context, mod api.Module, stack []uint64) {
target, status := p.resolvePath(mod, uint32(stack[0]), uint32(stack[1]), uint32(stack[2]), uint32(stack[3]), "write")
if status != statusOK {
stack[0] = wasmStatus(status)
return
}
data, ok := readWASMBytes(mod, uint32(stack[4]), uint32(stack[5]))
if !ok {
stack[0] = wasmStatus(statusInvalid)
return
}
if err := os.MkdirAll(filepath.Dir(target), 0o700); err != nil {
stack[0] = wasmStatus(statusIO)
return
}
if err := os.WriteFile(target, data, 0o600); err != nil {
stack[0] = wasmStatus(statusIO)
return
}
stack[0] = 0
}
func (p *wasmPlugin) fsDelete(ctx context.Context, mod api.Module, stack []uint64) {
target, status := p.resolvePath(mod, uint32(stack[0]), uint32(stack[1]), uint32(stack[2]), uint32(stack[3]), "delete")
if status != statusOK {
stack[0] = wasmStatus(status)
return
}
if err := os.RemoveAll(target); err != nil {
stack[0] = wasmStatus(statusIO)
return
}
stack[0] = 0
}
func (p *wasmPlugin) fsMkdir(ctx context.Context, mod api.Module, stack []uint64) {
target, status := p.resolvePath(mod, uint32(stack[0]), uint32(stack[1]), uint32(stack[2]), uint32(stack[3]), "mkdir")
if status != statusOK {
stack[0] = wasmStatus(status)
return
}
if err := os.MkdirAll(target, 0o700); err != nil {
stack[0] = wasmStatus(statusIO)
return
}
stack[0] = 0
}
func (p *wasmPlugin) resolveFromStack(mod api.Module, stack []uint64, op string) (string, int32) {
return p.resolvePath(mod, uint32(stack[0]), uint32(stack[1]), uint32(stack[2]), uint32(stack[3]), op)
}
func (p *wasmPlugin) resolvePath(mod api.Module, bucketPtr, bucketLen, pathPtr, pathLen uint32, op string) (string, int32) {
bucketBytes, ok := readWASMBytes(mod, bucketPtr, bucketLen)
if !ok {
return "", statusInvalid
}
pathBytes, ok := readWASMBytes(mod, pathPtr, pathLen)
if !ok {
return "", statusInvalid
}
bucket := string(bucketBytes)
rel := string(pathBytes)
if !p.acl[bucket][op] {
return "", statusDenied
}
return resolvePluginBucketPath(p.manifest.ID, bucket, rel)
}
func resolvePluginBucketPath(pluginID, bucket, rel string) (string, int32) {
if strings.ContainsRune(rel, 0) {
return "", statusInvalid
}
if bucket == "fullDisk" {
if rel == "" {
if filepath.Separator == '\\' {
return filepath.VolumeName(os.TempDir()) + string(filepath.Separator), statusOK
}
return string(filepath.Separator), statusOK
}
if !filepath.IsAbs(rel) {
if filepath.Separator == '\\' {
rel = filepath.VolumeName(os.TempDir()) + string(filepath.Separator) + rel
} else {
rel = string(filepath.Separator) + rel
}
}
return filepath.Clean(rel), statusOK
}
root, ok := bucketRoot(pluginID, bucket)
if !ok {
return "", statusDenied
}
if filepath.IsAbs(rel) || strings.HasPrefix(rel, string(filepath.Separator)) || strings.HasPrefix(rel, "/") || strings.HasPrefix(rel, `\`) {
return "", statusDenied
}
cleanRel := strings.TrimPrefix(filepath.Clean(string(filepath.Separator)+rel), string(filepath.Separator))
target := filepath.Join(root, cleanRel)
resolvedRoot, err := filepath.Abs(root)
if err != nil {
return "", statusIO
}
resolvedTarget, err := filepath.Abs(target)
if err != nil {
return "", statusIO
}
r, err := filepath.Rel(resolvedRoot, resolvedTarget)
if err != nil || r == ".." || strings.HasPrefix(r, ".."+string(filepath.Separator)) {
return "", statusDenied
}
return resolvedTarget, statusOK
}
func bucketRoot(pluginID, bucket string) (string, bool) {
home, _ := os.UserHomeDir()
switch bucket {
case "home":
return home, home != ""
case "desktop":
return filepath.Join(home, "Desktop"), home != ""
case "documents":
return filepath.Join(home, "Documents"), home != ""
case "downloads":
return filepath.Join(home, "Downloads"), home != ""
case "temp":
return os.TempDir(), true
case "appData":
dir, err := os.UserConfigDir()
if err != nil {
return "", false
}
return filepath.Join(dir, "Overlord"), true
case "pluginData":
dir, err := os.UserConfigDir()
if err != nil {
return "", false
}
root := filepath.Join(dir, "Overlord", "plugins", pluginID)
_ = os.MkdirAll(root, 0o700)
return root, true
default:
return "", false
}
}
func buildFileACL(needs PluginNeeds) map[string]map[string]bool {
acl := map[string]map[string]bool{}
for _, need := range needs.Files {
if acl[need.Bucket] == nil {
acl[need.Bucket] = map[string]bool{}
}
for _, op := range need.Access {
acl[need.Bucket][op] = true
}
}
return acl
}
func (p *wasmPlugin) writeJSONResult(mod api.Module, stack []uint64, v interface{}) {
data, err := json.Marshal(v)
if err != nil {
stack[0] = wasmStatus(statusIO)
return
}
stack[0] = wasmStatus(writeWASMOutput(mod, uint32(stack[4]), uint32(stack[5]), data))
}
func readWASMBytes(mod api.Module, ptr, size uint32) ([]byte, bool) {
if size == 0 {
return nil, true
}
data, ok := mod.Memory().Read(ptr, size)
if !ok {
return nil, false
}
out := make([]byte, len(data))
copy(out, data)
return out, true
}
func writeWASMOutput(mod api.Module, ptr, size uint32, data []byte) int32 {
if uint32(len(data)) > size {
return statusTooSmall
}
if len(data) > 0 && !mod.Memory().Write(ptr, data) {
return statusInvalid
}
return int32(len(data))
}
const (
statusOK int32 = 0
statusDenied int32 = -1
statusInvalid int32 = -2
statusNotFound int32 = -3
statusTooSmall int32 = -4
statusTooLarge int32 = -5
statusIO int32 = -6
)
func wasmStatus(status int32) uint64 {
return uint64(uint32(status))
}
@@ -0,0 +1,41 @@
package plugins
import (
"path/filepath"
"strings"
"testing"
)
func TestBuildFileACL(t *testing.T) {
acl := buildFileACL(PluginNeeds{Files: []PluginFileNeed{
{Bucket: "downloads", Access: []string{"read", "list"}},
}})
if !acl["downloads"]["read"] || !acl["downloads"]["list"] {
t.Fatalf("expected read/list permissions: %#v", acl)
}
if acl["downloads"]["write"] {
t.Fatalf("did not expect write permission")
}
}
func TestWriteWASMOutputStatusCodes(t *testing.T) {
status := statusTooSmall
if wasmStatus(statusTooSmall) != uint64(uint32(status)) {
t.Fatalf("negative status should be encoded as wasm i32")
}
}
func TestResolvePluginBucketPathRejectsTraversal(t *testing.T) {
target, status := resolvePluginBucketPath("demo", "pluginData", ".."+string(filepath.Separator)+"escape.txt")
if status != statusOK {
t.Fatalf("expected cleaned pluginData path, got status %d", status)
}
if strings.Contains(target, "..") {
t.Fatalf("expected traversal to be cleaned, got %q", target)
}
_, status = resolvePluginBucketPath("demo", "downloads", string(filepath.Separator)+"absolute.txt")
if status != statusDenied {
t.Fatalf("expected absolute path to be denied for scoped bucket, got %d", status)
}
}
+2 -1
View File
@@ -19,8 +19,9 @@ require (
github.com/jezek/xgb v1.1.1 github.com/jezek/xgb v1.1.1
github.com/pion/rtcp v1.2.15 github.com/pion/rtcp v1.2.15
github.com/pion/webrtc/v4 v4.0.7 github.com/pion/webrtc/v4 v4.0.7
github.com/tetratelabs/wazero v1.12.0
golang.org/x/crypto v0.49.0 golang.org/x/crypto v0.49.0
golang.org/x/sys v0.42.0 golang.org/x/sys v0.44.0
) )
require ( require (
+4 -2
View File
@@ -77,6 +77,8 @@ 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.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 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 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/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8=
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
@@ -89,8 +91,8 @@ golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+85 -3
View File
@@ -616,8 +616,13 @@ function showPluginConfirmModal(pluginId, clientId, sigInfo) {
body: JSON.stringify({ confirmed: true }), body: JSON.stringify({ confirmed: true }),
}); });
if (!res.ok) { if (!res.ok) {
const text = await res.text(); const data = await res.json().catch(() => null);
alert(`Plugin load failed: ${text}`); if (data?.error === "needs_approval_required") {
modal.remove();
showPluginNeedsModal(pluginId, clientId, data.needs, data.needsHash);
return;
}
alert(`Plugin load failed: ${data?.error || res.statusText}`);
} else { } else {
window.open(`/plugins/${pluginId}?clientId=${clientId}`, "_blank", "noopener"); window.open(`/plugins/${pluginId}?clientId=${clientId}`, "_blank", "noopener");
} }
@@ -630,6 +635,79 @@ function showPluginConfirmModal(pluginId, clientId, sigInfo) {
input.focus(); input.focus();
} }
function escapePluginHtml(value) {
return String(value)
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function pluginNeedLines(needs) {
const files = Array.isArray(needs?.files) ? needs.files : [];
return files.map((need) => {
const access = Array.isArray(need.access) ? need.access.join(", ") : "";
const reason = need.reason ? ` - ${need.reason}` : "";
return `${need.bucket}: ${access}${reason}`;
});
}
function showPluginNeedsModal(pluginId, clientId, needs, needsHash) {
document.getElementById("plugin-needs-modal")?.remove();
const lines = pluginNeedLines(needs);
const modal = document.createElement("div");
modal.id = "plugin-needs-modal";
modal.className = "fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm";
modal.innerHTML = `
<div class="bg-slate-900 border border-slate-700 rounded-xl shadow-2xl max-w-lg w-full mx-4 p-6">
<div class="flex items-center gap-3 mb-4">
<div class="w-10 h-10 rounded-full flex items-center justify-center bg-amber-900/60 text-amber-300">
<i class="fa-solid fa-key text-lg"></i>
</div>
<div>
<h3 class="font-semibold text-lg">Approve Plugin Needs</h3>
<p class="text-sm text-slate-400">${escapePluginHtml(pluginId)}</p>
</div>
</div>
<p class="text-sm text-slate-300 mb-3">This plugin declares filesystem bridges that must be approved before loading.</p>
<div class="rounded-lg border border-slate-700 bg-slate-950/70 p-3 mb-3 space-y-2">
${lines.length ? lines.map((line) => `<div class="text-xs font-mono text-slate-300">${escapePluginHtml(line)}</div>`).join("") : '<div class="text-sm text-slate-400">No filesystem needs declared.</div>'}
</div>
${needsHash ? `<p class="text-xs text-slate-500 font-mono mb-4">Needs hash: ${escapePluginHtml(needsHash.slice(0, 16))}...</p>` : ""}
<div class="flex gap-3 justify-end">
<button id="plugin-needs-cancel" class="px-4 py-2 rounded-lg border border-slate-600 text-slate-300 hover:bg-slate-800">Cancel</button>
<button id="plugin-needs-approve" class="px-4 py-2 rounded-lg bg-amber-900/40 border border-amber-700/60 text-amber-100 hover:bg-amber-800/60">Approve and Load</button>
</div>
</div>
`;
document.body.appendChild(modal);
document.getElementById("plugin-needs-cancel")?.addEventListener("click", () => modal.remove());
modal.addEventListener("click", (e) => { if (e.target === modal) modal.remove(); });
document.getElementById("plugin-needs-approve")?.addEventListener("click", async () => {
const approveBtn = document.getElementById("plugin-needs-approve");
approveBtn.disabled = true;
approveBtn.textContent = "Approving...";
const approve = await fetch(`/api/plugins/${pluginId}/needs/approve`, { method: "POST" });
if (!approve.ok) {
alert("Needs approval failed");
modal.remove();
return;
}
const res = await fetch(`/api/clients/${clientId}/plugins/${pluginId}/load`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ confirmed: true }),
});
if (!res.ok) {
const data = await res.json().catch(() => null);
alert(`Plugin load failed: ${data?.error || res.statusText}`);
} else {
window.open(`/plugins/${pluginId}?clientId=${clientId}`, "_blank", "noopener");
}
modal.remove();
});
}
function renderPluginMenu(plugins) { function renderPluginMenu(plugins) {
const section = document.getElementById("plugin-section"); const section = document.getElementById("plugin-section");
const container = document.getElementById("plugin-menu"); const container = document.getElementById("plugin-menu");
@@ -1708,7 +1786,11 @@ menu.addEventListener("click", async (e) => {
if (res.status === 428) { if (res.status === 428) {
const data = await res.json(); const data = await res.json();
closeMenu(clearContext); closeMenu(clearContext);
showPluginConfirmModal(pluginId, savedClientId, data.signature); if (data.error === "needs_approval_required") {
showPluginNeedsModal(pluginId, savedClientId, data.needs, data.needsHash);
} else {
showPluginConfirmModal(pluginId, savedClientId, data.signature);
}
return; return;
} }
+141 -4
View File
@@ -96,6 +96,16 @@ function getTrustBadge(sig) {
return { icon: "fa-shield-halved", color: "text-orange-400 border-orange-600 bg-orange-900/30", label: "Unsigned", tooltip: "This plugin is not signed" }; return { icon: "fa-shield-halved", color: "text-orange-400 border-orange-600 bg-orange-900/30", label: "Unsigned", tooltip: "This plugin is not signed" };
} }
function describeNeeds(needs) {
const files = Array.isArray(needs?.files) ? needs.files : [];
if (!files.length) return [];
return files.map((need) => {
const access = Array.isArray(need.access) ? need.access.join(", ") : "";
const reason = need.reason ? ` - ${need.reason}` : "";
return `${need.bucket}: ${access}${reason}`;
});
}
function renderPlugins(plugins) { function renderPlugins(plugins) {
pluginList.innerHTML = ""; pluginList.innerHTML = "";
if (!plugins.length) { if (!plugins.length) {
@@ -123,6 +133,23 @@ function renderPlugins(plugins) {
trustBadge.title = badge.tooltip; trustBadge.title = badge.tooltip;
titleRow.appendChild(trustBadge); 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 ${
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 = 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';
titleRow.appendChild(runtimeBadge);
const subtitle = document.createElement("div"); const subtitle = document.createElement("div");
subtitle.className = "text-sm text-slate-400"; subtitle.className = "text-sm text-slate-400";
subtitle.textContent = `${plugin.id}${plugin.version ? ` • v${plugin.version}` : ""}`; subtitle.textContent = `${plugin.id}${plugin.version ? ` • v${plugin.version}` : ""}`;
@@ -137,6 +164,19 @@ function renderPlugins(plugins) {
meta.appendChild(titleRow); meta.appendChild(titleRow);
meta.appendChild(subtitle); meta.appendChild(subtitle);
const needLines = describeNeeds(plugin.needs);
if (needLines.length) {
const needsBox = document.createElement("div");
needsBox.className = "mt-2 text-xs text-slate-300 space-y-1";
needsBox.innerHTML = `
<div class="${plugin.needsApproved ? "text-emerald-300" : "text-amber-300"}">
<i class="fa-solid ${plugin.needsApproved ? "fa-lock-open" : "fa-lock"} mr-1"></i>
${plugin.needsApproved ? "Needs approved" : "Needs approval before load"}
</div>
${needLines.map((line) => `<div class="font-mono text-slate-400">${escapeHtml(line)}</div>`).join("")}
`;
meta.appendChild(needsBox);
}
const actions = document.createElement("div"); const actions = document.createElement("div");
actions.className = "flex items-center gap-2"; actions.className = "flex items-center gap-2";
@@ -159,6 +199,13 @@ function renderPlugins(plugins) {
}); });
if (!res.ok) { if (!res.ok) {
const data = await res.json().catch(() => null); const data = await res.json().catch(() => null);
if (data && data.error === "needs_approval_required") {
showNeedsApprovalModal(plugin, data.needs, data.needsHash, async () => {
await setPluginEnabled(plugin, wantEnabled, true);
await refresh();
});
return;
}
if (data && data.error === "confirmation_required") { if (data && data.error === "confirmation_required") {
showPluginEnableConfirmModal(plugin, data.signature); showPluginEnableConfirmModal(plugin, data.signature);
return; return;
@@ -202,14 +249,37 @@ function renderPlugins(plugins) {
}), }),
}); });
if (!res.ok) { if (!res.ok) {
const text = await res.text(); const data = await res.json().catch(() => null);
setStatus(`Auto-load toggle failed: ${text}`, true); if (data && data.error === "needs_approval_required") {
showNeedsApprovalModal(plugin, data.needs, data.needsHash, async () => {
await fetch(`/api/plugins/${plugin.id}/autoload`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
autoLoad: !plugin.autoLoad,
autoStartEvents: plugin.autoStartEvents || [],
}),
});
await refresh();
});
return;
}
setStatus(`Auto-load toggle failed: ${data?.error || res.statusText}`, true);
return; return;
} }
await refresh(); await refresh();
}); });
} }
if (needLines.length && !plugin.needsApproved) {
const approveBtn = document.createElement("button");
approveBtn.className =
"inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-amber-900/40 border border-amber-700/60 hover:bg-amber-800/60 text-amber-100";
approveBtn.innerHTML = '<i class="fa-solid fa-key"></i> Approve needs';
approveBtn.addEventListener("click", () => showNeedsApprovalModal(plugin, plugin.needs, plugin.needsHash, refresh));
actions.appendChild(approveBtn);
}
const removeBtn = document.createElement("button"); const removeBtn = document.createElement("button");
removeBtn.className = removeBtn.className =
"inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-red-900/40 border border-red-700/60 hover:bg-red-800/60 text-red-100"; "inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-red-900/40 border border-red-700/60 hover:bg-red-800/60 text-red-100";
@@ -241,6 +311,65 @@ function renderPlugins(plugins) {
} }
} }
function escapeHtml(value) {
return String(value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
async function setPluginEnabled(plugin, enabled, confirmed = false) {
return fetch(`/api/plugins/${plugin.id}/enable`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled, confirmed }),
});
}
function showNeedsApprovalModal(plugin, needs, needsHash, afterApprove) {
document.getElementById("plugin-needs-approval-modal")?.remove();
const needLines = describeNeeds(needs);
const overlay = document.createElement("div");
overlay.id = "plugin-needs-approval-modal";
overlay.className = "fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm";
overlay.innerHTML = `
<div class="bg-slate-900 border border-slate-700 rounded-2xl p-6 max-w-lg w-full mx-4 shadow-2xl">
<div class="flex items-center gap-3 mb-4">
<i class="fa-solid fa-key text-2xl text-amber-300"></i>
<h3 class="text-lg font-semibold text-slate-100">Approve Plugin Needs</h3>
</div>
<p class="text-sm text-slate-300 mb-3"><strong class="text-slate-100">${escapeHtml(plugin.name || plugin.id)}</strong> is asking for filesystem bridges before it can be sent to clients.</p>
<div class="rounded-lg border border-slate-700 bg-slate-950/70 p-3 mb-3 space-y-2">
${needLines.length ? needLines.map((line) => `<div class="text-xs font-mono text-slate-300">${escapeHtml(line)}</div>`).join("") : '<div class="text-sm text-slate-400">No filesystem needs declared.</div>'}
</div>
${needsHash ? `<p class="text-xs text-slate-500 font-mono mb-4">Needs hash: ${escapeHtml(needsHash.slice(0, 16))}...</p>` : ""}
<div class="flex justify-end gap-2">
<button id="plugin-needs-cancel" class="px-4 py-2 rounded-lg bg-slate-800 border border-slate-600 text-slate-300 hover:bg-slate-700">Cancel</button>
<button id="plugin-needs-ok" class="px-4 py-2 rounded-lg bg-amber-900/40 border border-amber-700/60 text-amber-100 hover:bg-amber-800/60">Approve</button>
</div>
</div>
`;
document.body.appendChild(overlay);
document.getElementById("plugin-needs-cancel")?.addEventListener("click", () => overlay.remove());
overlay.addEventListener("click", (e) => { if (e.target === overlay) overlay.remove(); });
document.getElementById("plugin-needs-ok")?.addEventListener("click", async () => {
const okBtn = document.getElementById("plugin-needs-ok");
okBtn.disabled = true;
okBtn.textContent = "Approving...";
const res = await fetch(`/api/plugins/${plugin.id}/needs/approve`, { method: "POST" });
if (!res.ok) {
const text = await res.text();
setStatus(`Needs approval failed: ${text}`, true);
overlay.remove();
return;
}
overlay.remove();
setStatus("Plugin needs approved.");
if (typeof afterApprove === "function") await afterApprove();
});
}
function showPluginEnableConfirmModal(plugin, sigInfo) { function showPluginEnableConfirmModal(plugin, sigInfo) {
document.getElementById("plugin-enable-confirm-modal")?.remove(); document.getElementById("plugin-enable-confirm-modal")?.remove();
@@ -302,8 +431,16 @@ function showPluginEnableConfirmModal(plugin, sigInfo) {
body: JSON.stringify({ enabled: true, confirmed: true }), body: JSON.stringify({ enabled: true, confirmed: true }),
}); });
if (!res.ok) { if (!res.ok) {
const text = await res.text(); const data = await res.json().catch(() => null);
setStatus(`Enable failed: ${text}`, true); if (data && data.error === "needs_approval_required") {
overlay.remove();
showNeedsApprovalModal(plugin, data.needs, data.needsHash, async () => {
await setPluginEnabled(plugin, true, true);
await refresh();
});
return;
}
setStatus(`Enable failed: ${data?.error || res.statusText}`, true);
} }
} catch { } catch {
setStatus("Enable failed", true); setStatus("Enable failed", true);
+8 -1
View File
@@ -73,6 +73,7 @@ import {
savePluginStateToDisk, savePluginStateToDisk,
sendPluginBundle, sendPluginBundle,
dispatchAutoLoadPlugins, dispatchAutoLoadPlugins,
arePluginNeedsApproved,
} from "./server/plugin-state-bundle"; } from "./server/plugin-state-bundle";
import { createPluginRuntime } from "./server/plugin-runtime/runtime"; import { createPluginRuntime } from "./server/plugin-runtime/runtime";
import { import {
@@ -276,7 +277,7 @@ const MAX_HTTP_BODY_BYTES = parseMaxHttpBodyBytes();
const pluginLoadedByClient = new Map<string, Set<string>>(); const pluginLoadedByClient = new Map<string, Set<string>>();
const pendingPluginEvents = new Map<string, Array<{ event: string; payload: any }>>(); const pendingPluginEvents = new Map<string, Array<{ event: string; payload: any }>>();
const pluginLoadingByClient = new Map<string, Set<string>>(); const pluginLoadingByClient = new Map<string, Set<string>>();
let pluginState = { enabled: {} as Record<string, boolean>, lastError: {} as Record<string, string>, autoLoad: {} as Record<string, boolean>, autoStartEvents: {} as Record<string, Array<{ event: string; payload: any }>> }; let pluginState = { enabled: {} as Record<string, boolean>, lastError: {} as Record<string, string>, autoLoad: {} as Record<string, boolean>, autoStartEvents: {} as Record<string, Array<{ event: string; payload: any }>>, approvedNeeds: {} as Record<string, string> };
const savePluginState = () => savePluginStateToDisk(PLUGIN_ROOT, PLUGIN_STATE_PATH, pluginState); const savePluginState = () => savePluginStateToDisk(PLUGIN_ROOT, PLUGIN_STATE_PATH, pluginState);
const loadPluginState = async () => { const loadPluginState = async () => {
@@ -633,6 +634,12 @@ async function startServer() {
notificationPluginHandlers.markPluginLoading, notificationPluginHandlers.markPluginLoading,
notificationPluginHandlers.enqueuePluginEvent, notificationPluginHandlers.enqueuePluginEvent,
loadPluginBundle, loadPluginBundle,
async (pluginId: string) => {
const manifests = await listPluginManifests();
const manifest = manifests.find((p) => p.id === pluginId);
if (!manifest) return false;
return arePluginNeedsApproved(pluginState, pluginId, manifest.needs);
},
).catch((err) => { ).catch((err) => {
logger.warn(`[plugin-autoload] dispatch error for ${info.id}: ${(err as Error).message}`); logger.warn(`[plugin-autoload] dispatch error for ${info.id}: ${(err as Error).message}`);
}); });
+14
View File
@@ -334,10 +334,14 @@ export type ScriptResult = {
export type PluginManifest = { export type PluginManifest = {
id: string; id: string;
name: string; name: string;
apiVersion?: number;
runtime?: "native" | "wasm" | string;
version?: string; version?: string;
description?: string; description?: string;
binary?: string; binary?: string;
binaries?: Record<string, string>; binaries?: Record<string, string>;
wasm?: string;
needs?: PluginNeeds;
entry?: string; entry?: string;
assets?: { assets?: {
html?: string; html?: string;
@@ -351,6 +355,16 @@ export type PluginManifest = {
hasServer?: boolean; hasServer?: boolean;
}; };
export type PluginFileNeed = {
bucket: "home" | "desktop" | "documents" | "downloads" | "temp" | "appData" | "pluginData" | "fullDisk" | string;
access: Array<"read" | "write" | "list" | "delete" | "mkdir" | string>;
reason?: string;
};
export type PluginNeeds = {
files?: PluginFileNeed[];
};
export type PluginSignatureInfo = { export type PluginSignatureInfo = {
signed: boolean; signed: boolean;
trusted: boolean; trusted: boolean;
@@ -1,8 +1,14 @@
import { afterEach, describe, expect, test } from "bun:test"; import { afterEach, describe, expect, test } from "bun:test";
import { mkdtemp, mkdir, rm, writeFile } from "fs/promises"; import { mkdtemp, mkdir, readFile, rm, writeFile } from "fs/promises";
import { tmpdir } from "os"; import { tmpdir } from "os";
import { join } from "path"; import { join } from "path";
import { loadPluginBundle } from "./plugin-state-bundle"; import {
arePluginNeedsApproved,
compilePluginTypeScript,
computePluginNeedsHash,
loadPluginBundle,
type PluginState,
} from "./plugin-state-bundle";
let tempRoots: string[] = []; let tempRoots: string[] = [];
@@ -37,4 +43,66 @@ describe("loadPluginBundle", () => {
expect(bundle.binaryPath).toBe(join(pluginDir, "sample-c-windows-amd64.dll")); expect(bundle.binaryPath).toBe(join(pluginDir, "sample-c-windows-amd64.dll"));
expect(bundle.size).toBe("test-binary".length); expect(bundle.size).toBe("test-binary".length);
}); });
test("loads wasm manifest independently of client platform", async () => {
const root = await createTempRoot();
const pluginDir = join(root, "wasm-demo");
await mkdir(pluginDir, { recursive: true });
await writeFile(join(pluginDir, "plugin.wasm"), Buffer.from([0x00, 0x61, 0x73, 0x6d]));
await writeFile(
join(pluginDir, "manifest.json"),
JSON.stringify({
id: "wasm-demo",
name: "WASM Demo",
apiVersion: 2,
runtime: "wasm",
wasm: "plugin.wasm",
needs: { files: [{ bucket: "downloads", access: ["list", "read"], reason: "Import files" }] },
}),
);
const bundle = await loadPluginBundle(root, "wasm-demo", async () => {}, "Windows 11", "amd64");
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("computes stable need hashes and invalidates changed approvals", () => {
const state: PluginState = {
enabled: {},
lastError: {},
autoLoad: {},
autoStartEvents: {},
approvedNeeds: {},
};
const needs = { files: [{ bucket: "downloads", access: ["read", "list"] }] };
const sameNeedsDifferentOrder = { files: [{ bucket: "downloads", access: ["list", "read"] }] };
const changedNeeds = { files: [{ bucket: "downloads", access: ["read", "write"] }] };
const hash = computePluginNeedsHash(needs);
expect(hash).toBe(computePluginNeedsHash(sameNeedsDifferentOrder));
expect(hash).not.toBe(computePluginNeedsHash(changedNeeds));
expect(arePluginNeedsApproved(state, "demo", needs)).toBe(false);
state.approvedNeeds.demo = hash;
expect(arePluginNeedsApproved(state, "demo", sameNeedsDifferentOrder)).toBe(true);
expect(arePluginNeedsApproved(state, "demo", changedNeeds)).toBe(false);
});
test("compiles TypeScript entrypoints with local imports", async () => {
const root = await createTempRoot();
await mkdir(join(root, "src"), { recursive: true });
await writeFile(join(root, "src", "shared.ts"), "export const message = 'hello-ts';\n");
await writeFile(join(root, "src", "ui.ts"), "import { message } from './shared'; document.body.dataset.sample = message;\n");
await writeFile(join(root, "src", "server.ts"), "import { message } from './shared'; export default { rpc: { ping() { return { message }; } } };\n");
await compilePluginTypeScript(join(root, "src", "ui.ts"), join(root, "assets", "sample-ts.js"), "browser", "sample-ts");
await compilePluginTypeScript(join(root, "src", "server.ts"), join(root, "server.js"), "bun", "sample-ts");
const uiJs = await readFile(join(root, "assets", "sample-ts.js"), "utf-8");
const serverJs = await readFile(join(root, "server.js"), "utf-8");
expect(uiJs).toContain("hello-ts");
expect(serverJs).toContain("hello-ts");
});
}); });
+221 -25
View File
@@ -1,19 +1,21 @@
import fs from "fs/promises"; import fs from "fs/promises";
import path from "path"; import path from "path";
import AdmZip from "adm-zip"; import { createHash } from "crypto";
import { v4 as uuidv4 } from "uuid"; import { v4 as uuidv4 } from "uuid";
import type { ClientInfo } from "../types"; import type { ClientInfo } from "../types";
import { logger } from "../logger"; import type { PluginFileNeed, PluginManifest, PluginNeeds, PluginSignatureInfo } from "../protocol";
import { encodeMessage, type PluginManifest, type PluginSignatureInfo } from "../protocol";
import { verifyPluginSignature, getOrVerifySignature } from "./plugin-signature";
export type PluginState = { export type PluginState = {
enabled: Record<string, boolean>; enabled: Record<string, boolean>;
lastError: Record<string, string>; lastError: Record<string, string>;
autoLoad: Record<string, boolean>; autoLoad: Record<string, boolean>;
autoStartEvents: Record<string, Array<{ event: string; payload: any }>>; autoStartEvents: Record<string, Array<{ event: string; payload: any }>>;
approvedNeeds: Record<string, string>;
}; };
const FILE_BUCKETS = new Set(["home", "desktop", "documents", "downloads", "temp", "appData", "pluginData", "fullDisk"]);
const FILE_ACCESS = new Set(["read", "write", "list", "delete", "mkdir"]);
export async function loadPluginStateFromDisk(pluginStatePath: string): Promise<PluginState> { export async function loadPluginStateFromDisk(pluginStatePath: string): Promise<PluginState> {
try { try {
const raw = await fs.readFile(pluginStatePath, "utf-8"); const raw = await fs.readFile(pluginStatePath, "utf-8");
@@ -23,9 +25,10 @@ export async function loadPluginStateFromDisk(pluginStatePath: string): Promise<
lastError: parsed.lastError || {}, lastError: parsed.lastError || {},
autoLoad: parsed.autoLoad || {}, autoLoad: parsed.autoLoad || {},
autoStartEvents: parsed.autoStartEvents || {}, autoStartEvents: parsed.autoStartEvents || {},
approvedNeeds: parsed.approvedNeeds || {},
}; };
} catch { } catch {
return { enabled: {}, lastError: {}, autoLoad: {}, autoStartEvents: {} }; return { enabled: {}, lastError: {}, autoLoad: {}, autoStartEvents: {}, approvedNeeds: {} };
} }
} }
@@ -75,6 +78,7 @@ export async function ensurePluginExtracted(
const assetsDir = path.join(pluginDir, "assets"); const assetsDir = path.join(pluginDir, "assets");
await fs.mkdir(assetsDir, { recursive: true }); await fs.mkdir(assetsDir, { recursive: true });
const AdmZip = await loadAdmZip();
const zip = new AdmZip(zipPath); const zip = new AdmZip(zipPath);
const entries = zip.getEntries(); const entries = zip.getEntries();
@@ -97,14 +101,19 @@ export async function ensurePluginExtracted(
let jsEntry: Buffer | null = null; let jsEntry: Buffer | null = null;
let serverEntry: Buffer | null = null; let serverEntry: Buffer | null = null;
let configEntry: 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(); const nativeBinaries: Map<string, Buffer> = new Map();
for (const entry of entries) { for (const entry of entries) {
if (entry.isDirectory) continue; if (entry.isDirectory) continue;
const normalizedEntry = normalizeBundlePath(entry.entryName);
const base = path.basename(entry.entryName); const base = path.basename(entry.entryName);
const lower = base.toLowerCase(); const lower = base.toLowerCase();
if (lower.endsWith(".so") || lower.endsWith(".dll") || lower.endsWith(".dylib")) { if (lower.endsWith(".so") || lower.endsWith(".dll") || lower.endsWith(".dylib")) {
nativeBinaries.set(base, entry.getData()); nativeBinaries.set(base, entry.getData());
} else if (lower.endsWith(".wasm")) {
wasmEntry = { filename: base, data: entry.getData() };
} else if (lower === "server.js") { } else if (lower === "server.js") {
serverEntry = entry.getData(); serverEntry = entry.getData();
} else if (lower.endsWith(".html")) { } else if (lower.endsWith(".html")) {
@@ -116,10 +125,28 @@ export async function ensurePluginExtracted(
} else if (lower === "config.json") { } else if (lower === "config.json") {
configEntry = entry.getData(); configEntry = entry.getData();
} }
if (normalizedEntry && isPluginSourcePath(normalizedEntry)) {
sourceEntries.set(normalizedEntry, entry.getData());
}
} }
if (!htmlEntry || !cssEntry || !jsEntry) { let extraConfig: any = {};
throw new Error(`Invalid plugin bundle: ${safeId} (missing .html, .css, or .js)`); if (configEntry) {
try {
extraConfig = JSON.parse(configEntry.toString("utf-8"));
} catch (err) {
warnPlugin(`[plugin] invalid config.json in bundle ${safeId}, ignoring: ${err}`);
}
}
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" : "");
const serverTsEntry = getConfiguredSourceEntry(extraConfig, ["serverEntry", "server.entry", "build.server", "build.serverEntry"], sourceEntries)
|| (sourceEntries.has("src/server.ts") ? "src/server.ts" : "");
if (!htmlEntry || !cssEntry || (!jsEntry && !uiTsEntry)) {
throw new Error(`Invalid plugin bundle: ${safeId} (missing .html, .css, and either .js or src/ui.ts)`);
} }
const binariesMap: Record<string, string> = {}; const binariesMap: Record<string, string> = {};
@@ -130,13 +157,35 @@ export async function ensurePluginExtracted(
binariesMap[platformKey] = filename; 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) {
const target = resolveInside(pluginDir, relPath);
if (!target) {
throw new Error(`Invalid plugin bundle: ${safeId} (unsafe source path)`);
}
await fs.mkdir(path.dirname(target), { recursive: true });
await fs.writeFile(target, data);
}
}
await fs.writeFile(path.join(assetsDir, `${safeId}.html`), htmlEntry); await fs.writeFile(path.join(assetsDir, `${safeId}.html`), htmlEntry);
await fs.writeFile(path.join(assetsDir, `${safeId}.css`), cssEntry); await fs.writeFile(path.join(assetsDir, `${safeId}.css`), cssEntry);
await fs.writeFile(path.join(assetsDir, `${safeId}.js`), jsEntry); if (uiTsEntry) {
await compilePluginTypeScript(path.join(pluginDir, uiTsEntry), path.join(assetsDir, `${safeId}.js`), "browser", safeId);
} else if (jsEntry) {
await fs.writeFile(path.join(assetsDir, `${safeId}.js`), jsEntry);
}
const serverScriptPath = path.join(pluginDir, "server.js"); const serverScriptPath = path.join(pluginDir, "server.js");
if (serverEntry) { if (serverTsEntry) {
await compilePluginTypeScript(path.join(pluginDir, serverTsEntry), serverScriptPath, "bun", safeId);
} else if (serverEntry) {
await fs.writeFile(serverScriptPath, serverEntry); await fs.writeFile(serverScriptPath, serverEntry);
} else { } else {
// A previous extraction may have left a stale server.js — drop it so the // A previous extraction may have left a stale server.js — drop it so the
@@ -144,20 +193,17 @@ export async function ensurePluginExtracted(
try { await fs.rm(serverScriptPath, { force: true }); } catch {} try { await fs.rm(serverScriptPath, { force: true }); } catch {}
} }
let extraConfig: any = {}; const runtime = normalizePluginRuntime(extraConfig.runtime, wasmEntry !== null);
if (configEntry) { const needs = normalizePluginNeeds(extraConfig.needs);
try {
extraConfig = JSON.parse(configEntry.toString("utf-8"));
} catch (err) {
logger.warn(`[plugin] invalid config.json in bundle ${safeId}, ignoring: ${err}`);
}
}
const manifest: PluginManifest = { const manifest: PluginManifest = {
id: safeId, id: safeId,
name: extraConfig.name || safeId, name: extraConfig.name || safeId,
apiVersion: Number(extraConfig.apiVersion) === 2 || runtime === "wasm" ? 2 : 1,
runtime,
version: extraConfig.version || "1.0.0", version: extraConfig.version || "1.0.0",
description: extraConfig.description, description: extraConfig.description,
...(runtime === "wasm" && { wasm: String(extraConfig.wasm || wasmEntry?.filename || binariesMap["wasm32-wasi"] || "") }),
...(needs && { needs }),
binaries: binariesMap, binaries: binariesMap,
entry: `${safeId}.html`, entry: `${safeId}.html`,
assets: { assets: {
@@ -166,16 +212,17 @@ export async function ensurePluginExtracted(
js: `${safeId}.js`, js: `${safeId}.js`,
}, },
...(extraConfig.navbar && { navbar: extraConfig.navbar }), ...(extraConfig.navbar && { navbar: extraConfig.navbar }),
hasServer: serverEntry !== null, hasServer: serverEntry !== null || !!serverTsEntry,
}; };
await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2)); await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2));
try { try {
const { verifyPluginSignature } = await import("./plugin-signature");
const sigInfo = await verifyPluginSignature(zipPath); const sigInfo = await verifyPluginSignature(zipPath);
const sigInfoPath = path.join(pluginDir, "signature-info.json"); const sigInfoPath = path.join(pluginDir, "signature-info.json");
await fs.writeFile(sigInfoPath, JSON.stringify(sigInfo, null, 2)); await fs.writeFile(sigInfoPath, JSON.stringify(sigInfo, null, 2));
} catch (err) { } catch (err) {
logger.warn(`[plugin] failed to verify signature for ${safeId}: ${(err as Error).message}`); warnPlugin(`[plugin] failed to verify signature for ${safeId}: ${(err as Error).message}`);
} }
} }
@@ -191,7 +238,7 @@ export async function syncPluginBundles(
try { try {
await ensureExtracted(pluginId); await ensureExtracted(pluginId);
} catch (err) { } catch (err) {
logger.warn(`[plugin] failed to extract ${pluginId}: ${(err as Error).message}`); warnPlugin(`[plugin] failed to extract ${pluginId}: ${(err as Error).message}`);
} }
} }
} }
@@ -199,6 +246,7 @@ export async function syncPluginBundles(
export type PluginManifestWithSignature = PluginManifest & { export type PluginManifestWithSignature = PluginManifest & {
signature?: PluginSignatureInfo; signature?: PluginSignatureInfo;
needsHash?: string;
}; };
export async function listPluginManifests( export async function listPluginManifests(
@@ -222,8 +270,9 @@ export async function listPluginManifests(
if (pluginState.enabled[id] === undefined) { if (pluginState.enabled[id] === undefined) {
pluginState.enabled[id] = true; pluginState.enabled[id] = true;
} }
const { getOrVerifySignature } = await import("./plugin-signature");
const sigInfo = await getOrVerifySignature(pluginRoot, id); const sigInfo = await getOrVerifySignature(pluginRoot, id);
manifests.push({ ...manifest, id, name, signature: sigInfo }); manifests.push({ ...manifest, id, name, signature: sigInfo, needsHash: computePluginNeedsHash(manifest.needs) });
} catch {} } catch {}
} }
await saveState(); await saveState();
@@ -254,6 +303,19 @@ export async function loadPluginBundle(
manifest.id = manifest.id || pluginId; manifest.id = manifest.id || pluginId;
manifest.name = manifest.name || 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 };
}
manifest.runtime = manifest.runtime || "native";
manifest.apiVersion = manifest.apiVersion || 1;
const hasBinaries = manifest.binaries && Object.keys(manifest.binaries).length > 0; const hasBinaries = manifest.binaries && Object.keys(manifest.binaries).length > 0;
if (!hasBinaries) { if (!hasBinaries) {
return { manifest, binaryPath: null, size: 0 }; return { manifest, binaryPath: null, size: 0 };
@@ -366,6 +428,7 @@ export function sendPluginBundle(
bundle: PluginBundle, bundle: PluginBundle,
): void { ): void {
if (!bundle.binaryPath) return; if (!bundle.binaryPath) return;
const { encodeMessage } = require("../protocol");
const pullId = uuidv4(); const pullId = uuidv4();
const expiresAt = Date.now() + PLUGIN_PULL_TTL_MS; const expiresAt = Date.now() + PLUGIN_PULL_TTL_MS;
@@ -405,6 +468,7 @@ export async function dispatchAutoLoadPlugins(
markPluginLoading: (clientId: string, pluginId: string) => void, markPluginLoading: (clientId: string, pluginId: string) => void,
enqueuePluginEvent: (clientId: string, pluginId: string, event: string, payload: any) => void, enqueuePluginEvent: (clientId: string, pluginId: string, event: string, payload: any) => void,
loadBundle: (pluginId: string, clientOS?: string, clientArch?: string) => Promise<PluginBundle>, loadBundle: (pluginId: string, clientOS?: string, clientArch?: string) => Promise<PluginBundle>,
needsApproved: (pluginId: string) => Promise<boolean> = async () => true,
): Promise<void> { ): Promise<void> {
const autoLoadIds = Object.entries(pluginState.autoLoad) const autoLoadIds = Object.entries(pluginState.autoLoad)
.filter(([id, enabled]) => enabled && pluginState.enabled[id] !== false) .filter(([id, enabled]) => enabled && pluginState.enabled[id] !== false)
@@ -416,6 +480,10 @@ export async function dispatchAutoLoadPlugins(
} }
try { try {
if (!(await needsApproved(pluginId))) {
warnPlugin(`[plugin-autoload] skipped ${pluginId} for ${client.id}: plugin needs approval`);
continue;
}
const bundle = await loadBundle(pluginId, client.os, client.arch); const bundle = await loadBundle(pluginId, client.os, client.arch);
markPluginLoading(client.id, pluginId); markPluginLoading(client.id, pluginId);
sendPluginBundle(client, bundle); sendPluginBundle(client, bundle);
@@ -427,14 +495,134 @@ export async function dispatchAutoLoadPlugins(
} }
} }
logger.info(`[plugin-autoload] dispatched ${pluginId} to ${client.id}`); infoPlugin(`[plugin-autoload] dispatched ${pluginId} to ${client.id}`);
} catch (err) { } catch (err) {
logger.warn(`[plugin-autoload] failed to load ${pluginId} for ${client.id}: ${(err as Error).message}`); warnPlugin(`[plugin-autoload] failed to load ${pluginId} for ${client.id}: ${(err as Error).message}`);
} }
} }
} }
export function detectPluginIdFromZip(zip: AdmZip): string | null { function warnPlugin(message: string): void {
console.warn(message);
}
function infoPlugin(message: string): void {
console.log(message);
}
function normalizeBundlePath(entryName: string): string | null {
if (!entryName || entryName.includes("\0")) return null;
const normalized = entryName.replace(/\\/g, "/").replace(/^\/+/, "");
const parts = normalized.split("/").filter(Boolean);
if (parts.some((part) => part === "." || part === "..")) return null;
return parts.join("/");
}
function isPluginSourcePath(relPath: string): boolean {
if (!relPath.startsWith("src/")) return false;
const lower = relPath.toLowerCase();
return (
lower.endsWith(".ts") ||
lower.endsWith(".tsx") ||
lower.endsWith(".js") ||
lower.endsWith(".jsx") ||
lower.endsWith(".json") ||
lower.endsWith(".d.ts")
);
}
function resolveInside(root: string, relPath: string): string | null {
const resolvedRoot = path.resolve(root);
const resolvedTarget = path.resolve(resolvedRoot, relPath);
const prefix = resolvedRoot.endsWith(path.sep) ? resolvedRoot : `${resolvedRoot}${path.sep}`;
if (resolvedTarget !== resolvedRoot && !resolvedTarget.startsWith(prefix)) return null;
return resolvedTarget;
}
function getConfiguredSourceEntry(config: any, candidates: string[], sourceEntries: Map<string, Buffer>): string {
for (const candidate of candidates) {
const value = getPathValue(config, candidate);
if (typeof value !== "string") continue;
const normalized = normalizeBundlePath(value);
if (!normalized || !isPluginSourcePath(normalized)) continue;
if (!sourceEntries.has(normalized)) {
throw new Error(`Configured TypeScript entry not found: ${normalized}`);
}
return normalized;
}
return "";
}
function getPathValue(obj: any, dotted: string): unknown {
let cur = obj;
for (const part of dotted.split(".")) {
if (!cur || typeof cur !== "object") return undefined;
cur = cur[part];
}
return cur;
}
export async function compilePluginTypeScript(entryPath: string, outFile: string, target: "browser" | "bun", pluginId: string): Promise<void> {
const result = await Bun.build({
entrypoints: [entryPath],
target,
format: "esm",
minify: false,
sourcemap: "none",
write: false,
});
if (!result.success) {
const logs = result.logs.map((log) => log.message).join("; ");
throw new Error(`TypeScript build failed for ${pluginId}: ${logs || "unknown error"}`);
}
const output = result.outputs.find((out) => out.path.endsWith(".js")) || result.outputs[0];
if (!output) {
throw new Error(`TypeScript build failed for ${pluginId}: no JavaScript output`);
}
await fs.mkdir(path.dirname(outFile), { recursive: true });
await fs.writeFile(outFile, await output.text());
}
export function computePluginNeedsHash(needs: PluginNeeds | undefined): string {
const normalized = normalizePluginNeeds(needs);
if (!normalized || !normalized.files || normalized.files.length === 0) return "";
return createHash("sha256").update(JSON.stringify(normalized)).digest("hex");
}
export function arePluginNeedsApproved(pluginState: PluginState, pluginId: string, needs: PluginNeeds | undefined): boolean {
const hash = computePluginNeedsHash(needs);
return !hash || pluginState.approvedNeeds[pluginId] === hash;
}
export function normalizePluginNeeds(raw: any): PluginNeeds | undefined {
if (!raw || typeof raw !== "object") return undefined;
const filesRaw = Array.isArray(raw.files) ? raw.files : [];
const files: PluginFileNeed[] = [];
for (const item of filesRaw) {
if (!item || typeof item !== "object") continue;
const bucket = typeof item.bucket === "string" ? item.bucket.trim() : "";
if (!FILE_BUCKETS.has(bucket)) continue;
const accessRaw = Array.isArray(item.access) ? item.access : [];
const access = Array.from(new Set(accessRaw.filter((a: any) => typeof a === "string" && FILE_ACCESS.has(a))));
if (access.length === 0) continue;
files.push({
bucket,
access: access.sort(),
...(typeof item.reason === "string" && item.reason.trim() ? { reason: item.reason.trim().slice(0, 500) } : {}),
});
}
files.sort((a, b) => `${a.bucket}:${a.access.join(",")}`.localeCompare(`${b.bucket}:${b.access.join(",")}`));
return files.length > 0 ? { files } : undefined;
}
function normalizePluginRuntime(runtime: any, hasWasm: boolean): "native" | "wasm" | "server" {
const value = typeof runtime === "string" ? runtime.trim().toLowerCase() : "";
if (value === "wasm" || hasWasm) return "wasm";
if (value === "server") return "server";
return "native";
}
export function detectPluginIdFromZip(zip: any): string | null {
const entries = zip.getEntries(); const entries = zip.getEntries();
for (const entry of entries) { for (const entry of entries) {
if (entry.isDirectory) continue; if (entry.isDirectory) continue;
@@ -457,6 +645,14 @@ export function detectPluginIdFromZip(zip: AdmZip): string | null {
return null; return null;
} }
async function loadAdmZip(): Promise<any> {
try {
return require("adm-zip");
} catch {
return require(path.join(process.cwd(), "node_modules", "adm-zip", "adm-zip.js"));
}
}
function derivePlatformKey(filename: string): string { function derivePlatformKey(filename: string): string {
const match = filename.match(/-(linux|darwin|windows|freebsd)-(amd64|arm64|arm|386)\.(so|dll|dylib)$/i); const match = filename.match(/-(linux|darwin|windows|freebsd)-(amd64|arm64|arm|386)\.(so|dll|dylib)$/i);
if (match) { if (match) {
@@ -12,7 +12,7 @@ import { encodeMessage, type PluginSignatureInfo } from "../../protocol";
import { getConfig, updatePluginsConfig } from "../../config"; import { getConfig, updatePluginsConfig } from "../../config";
import { getOrVerifySignature, BUILTIN_TRUSTED_KEYS } from "../plugin-signature"; import { getOrVerifySignature, BUILTIN_TRUSTED_KEYS } from "../plugin-signature";
import type { PluginRuntime } from "../plugin-runtime/runtime"; import type { PluginRuntime } from "../plugin-runtime/runtime";
import { getPluginPull, deletePluginPull, detectPluginIdFromZip } from "../plugin-state-bundle"; import { arePluginNeedsApproved, computePluginNeedsHash, getPluginPull, deletePluginPull, detectPluginIdFromZip } from "../plugin-state-bundle";
import { isAuthorizedAgentRequest } from "../agent-auth"; import { isAuthorizedAgentRequest } from "../agent-auth";
import { logger } from "../../logger"; import { logger } from "../../logger";
@@ -21,6 +21,11 @@ type PluginManifest = {
name: string; name: string;
signature?: PluginSignatureInfo; signature?: PluginSignatureInfo;
hasServer?: boolean; hasServer?: boolean;
apiVersion?: number;
runtime?: string;
wasm?: string;
needs?: any;
needsHash?: string;
}; };
type PluginBundle = { type PluginBundle = {
@@ -34,6 +39,7 @@ type PluginState = {
lastError: Record<string, string>; lastError: Record<string, string>;
autoLoad: Record<string, boolean>; autoLoad: Record<string, boolean>;
autoStartEvents: Record<string, Array<{ event: string; payload: any }>>; autoStartEvents: Record<string, Array<{ event: string; payload: any }>>;
approvedNeeds: Record<string, string>;
}; };
type PluginRouteDeps = { type PluginRouteDeps = {
@@ -72,6 +78,34 @@ export async function handlePluginRoutes(
return null; return null;
} }
async function loadManifest(pluginId: string): Promise<PluginManifest> {
await deps.ensurePluginExtracted(pluginId);
const raw = await fs.readFile(path.join(deps.PLUGIN_ROOT, pluginId, "manifest.json"), "utf-8");
const manifest = JSON.parse(raw) as PluginManifest;
manifest.id = manifest.id || pluginId;
manifest.name = manifest.name || pluginId;
manifest.needsHash = computePluginNeedsHash(manifest.needs);
return manifest;
}
function needsApproved(manifest: PluginManifest): boolean {
return arePluginNeedsApproved(deps.pluginState, manifest.id, manifest.needs);
}
async function requireNeedsApproval(pluginId: string): Promise<Response | null> {
const manifest = await loadManifest(pluginId);
if (needsApproved(manifest)) return null;
return Response.json(
{
ok: false,
error: "needs_approval_required",
needs: manifest.needs || {},
needsHash: manifest.needsHash || "",
},
{ status: 428 },
);
}
if (req.method === "GET" && url.pathname.startsWith("/api/plugins/pull/")) { if (req.method === "GET" && url.pathname.startsWith("/api/plugins/pull/")) {
const agentToken = getConfig().auth.agentToken; const agentToken = getConfig().auth.agentToken;
if (!isAuthorizedAgentRequest(req, url, agentToken)) { if (!isAuthorizedAgentRequest(req, url, agentToken)) {
@@ -137,6 +171,11 @@ export async function handlePluginRoutes(
autoLoad: deps.pluginState.autoLoad[p.id] === true, autoLoad: deps.pluginState.autoLoad[p.id] === true,
autoStartEvents: deps.pluginState.autoStartEvents[p.id] || [], autoStartEvents: deps.pluginState.autoStartEvents[p.id] || [],
signature: p.signature || { signed: false, trusted: false, valid: false }, signature: p.signature || { signed: false, trusted: false, valid: false },
runtime: p.runtime || "native",
apiVersion: p.apiVersion || 1,
needs: p.needs || {},
needsHash: p.needsHash || computePluginNeedsHash(p.needs),
needsApproved: arePluginNeedsApproved(deps.pluginState, p.id, p.needs),
hasServer: p.hasServer === true || deps.pluginRuntime.hasServerCode(p.id), hasServer: p.hasServer === true || deps.pluginRuntime.hasServerCode(p.id),
serverRunning: deps.pluginRuntime.isRunning(p.id), serverRunning: deps.pluginRuntime.isRunning(p.id),
})); }));
@@ -229,6 +268,11 @@ export async function handlePluginRoutes(
enabled: deps.pluginState.enabled[manifest.id] !== false, enabled: deps.pluginState.enabled[manifest.id] !== false,
lastError: deps.pluginState.lastError[manifest.id] || "", lastError: deps.pluginState.lastError[manifest.id] || "",
signature: manifest.signature || { signed: false, trusted: false, valid: false }, signature: manifest.signature || { signed: false, trusted: false, valid: false },
runtime: manifest.runtime || "native",
apiVersion: manifest.apiVersion || 1,
needs: manifest.needs || {},
needsHash: manifest.needsHash || computePluginNeedsHash(manifest.needs),
needsApproved: arePluginNeedsApproved(deps.pluginState, manifest.id, manifest.needs),
})); }));
return Response.json({ plugins }); return Response.json({ plugins });
} }
@@ -312,8 +356,10 @@ export async function handlePluginRoutes(
const sigInfo = await getOrVerifySignature(deps.PLUGIN_ROOT, pluginId); const sigInfo = await getOrVerifySignature(deps.PLUGIN_ROOT, pluginId);
const uploadedManifest = await loadManifest(pluginId);
if (deps.pluginState.enabled[pluginId] === undefined) { if (deps.pluginState.enabled[pluginId] === undefined) {
deps.pluginState.enabled[pluginId] = sigInfo.trusted === true; deps.pluginState.enabled[pluginId] = sigInfo.trusted === true && needsApproved(uploadedManifest);
await deps.savePluginState(); await deps.savePluginState();
} }
@@ -364,6 +410,8 @@ export async function handlePluginRoutes(
); );
} }
} }
const needsResponse = await requireNeedsApproval(pluginId);
if (needsResponse) return needsResponse;
} }
deps.pluginState.enabled[pluginId] = enabled; deps.pluginState.enabled[pluginId] = enabled;
@@ -410,6 +458,10 @@ export async function handlePluginRoutes(
{ status: 400 }, { status: 400 },
); );
} }
if (autoLoad) {
const needsResponse = await requireNeedsApproval(pluginId);
if (needsResponse) return needsResponse;
}
deps.pluginState.autoLoad[pluginId] = autoLoad; deps.pluginState.autoLoad[pluginId] = autoLoad;
@@ -444,6 +496,33 @@ export async function handlePluginRoutes(
}); });
} }
const pluginNeedsApproveMatch = url.pathname.match(/^\/api\/plugins\/(.+)\/needs\/approve$/);
if (req.method === "POST" && pluginNeedsApproveMatch) {
const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 });
try {
requirePermission(user, "plugins:manage");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
let pluginId = "";
try {
pluginId = deps.sanitizePluginId(pluginNeedsApproveMatch[1]);
} catch {
return new Response("Invalid plugin id", { status: 400 });
}
const manifest = await loadManifest(pluginId);
const needsHash = manifest.needsHash || "";
if (!needsHash) {
delete deps.pluginState.approvedNeeds[pluginId];
} else {
deps.pluginState.approvedNeeds[pluginId] = needsHash;
}
await deps.savePluginState();
return Response.json({ ok: true, id: pluginId, needsHash, needsApproved: true });
}
function resolveDataPath(pluginId: string, relPath: string): string | null { function resolveDataPath(pluginId: string, relPath: string): string | null {
const dataDir = path.join(deps.PLUGIN_ROOT, pluginId, "data"); const dataDir = path.join(deps.PLUGIN_ROOT, pluginId, "data");
const target = path.resolve(dataDir, relPath); const target = path.resolve(dataDir, relPath);
@@ -755,6 +834,7 @@ export async function handlePluginRoutes(
delete deps.pluginState.lastError[pluginId]; delete deps.pluginState.lastError[pluginId];
delete deps.pluginState.autoLoad[pluginId]; delete deps.pluginState.autoLoad[pluginId];
delete deps.pluginState.autoStartEvents[pluginId]; delete deps.pluginState.autoStartEvents[pluginId];
delete deps.pluginState.approvedNeeds[pluginId];
await deps.savePluginState(); await deps.savePluginState();
if (deps.pluginRuntime.isRunning(pluginId)) { if (deps.pluginRuntime.isRunning(pluginId)) {
@@ -821,6 +901,8 @@ export async function handlePluginRoutes(
try { try {
const bundle = await deps.loadPluginBundle(pluginId, target.os, target.arch); const bundle = await deps.loadPluginBundle(pluginId, target.os, target.arch);
const needsResponse = await requireNeedsApproval(pluginId);
if (needsResponse) return needsResponse;
deps.markPluginLoading(targetId, pluginId); deps.markPluginLoading(targetId, pluginId);
deps.sendPluginBundle(target, bundle); deps.sendPluginBundle(target, bundle);
metrics.recordCommand("plugin_load"); metrics.recordCommand("plugin_load");
@@ -902,6 +984,8 @@ export async function handlePluginRoutes(
} }
if (!deps.isPluginLoaded(targetId, pluginId)) { if (!deps.isPluginLoaded(targetId, pluginId)) {
const needsResponse = await requireNeedsApproval(pluginId);
if (needsResponse) return needsResponse;
deps.enqueuePluginEvent(targetId, pluginId, event, payload); deps.enqueuePluginEvent(targetId, pluginId, event, payload);
if (!deps.isPluginLoading(targetId, pluginId)) { if (!deps.isPluginLoading(targetId, pluginId)) {
try { try {
+263 -66
View File
@@ -1,10 +1,92 @@
# Overlord Plugins # Overlord Plugins
This document explains how to build Overlord plugins using the **native plugin system**. Plugins run as native shared libraries (`.so` on Linux, `.dylib` on macOS, in-memory DLL on Windows), giving them full access to system APIs. This document explains how to build Overlord plugins. **Plugin 2.0** uses WebAssembly modules for agent-side code and permissioned host bridges. The legacy native plugin system still works, but native plugins are now considered v1.
Plugins can be written in **any language** that can produce a C-ABI shared library: **Go**, **C**, **C++**, **Rust**, or anything else that compiles to native code. Plugin UI and server-side logic can be written in JavaScript or TypeScript. Agent-side Plugin 2.0 code is a single WASM module that can be built from C, Rust, Zig, TinyGo, AssemblyScript, or any other toolchain that can target WASI-style WebAssembly. Legacy v1 agent plugins can still be written as native shared libraries.
> TL;DR: A plugin is a zip with platform-specific binaries (`.so`/`.dll`/`.dylib`) plus `<id>.html`, `<id>.css`, `<id>.js`. Upload it in the Plugins page or drop it in Overlord-Server/plugins. Optionally include a `server.js` for a per-plugin server runtime with a private SQLite DB, an RPC API, and live broadcasts to open UIs (see [section 11](#11-server-side-plugin-runtime)). > TL;DR: A Plugin 2.0 bundle is a zip with `config.json`, `<id>.html`, `<id>.css`, and either `<id>.js` or TypeScript under `src/`. If it needs agent-side code, include one `.wasm` module. Upload it in the Plugins page or drop it in `Overlord-Server/plugins`. The server shows declared `needs` before a WASM plugin can be sent to a client.
## Plugin 2.0: WASM runtime
WASM plugins run inside the agent through a sandboxed runtime. They cannot access files directly; instead they declare broad file needs in `config.json`, and the agent exposes only those approved bridge operations.
```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" },
{ "bucket": "downloads", "access": ["list"], "reason": "Let the user pick downloaded files" }
]
}
}
```
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 the 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 are available from both `env` and `overlord` modules:
| Import | Behavior |
|--------|----------|
| `overlord_emit(event, eventLen, payload, payloadLen)` | Sends an event back through the normal plugin event channel. |
| `overlord_host_info(out, outLen)` | Writes HostInfo JSON into `out`; includes `clientId`, `os`, `arch`, and `version`. |
| `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.
See `plugins/sample-wasm` for a C/WASI example, `plugins/sample-wasm-hostinfo` for a TinyGo example, and `plugins/sample-wasm-platform-note` for a Rust example.
## TypeScript UI and server logic
Plugin bundles can ship TypeScript source for browser UI logic and the optional server runtime. The server compiles these files during extraction, so the uploaded zip can stay clean and source-oriented.
Default layout:
```
sample-ts-fullstack.zip
├─ config.json
├─ sample-ts-fullstack.html
├─ sample-ts-fullstack.css
└─ src/
├─ ui.ts
├─ server.ts
└─ shared.ts
```
`src/ui.ts` is compiled to `assets/<pluginId>.js`. `src/server.ts` is compiled to `server.js`. Both entrypoints can import other local files, so plugin authors can split code across multiple TypeScript modules instead of maintaining one large JavaScript file. This is only for browser UI and server-side plugin runtime logic; agent-side WASM is still supplied as a prebuilt `.wasm` file.
You can override entrypoints in `config.json`:
```json
{
"apiVersion": 2,
"uiEntry": "src/browser/main.ts",
"serverEntry": "src/backend/index.ts"
}
```
Only files under `src/` are copied for compilation. Extraction does not run `npm install`, download packages, or execute arbitrary build scripts; use relative imports and APIs already available in the browser or the Overlord plugin server runtime.
See `plugins/sample-ts-fullstack` for a multi-file TypeScript UI/backend example.
## 1) How plugins are structured ## 1) How plugins are structured
@@ -16,47 +98,84 @@ A plugin bundle is a zip file named after the plugin ID:
<pluginId>.zip <pluginId>.zip
``` ```
Inside the zip (root level), you need: For a Plugin 2.0 source-oriented bundle, the root normally contains:
- **Platform-specific binaries** named `<pluginId>-<os>-<arch>.<ext>`
- **Web assets**: `<pluginId>.html`, `<pluginId>.css`, `<pluginId>.js`
Example for plugin ID `sample`:
``` ```
sample.zip <pluginId>.zip
├─ sample-linux-amd64.so ├─ config.json
├─ sample-linux-arm64.so ├─ <pluginId>.html
├─ sample-darwin-arm64.dylib ├─ <pluginId>.css
├─ sample-windows-amd64.dll ├─ <pluginId>.js (optional when src/ui.ts exists)
├─ sample.html ├─ <pluginId>.wasm (optional agent-side WASM module)
├─ sample.css └─ src/ (optional TypeScript/JavaScript source)
├─ sample.js ├─ ui.ts (compiled to assets/<pluginId>.js)
├─ server.js (optional — server-side runtime, see section 11) ├─ server.ts (compiled to server.js)
└─ config.json (optional — enables navbar & metadata) └─ shared.ts
```
For a legacy native v1 bundle, include platform-specific binaries named `<pluginId>-<os>-<arch>.<ext>` plus web assets.
Plugin 2.0 example for plugin ID `sample-wasm`:
```
sample-wasm.zip
├─ config.json
├─ sample-wasm.html
├─ sample-wasm.css
├─ sample-wasm.wasm
└─ src/
├─ ui.ts
└─ server.ts
``` ```
When the server extracts the zip: When the server extracts the zip:
``` ```
Overlord-Server/plugins/sample/ Overlord-Server/plugins/sample-wasm/
├─ sample-linux-amd64.so ├─ sample-wasm.wasm
├─ sample-linux-arm64.so ├─ server.js (compiled from src/server.ts when present)
├─ sample-darwin-arm64.dylib
├─ sample-windows-amd64.dll
├─ server.js (optional — picked up by the plugin runtime)
├─ manifest.json (auto-generated, merged with config.json) ├─ manifest.json (auto-generated, merged with config.json)
├─ src/ (copied source files)
├─ data/ ├─ data/
│ └─ plugin.db (auto-created when server.js is present) │ └─ plugin.db (auto-created when server.js is present)
└─ assets/ └─ assets/
├─ sample.html ├─ sample-wasm.html
├─ sample.css ├─ sample-wasm.css
└─ sample.js └─ sample-wasm.js (compiled from src/ui.ts or copied from root JS)
``` ```
### Architecture validation ### Runtime selection
The server matches binaries to clients by OS and architecture. Binary filenames **must** follow the naming convention `<pluginId>-<os>-<arch>.<ext>`: `config.json` controls the runtime style:
```json
{
"apiVersion": 2,
"runtime": "wasm",
"wasm": "sample-wasm.wasm"
}
```
Use `runtime: "wasm"` when the plugin has an agent-side WASM module. Use `runtime: "server"` for UI/server-only Plugin 2.0 bundles. Legacy native bundles omit `apiVersion`/`runtime` or use `runtime: "native"`.
### WASM architecture behavior
WASM plugins are universal: the same `.wasm` file is sent to Windows, Linux, and macOS clients on any supported architecture. To branch by client platform, read HostInfo either from `overlord_on_load(hostInfo, hostInfoLen)` or by calling `overlord_host_info(out, outLen)`.
Example HostInfo:
```json
{
"clientId": "abc123",
"os": "windows",
"arch": "amd64",
"version": "1.0.0"
}
```
### Legacy native architecture validation
Legacy native v1 binaries are still matched to clients by OS and architecture. Binary filenames **must** follow the naming convention `<pluginId>-<os>-<arch>.<ext>`:
| OS | Arch | Example filename | | OS | Arch | Example filename |
|----------|---------|------------------------------| |----------|---------|------------------------------|
@@ -67,7 +186,7 @@ The server matches binaries to clients by OS and architecture. Binary filenames
| darwin | amd64 | `sample-darwin-amd64.dylib` | | darwin | amd64 | `sample-darwin-amd64.dylib` |
| darwin | arm64 | `sample-darwin-arm64.dylib` | | darwin | arm64 | `sample-darwin-arm64.dylib` |
The server will **never** send an x64 binary to an ARM client (or vice versa). If no binary matches the client's platform, loading is skipped with an error. The server will **never** send an x64 native binary to an ARM client (or vice versa). If no native binary matches the client's platform, loading is skipped with an error.
### Manifest fields ### Manifest fields
@@ -77,13 +196,18 @@ The auto-generated manifest (merged with `config.json` if present):
{ {
"id": "sample", "id": "sample",
"name": "Sample Plugin", "name": "Sample Plugin",
"apiVersion": 2,
"runtime": "wasm",
"version": "1.0.0", "version": "1.0.0",
"description": "An example plugin", "description": "An example plugin",
"wasm": "sample.wasm",
"needs": {
"files": [
{ "bucket": "pluginData", "access": ["read", "write"], "reason": "Store plugin state" }
]
},
"binaries": { "binaries": {
"linux-amd64": "sample-linux-amd64.so", "wasm32-wasi": "sample.wasm"
"linux-arm64": "sample-linux-arm64.so",
"darwin-arm64": "sample-darwin-arm64.dylib",
"windows-amd64": "sample-windows-amd64.dll"
}, },
"entry": "sample.html", "entry": "sample.html",
"assets": { "assets": {
@@ -98,7 +222,7 @@ The auto-generated manifest (merged with `config.json` if present):
} }
``` ```
The `navbar` field is only present when a `config.json` is included in the bundle. The `navbar`, `needs`, `wasm`, and TypeScript entrypoint fields are present only when declared or inferred from the bundle.
### Global Nav Bar Plugins (`config.json`) ### Global Nav Bar Plugins (`config.json`)
@@ -125,9 +249,11 @@ When the server extracts the bundle:
> **Important:** `config.json` must be in the **root** of the zip (not inside a subfolder). The build scripts automatically include it if it exists at the plugin source root. The manifest is re-generated on every extraction (triggered when the zip is newer than `manifest.json`), so keep `config.json` in the zip to avoid losing the navbar registration on re-upload. > **Important:** `config.json` must be in the **root** of the zip (not inside a subfolder). The build scripts automatically include it if it exists at the plugin source root. The manifest is re-generated on every extraction (triggered when the zip is newer than `manifest.json`), so keep `config.json` in the zip to avoid losing the navbar registration on re-upload.
## 2) Plugin ABI (all languages) ## 2) Legacy native plugin ABI
Every plugin — regardless of language — must export **C-callable functions** with specific signatures. The ABI differs slightly between Windows and Unix. This section applies only to legacy v1 native agent plugins. Plugin 2.0 WASM modules use the `overlord_*` ABI described above.
Every native plugin must export **C-callable functions** with specific signatures. The ABI differs slightly between Windows and Unix.
### Required exports ### Required exports
@@ -199,7 +325,18 @@ The `hostInfo` buffer passed to `PluginOnLoad` is JSON:
- **Linux** — Shared libraries are loaded in memory via `memfd_create` + `dlopen` on `/proc/self/fd/`. No files touch disk. - **Linux** — Shared libraries are loaded in memory via `memfd_create` + `dlopen` on `/proc/self/fd/`. No files touch disk.
- **macOS** — Shared libraries are written to a temp file, `dlopen`'d, then the temp file is deleted. - **macOS** — Shared libraries are written to a temp file, `dlopen`'d, then the temp file is deleted.
## 3) Sample plugins by language ## 3) Sample plugins
Current Plugin 2.0 samples:
| Sample | Purpose |
|--------|---------|
| `sample-wasm` | C/WASI file bridge sample using `pluginData`, `downloads`, and `overlord_host_info`. |
| `sample-wasm-hostinfo` | TinyGo sample that queries HostInfo from a universal WASM module. |
| `sample-wasm-platform-note` | Rust sample that branches by `os`/`arch` inside one WASM module and writes a platform-specific note. |
| `sample-ts-fullstack` | TypeScript UI plus TypeScript server runtime with shared local modules. |
Legacy native samples remain available:
### Go (`sample-go/`) ### Go (`sample-go/`)
@@ -390,18 +527,20 @@ cargo build --release --target=x86_64-pc-windows-msvc
cargo build --release --target=aarch64-unknown-linux-gnu cargo build --release --target=aarch64-unknown-linux-gnu
``` ```
## 4) Build scripts & cross-compilation ## 4) Legacy native build scripts & cross-compilation
Each language has `.bat` (Windows) and `.sh` (Unix) build scripts. All scripts support the `BUILD_TARGETS` environment variable to compile for multiple platforms in one invocation. This section applies to legacy native v1 plugins. Plugin 2.0 WASM modules are universal and do not need one binary per OS/architecture, though you still need a WASM toolchain such as WASI clang, Rust `wasm32-wasip1`, Zig, TinyGo, or AssemblyScript.
Each legacy native sample keeps its `.bat` (Windows) and `.sh` (Unix) build scripts in the plugin's own directory. All scripts support the `BUILD_TARGETS` environment variable to compile for multiple platforms in one invocation.
### Available build scripts ### Available build scripts
| Language | Windows | Unix | | Language | Windows | Unix |
|----------|--------------------------|-------------------------| |----------|--------------------------|-------------------------|
| Go | `build-plugin.bat` | `build-plugin.sh` | | Go | `sample-go/build.bat` | `sample-go/build.sh` |
| C | `build-plugin-c.bat` | `build-plugin-c.sh` | | C | `sample-c/build.bat` | `sample-c/build.sh` |
| C++ | `build-plugin-cpp.bat` | `build-plugin-cpp.sh` | | C++ | `sample-cpp/build.bat` | `sample-cpp/build.sh` |
| Rust | `build-plugin-rust.bat` | `build-plugin-rust.sh` | | Rust | `sample-rust/build.bat` | `sample-rust/build.sh` |
### Usage ### Usage
@@ -409,25 +548,26 @@ All scripts default to building for the host platform only:
```bash ```bash
# Build for current platform # Build for current platform
./build-plugin-c.sh cd sample-c
./build.sh
# Build for multiple targets # Build for multiple targets
BUILD_TARGETS="linux-amd64 linux-arm64 windows-amd64" ./build-plugin-c.sh BUILD_TARGETS="linux-amd64 linux-arm64 windows-amd64" ./build.sh
# Custom plugin directory # Custom plugin directory
./build-plugin-c.sh /path/to/my-plugin ./build.sh /path/to/my-plugin
``` ```
```bat ```bat
REM Build for current platform REM Build for current platform
build-plugin-c.bat sample-c\build.bat
REM Build for multiple targets REM Build for multiple targets
set BUILD_TARGETS=windows-amd64 windows-arm64 set BUILD_TARGETS=windows-amd64 windows-arm64
build-plugin-c.bat sample-c\build.bat
REM Custom plugin directory REM Custom plugin directory
build-plugin-c.bat C:\path\to\my-plugin sample-c\build.bat C:\path\to\my-plugin
``` ```
### Cross-compilation ### Cross-compilation
@@ -460,8 +600,8 @@ On Windows `.bat` scripts, MSVC (`cl`) is tried first with the appropriate `/lin
On Unix `.sh` scripts, override the compiler with `CC=<compiler>` (C) or `CXX=<compiler>` (C++): On Unix `.sh` scripts, override the compiler with `CC=<compiler>` (C) or `CXX=<compiler>` (C++):
```bash ```bash
CC=zig-cc BUILD_TARGETS="linux-arm64" ./build-plugin-c.sh CC=zig-cc BUILD_TARGETS="linux-arm64" ./sample-c/build.sh
CXX=zig-c++ BUILD_TARGETS="linux-arm64" ./build-plugin-cpp.sh CXX=zig-c++ BUILD_TARGETS="linux-arm64" ./sample-cpp/build.sh
``` ```
### Installing cross-compiler toolchains ### Installing cross-compiler toolchains
@@ -487,6 +627,50 @@ brew install FiloSottile/musl-cross/musl-cross
rustup target add aarch64-apple-darwin x86_64-apple-darwin rustup target add aarch64-apple-darwin x86_64-apple-darwin
``` ```
### WASM build examples
Plugin 2.0 samples keep their Windows builders in their own plugin directories:
| Script | Purpose |
|--------|---------|
| `sample-wasm/build.bat` | Builds and packages `sample-wasm`. |
| `sample-wasm-hostinfo/build.bat` | Builds and packages `sample-wasm-hostinfo`. |
| `sample-wasm-platform-note/build.bat` | Builds and packages `sample-wasm-platform-note`. |
| `sample-ts-fullstack/build.bat` | Packages the TypeScript UI/server sample; the server compiles TypeScript during extraction. |
Usage:
```bat
REM Per-sample Plugin 2.0 build
sample-ts-fullstack\build.bat
sample-wasm\build.bat
```
The sample WASM plugins intentionally use different source languages:
| Sample | Source language | Toolchain |
|--------|-----------------|-----------|
| `sample-wasm` | C | WASI-capable `clang` |
| `sample-wasm-hostinfo` | Go | TinyGo 0.41.0+ with `-target=wasi` |
| `sample-wasm-platform-note` | Rust | Rust target `wasm32-wasip1` |
A minimal manual C/WASI build for `sample-wasm` looks like:
```bash
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 native/sample_wasm.c
```
The generated `.wasm` goes at the root of the plugin zip and is referenced by `config.json`.
For the TinyGo sample, install TinyGo 0.41.0 or newer and run `sample-wasm-hostinfo\build.bat`. For the Rust sample, install the target with `rustup target add wasm32-wasip1`, then run `sample-wasm-platform-note\build.bat`.
## 5) Install & open a plugin ## 5) Install & open a plugin
### Install / upload ### Install / upload
@@ -508,7 +692,7 @@ For **global nav bar plugins** (those registered via `config.json`), the UI open
/plugins/<pluginId> /plugins/<pluginId>
``` ```
The server reads your `<pluginId>.html`, extracts the body content, and renders it inside the standard Overlord layout. Your CSS and JS are loaded from `/plugins/<pluginId>/assets/`. The server reads your `<pluginId>.html`, extracts the body content, and renders it inside the standard Overlord layout. Your CSS and generated/copied JS are loaded from `/plugins/<pluginId>/assets/`.
To get the `clientId` in your JS (for per-client plugins): To get the `clientId` in your JS (for per-client plugins):
@@ -529,7 +713,9 @@ const onlineIds = items.filter(c => c.online).map(c => c.id);
Overlord plugins have **two parts**: Overlord plugins have **two parts**:
1. **UI (HTML/CSS/JS)** — Runs in the browser, calls server APIs. 1. **UI (HTML/CSS/JS)** — Runs in the browser, calls server APIs.
2. **Native module** — Runs in the agent (client) process as a loaded shared library. 2. **Optional agent module** — Plugin 2.0 uses WASM; legacy v1 uses native shared libraries.
Plugins can also include optional **server-side logic** through `server.js` or compiled `src/server.ts`. That code runs in the server plugin worker and exposes RPC/broadcast APIs to the UI.
### UI → agent (plugin event) ### UI → agent (plugin event)
@@ -545,13 +731,13 @@ POST /api/clients/<clientId>/plugins/<pluginId>/event
If the plugin is not loaded yet, the server will load it on the client, queue the event, and deliver it once ready. If the plugin is not loaded yet, the server will load it on the client, queue the event, and deliver it once ready.
### Agent → plugin (direct function call) ### Agent → plugin
The agent calls your `OnEvent(event, payload)` function directly with JSON-encoded data. No stdin/stdout pipes, no msgpack — just a direct function call. For WASM plugins, the agent calls `overlord_on_event(eventPtr, eventLen, payloadPtr, payloadLen)`. For legacy native plugins, the agent calls `PluginOnEvent(event, payload)`. In both cases the payload is JSON-encoded data from the UI/server.
### Plugin → agent (callback) ### Plugin → agent/server events
Your plugin sends events back to the host using the callback received during `OnLoad`: WASM plugins send events with `overlord_emit(event, eventLen, payload, payloadLen)`. Legacy native plugins use the callback received during `PluginOnLoad`.
**Go:** **Go:**
```go ```go
@@ -582,7 +768,16 @@ The agent sends these events to the server:
## 7) What can plugins do? ## 7) What can plugins do?
Since plugins run as native code, they can: WASM Plugin 2.0 agent modules are sandboxed. They can:
- Receive UI/server events.
- Send events back through `overlord_emit`.
- Read HostInfo (`clientId`, `os`, `arch`, `version`).
- Use declared file bridges after the user approves the plugin's `needs`.
WASM modules cannot directly access arbitrary files, processes, network sockets, or OS APIs unless Overlord exposes an explicit host bridge.
Legacy native v1 plugins run as native code and can:
- Call any system API (file I/O, network, processes, etc.) - Call any system API (file I/O, network, processes, etc.)
- Use any library available to their language - Use any library available to their language
@@ -590,7 +785,7 @@ Since plugins run as native code, they can:
- Access hardware - Access hardware
- Do anything a normal native program can do - Do anything a normal native program can do
Plugins have the same capabilities as the agent itself. Legacy native plugins have the same capabilities as the agent itself.
| Language | Can be fully unloaded? | Runtime overhead | | Language | Can be fully unloaded? | Runtime overhead |
|----------|----------------------|------------------| |----------|----------------------|------------------|
@@ -647,9 +842,10 @@ If you want defensive scoping anyway, wrap your plugin JS in an IIFE so top-leve
### Plugin management ### Plugin management
- `GET /api/plugins` — list installed plugins (includes `autoLoad` and `autoStartEvents` per plugin) - `GET /api/plugins` — list installed plugins, including `runtime`, `apiVersion`, `needs`, `needsHash`, `needsApproved`, `autoLoad`, and `autoStartEvents`
- `POST /api/plugins/upload` — upload zip - `POST /api/plugins/upload` — upload zip
- `POST /api/plugins/<id>/enable` — enable/disable - `POST /api/plugins/<id>/enable` — enable/disable
- `POST /api/plugins/<id>/needs/approve` — approve the currently declared Plugin 2.0 needs hash
- `POST /api/plugins/<id>/autoload` — configure auto-load on client connect - `POST /api/plugins/<id>/autoload` — configure auto-load on client connect
- `DELETE /api/plugins/<id>` — remove (preserves `data/` directory) - `DELETE /api/plugins/<id>` — remove (preserves `data/` directory)
@@ -775,10 +971,11 @@ Content-Type: application/json
### Notes ### Notes
- Auto-load respects the `enabled` flag — disabled plugins are never auto-loaded - Auto-load respects the `enabled` flag — disabled plugins are never auto-loaded
- Auto-load for plugins with declared `needs` is blocked until those needs are approved
- Auto-load state is persisted in `.plugin-state.json` and survives server restarts - Auto-load state is persisted in `.plugin-state.json` and survives server restarts
- Deleting a plugin also removes its auto-load configuration - Deleting a plugin also removes its auto-load configuration
- The server selects the correct binary for each client's OS/architecture automatically - WASM plugins use one universal module for every OS/architecture
- If a plugin binary isn't available for a client's platform, the auto-load silently skips that client - Legacy native plugins still require a compatible binary for each target platform; if one is unavailable, auto-load skips that client
## 10) Server-side plugin data directory ## 10) Server-side plugin data directory
@@ -1197,11 +1394,11 @@ Set the `PLUGIN_SIGN_KEY` environment variable to automatically sign plugins dur
```bash ```bash
# Unix # Unix
PLUGIN_SIGN_KEY=path/to/my-signing-key.key ./build-plugin.sh PLUGIN_SIGN_KEY=path/to/my-signing-key.key ./sample-go/build.sh
# Windows # Windows
set PLUGIN_SIGN_KEY=path\to\my-signing-key.key set PLUGIN_SIGN_KEY=path\to\my-signing-key.key
build-plugin.bat sample-go\build.bat
``` ```
### How Signing Works ### How Signing Works
@@ -1,8 +1,7 @@
@echo off @echo off
setlocal enabledelayedexpansion setlocal enabledelayedexpansion
set "ROOT=%~dp0" set "PLUGIN_DIR=%~dp0."
set "PLUGIN_DIR=%ROOT%sample-c"
if not "%~1"=="" set "PLUGIN_DIR=%~1" if not "%~1"=="" set "PLUGIN_DIR=%~1"
set "NATIVE_DIR=%PLUGIN_DIR%\native" set "NATIVE_DIR=%PLUGIN_DIR%\native"
@@ -2,7 +2,7 @@
set -euo pipefail set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PLUGIN_DIR="${1:-${ROOT_DIR}/sample-c}" PLUGIN_DIR="${1:-${ROOT_DIR}}"
NATIVE_DIR="${PLUGIN_DIR}/native" NATIVE_DIR="${PLUGIN_DIR}/native"
PLUGIN_NAME="sample-c" PLUGIN_NAME="sample-c"
ZIP_OUT="${PLUGIN_DIR}/${PLUGIN_NAME}.zip" ZIP_OUT="${PLUGIN_DIR}/${PLUGIN_NAME}.zip"
@@ -1,8 +1,7 @@
@echo off @echo off
setlocal enabledelayedexpansion setlocal enabledelayedexpansion
set "ROOT=%~dp0" set "PLUGIN_DIR=%~dp0."
set "PLUGIN_DIR=%ROOT%sample-cpp"
if not "%~1"=="" set "PLUGIN_DIR=%~1" if not "%~1"=="" set "PLUGIN_DIR=%~1"
set "NATIVE_DIR=%PLUGIN_DIR%\native" set "NATIVE_DIR=%PLUGIN_DIR%\native"
@@ -2,7 +2,7 @@
set -euo pipefail set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PLUGIN_DIR="${1:-${ROOT_DIR}/sample-cpp}" PLUGIN_DIR="${1:-${ROOT_DIR}}"
NATIVE_DIR="${PLUGIN_DIR}/native" NATIVE_DIR="${PLUGIN_DIR}/native"
PLUGIN_NAME="sample-cpp" PLUGIN_NAME="sample-cpp"
ZIP_OUT="${PLUGIN_DIR}/${PLUGIN_NAME}.zip" ZIP_OUT="${PLUGIN_DIR}/${PLUGIN_NAME}.zip"
+4 -3
View File
@@ -16,13 +16,14 @@ Use the provided build scripts:
```bash ```bash
# Linux/macOS # Linux/macOS
./build-plugin.sh cd sample-go
./build.sh
# Multiple targets # Multiple targets
BUILD_TARGETS="linux-amd64 linux-arm64 darwin-arm64" ./build-plugin.sh BUILD_TARGETS="linux-amd64 linux-arm64 darwin-arm64" ./build.sh
# Windows # Windows
build-plugin.bat sample-go\build.bat
``` ```
## Create the zip ## Create the zip
@@ -1,8 +1,7 @@
@echo off @echo off
setlocal enabledelayedexpansion setlocal enabledelayedexpansion
set "ROOT=%~dp0" set "PLUGIN_DIR=%~dp0."
set "PLUGIN_DIR=%ROOT%sample-go"
if not "%~1"=="" set "PLUGIN_DIR=%~1" if not "%~1"=="" set "PLUGIN_DIR=%~1"
set "NATIVE_DIR=%PLUGIN_DIR%\native" set "NATIVE_DIR=%PLUGIN_DIR%\native"
@@ -92,7 +91,7 @@ if defined PLUGIN_SIGN_KEY (
where bun >nul 2>&1 where bun >nul 2>&1
if not errorlevel 1 ( if not errorlevel 1 (
echo [sign] Signing plugin with key: %PLUGIN_SIGN_KEY% echo [sign] Signing plugin with key: %PLUGIN_SIGN_KEY%
bun run "%ROOT%Overlord-Server\scripts\plugin-sign.ts" --key "%PLUGIN_SIGN_KEY%" "%ZIP_OUT%" bun run "%~dp0..\..\Overlord-Server\scripts\plugin-sign.ts" --key "%PLUGIN_SIGN_KEY%" "%ZIP_OUT%"
) else ( ) else (
echo [warn] bun not found, skipping plugin signing echo [warn] bun not found, skipping plugin signing
) )
@@ -2,7 +2,7 @@
set -euo pipefail set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PLUGIN_DIR="${1:-${ROOT_DIR}/sample-go}" PLUGIN_DIR="${1:-${ROOT_DIR}}"
NATIVE_DIR="${PLUGIN_DIR}/native" NATIVE_DIR="${PLUGIN_DIR}/native"
PLUGIN_NAME="sample" PLUGIN_NAME="sample"
ZIP_OUT="${PLUGIN_DIR}/${PLUGIN_NAME}.zip" ZIP_OUT="${PLUGIN_DIR}/${PLUGIN_NAME}.zip"
@@ -69,10 +69,9 @@ fi
# Optional: sign the plugin if PLUGIN_SIGN_KEY is set # Optional: sign the plugin if PLUGIN_SIGN_KEY is set
if [[ -n "${PLUGIN_SIGN_KEY:-}" ]]; then if [[ -n "${PLUGIN_SIGN_KEY:-}" ]]; then
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if command -v bun >/dev/null 2>&1; then if command -v bun >/dev/null 2>&1; then
echo "[sign] Signing plugin with key: ${PLUGIN_SIGN_KEY}" echo "[sign] Signing plugin with key: ${PLUGIN_SIGN_KEY}"
bun run "${SCRIPT_DIR}/Overlord-Server/scripts/plugin-sign.ts" --key "${PLUGIN_SIGN_KEY}" "${ZIP_OUT}" bun run "${ROOT_DIR}/../../Overlord-Server/scripts/plugin-sign.ts" --key "${PLUGIN_SIGN_KEY}" "${ZIP_OUT}"
else else
echo "[warn] bun not found, skipping plugin signing" >&2 echo "[warn] bun not found, skipping plugin signing" >&2
fi fi
@@ -1,8 +1,7 @@
@echo off @echo off
setlocal enabledelayedexpansion setlocal enabledelayedexpansion
set "ROOT=%~dp0" set "PLUGIN_DIR=%~dp0."
set "PLUGIN_DIR=%ROOT%sample-rust"
if not "%~1"=="" set "PLUGIN_DIR=%~1" if not "%~1"=="" set "PLUGIN_DIR=%~1"
set "NATIVE_DIR=%PLUGIN_DIR%\native" set "NATIVE_DIR=%PLUGIN_DIR%\native"
@@ -2,7 +2,7 @@
set -euo pipefail set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PLUGIN_DIR="${1:-${ROOT_DIR}/sample-rust}" PLUGIN_DIR="${1:-${ROOT_DIR}}"
NATIVE_DIR="${PLUGIN_DIR}/native" NATIVE_DIR="${PLUGIN_DIR}/native"
PLUGIN_NAME="sample-rust" PLUGIN_NAME="sample-rust"
ZIP_OUT="${PLUGIN_DIR}/${PLUGIN_NAME}.zip" ZIP_OUT="${PLUGIN_DIR}/${PLUGIN_NAME}.zip"
+9
View File
@@ -0,0 +1,9 @@
# Sample TS Fullstack Plugin
This sample intentionally ships TypeScript source instead of generated JavaScript.
The server extracts the bundle, compiles `src/ui.ts` to `assets/sample-ts-fullstack.js`, and compiles `src/server.ts` to `server.js`. Both entrypoints import `src/shared.ts`, demonstrating that plugin authors can split logic across multiple TypeScript files.
No package install is performed during plugin extraction; use relative imports or APIs already available in the browser/server runtime.
On Windows, run this sample's local `build.bat` to create `sample-ts-fullstack.zip`. The batch file packages the TypeScript sources cleanly; compilation still happens on the server during extraction.
+60
View File
@@ -0,0 +1,60 @@
@echo off
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" (
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%
@@ -0,0 +1,3 @@
#sample-ts-output {
min-height: 9rem;
}
@@ -0,0 +1,11 @@
<div class="space-y-3">
<div>
<h2 class="text-lg font-semibold text-slate-100">Sample TS Fullstack</h2>
<p class="text-sm text-slate-400">Multiple TypeScript files compiled by the server at plugin extraction time.</p>
</div>
<div class="flex gap-2">
<button id="sample-ts-ping" class="px-3 py-2 rounded-lg border border-cyan-700 bg-cyan-950/40 text-cyan-100">Ping backend</button>
<button id="sample-ts-count" class="px-3 py-2 rounded-lg border border-emerald-700 bg-emerald-950/40 text-emerald-100">Increment</button>
</div>
<pre id="sample-ts-output" class="text-xs text-slate-300 whitespace-pre-wrap"></pre>
</div>
+41
View File
@@ -0,0 +1,41 @@
import type { RpcResponse } from "./shared";
type PluginContext = {
db: {
exec(sql: string): void;
prepare(sql: string): {
get(...args: unknown[]): any;
run(...args: unknown[]): any;
};
};
log: { info(message: string): void };
};
function now(): string {
return new Date().toISOString();
}
function response(message: string, count?: number): RpcResponse {
return { message, count, at: now() };
}
export default {
setup(ctx: PluginContext) {
ctx.db.exec("CREATE TABLE IF NOT EXISTS counter (id INTEGER PRIMARY KEY CHECK (id = 1), value INTEGER NOT NULL)");
ctx.db.prepare("INSERT OR IGNORE INTO counter (id, value) VALUES (1, 0)").run();
ctx.log.info("sample-ts-fullstack ready");
},
rpc: {
ping(): RpcResponse {
return response("hello from TypeScript server");
},
increment(ctx: PluginContext, params: unknown): RpcResponse {
const by = typeof (params as any)?.by === "number" ? Math.trunc((params as any).by) : 1;
ctx.db.prepare("UPDATE counter SET value = value + ? WHERE id = 1").run(by);
const row = ctx.db.prepare("SELECT value FROM counter WHERE id = 1").get();
return response("counter updated", Number(row?.value || 0));
},
},
};
+10
View File
@@ -0,0 +1,10 @@
export type RpcResponse = {
message: string;
count?: number;
at: string;
};
export function formatLine(label: string, value: RpcResponse): string {
const count = typeof value.count === "number" ? ` count=${value.count}` : "";
return `${label}: ${value.message}${count} at ${value.at}`;
}
+39
View File
@@ -0,0 +1,39 @@
import { formatLine, type RpcResponse } from "./shared";
const output = document.getElementById("sample-ts-output");
const pingButton = document.getElementById("sample-ts-ping");
const countButton = document.getElementById("sample-ts-count");
function append(message: string) {
if (!output) return;
output.textContent = `${message}\n${output.textContent || ""}`;
}
async function rpc(method: string, params?: unknown): Promise<RpcResponse> {
const res = await fetch("/api/plugins/sample-ts-fullstack/rpc", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ method, params }),
});
const body = await res.json();
if (!res.ok || body.ok === false) {
throw new Error(body.error || res.statusText);
}
return body.result as RpcResponse;
}
pingButton?.addEventListener("click", async () => {
try {
append(formatLine("ping", await rpc("ping")));
} catch (err) {
append(`ping failed: ${err instanceof Error ? err.message : String(err)}`);
}
});
countButton?.addEventListener("click", async () => {
try {
append(formatLine("increment", await rpc("increment", { by: 1 })));
} catch (err) {
append(`increment failed: ${err instanceof Error ? err.message : String(err)}`);
}
});
+5
View File
@@ -0,0 +1,5 @@
# 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
@@ -0,0 +1,55 @@
@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%
@@ -0,0 +1,93 @@
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() {}
@@ -0,0 +1,22 @@
#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;
}
@@ -0,0 +1,12 @@
<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>
@@ -0,0 +1,93 @@
(() => {
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);
}
})();
@@ -0,0 +1,11 @@
# Sample WASM Platform Note Plugin
This Plugin 2.0 sample is written in Rust. It reads HostInfo, branches on `os` and `arch`, and writes a small note into the approved `pluginData` bucket.
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`.
@@ -0,0 +1,76 @@
@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
@@ -0,0 +1,7 @@
# 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"
@@ -0,0 +1,8 @@
[package]
name = "sample_wasm_platform_note"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
path = "src/lib.rs"
@@ -0,0 +1,131 @@
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_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 response = format!(
r#"{{"os":"{os}","arch":"{arch}","mkdir":{mkdir_result},"write":{write_result}}}"#
);
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,
);
}
}
#[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
}
@@ -0,0 +1 @@
{"rustc_fingerprint":2568544898921049753,"outputs":{"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"},"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":""}},"successes":{}}
@@ -0,0 +1,3 @@
Signature: 8a477f597d28d172789f06886806bc55
# This file is a cache directory tag created by cargo.
# For information about cache directory tags see https://bford.info/cachedir/
@@ -0,0 +1,3 @@
Signature: 8a477f597d28d172789f06886806bc55
# This file is a cache directory tag created by cargo.
# For information about cache directory tags see https://bford.info/cachedir/
@@ -0,0 +1,22 @@
#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;
}
@@ -0,0 +1,12 @@
<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>
<pre id="sample-wasm-platform-note-output" class="text-xs text-slate-300 whitespace-pre-wrap"></pre>
</div>
@@ -0,0 +1,93 @@
(() => {
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"),
};
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 ?? "-");
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
@@ -0,0 +1,9 @@
# 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
@@ -0,0 +1,60 @@
@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
@@ -0,0 +1,69 @@
// 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
@@ -0,0 +1,3 @@
#sample-wasm-output {
min-height: 7rem;
}
+6
View File
@@ -0,0 +1,6 @@
<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
@@ -0,0 +1,22 @@
(() => {
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}`);
});
})();