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