new plugin system

This commit is contained in:
KingKDot
2026-03-19 16:29:26 -05:00
parent 2aeac06b1a
commit cf314b5189
31 changed files with 1942 additions and 517 deletions
+1
View File
@@ -46,6 +46,7 @@ Overlord-Server/plugins/*.zip
Overlord-Server/plugins/.plugin-state.json
plugin-sample-go/*.wasm
plugin-sample-go/*.zip
plugin-sample-go/*.h
# Runtime data
Overlord-Server/data/
@@ -486,7 +486,7 @@ func HandleCommand(ctx context.Context, env *runtime.Env, envelope map[string]in
return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{Type: "command_result", CommandID: cmdID, OK: false, Message: "missing payload"})
}
manifestRaw, _ := payload["manifest"].(map[string]interface{})
wasmBytes, _ := payload["wasm"].([]byte)
binaryBytes, _ := payload["binary"].([]byte)
if env.Plugins == nil {
return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{Type: "command_result", CommandID: cmdID, OK: false, Message: "plugin manager not ready"})
}
@@ -494,7 +494,7 @@ func HandleCommand(ctx context.Context, env *runtime.Env, envelope map[string]in
if err != nil {
return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{Type: "command_result", CommandID: cmdID, OK: false, Message: err.Error()})
}
if err := env.Plugins.Load(ctx, manifest, wasmBytes); err != nil {
if err := env.Plugins.Load(ctx, manifest, binaryBytes); err != nil {
_ = wire.WriteMsg(ctx, env.Conn, wire.PluginEvent{Type: "plugin_event", PluginID: manifest.ID, Event: "error", Error: err.Error()})
return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{Type: "command_result", CommandID: cmdID, OK: false, Message: err.Error()})
}
@@ -0,0 +1,207 @@
//go:build darwin && cgo
package plugins
/*
#include <dlfcn.h>
#include <stdint.h>
#include <stdlib.h>
static void* so_dlopen(const char* path, char** errOut) {
void* h = dlopen(path, RTLD_NOW | RTLD_LOCAL);
if (!h && errOut) *errOut = (char*)dlerror();
return h;
}
static void* so_dlsym(void* h, const char* name, char** errOut) {
void* s = dlsym(h, name);
if (!s && errOut) *errOut = (char*)dlerror();
return s;
}
static int so_dlclose(void* h) {
if (h) return dlclose(h);
return 0;
}
// Go callback bridge — forward-declared here, implemented via //export below.
extern void goPluginHostCallback(
uintptr_t ctx,
const char* event, int eventLen,
const char* payload, int payloadLen);
// Call the plugin's PluginOnLoad, injecting our callback bridge + context.
static int so_call_onload(void* fn, const char* info, int infoLen, uintptr_t ctx) {
typedef int (*fn_t)(const char*, int, uintptr_t, uintptr_t);
return ((fn_t)fn)(info, infoLen, (uintptr_t)goPluginHostCallback, ctx);
}
static int so_call_onevent(void* fn, const char* ev, int evLen, const char* pl, int plLen) {
typedef int (*fn_t)(const char*, int, const char*, int);
return ((fn_t)fn)(ev, evLen, pl, plLen);
}
static void so_call_onunload(void* fn) {
typedef void (*fn_t)(void);
((fn_t)fn)();
}
*/
import "C"
import (
"errors"
"fmt"
"os"
"runtime/cgo"
"sync"
"unsafe"
)
//export goPluginHostCallback
func goPluginHostCallback(ctx C.uintptr_t, event *C.char, eventLen C.int, payload *C.char, payloadLen C.int) {
h := cgo.Handle(ctx)
send := h.Value().(func(string, []byte))
ev := C.GoStringN(event, eventLen)
var pl []byte
if payloadLen > 0 {
pl = C.GoBytes(unsafe.Pointer(payload), payloadLen)
}
send(ev, pl)
}
func loadNativePlugin(data []byte) (NativePlugin, error) {
if len(data) == 0 {
return nil, errors.New("empty plugin binary")
}
f, err := os.CreateTemp("", "*.dylib")
if err != nil {
return nil, fmt.Errorf("create temp: %w", err)
}
tmpPath := f.Name()
if _, err := f.Write(data); err != nil {
f.Close()
os.Remove(tmpPath)
return nil, fmt.Errorf("write temp: %w", err)
}
f.Close()
cPath := C.CString(tmpPath)
defer C.free(unsafe.Pointer(cPath))
var cErr *C.char
handle := C.so_dlopen(cPath, &cErr)
os.Remove(tmpPath)
if handle == nil {
return nil, fmt.Errorf("dlopen: %s", C.GoString(cErr))
}
resolve := func(name string) (unsafe.Pointer, error) {
cName := C.CString(name)
defer C.free(unsafe.Pointer(cName))
var cErr *C.char
s := C.so_dlsym(handle, cName, &cErr)
if s == nil {
return nil, fmt.Errorf("dlsym(%s): %s", name, C.GoString(cErr))
}
return s, nil
}
onLoad, err := resolve("PluginOnLoad")
if err != nil {
C.so_dlclose(handle)
return nil, err
}
onEvent, err := resolve("PluginOnEvent")
if err != nil {
C.so_dlclose(handle)
return nil, err
}
onUnload, err := resolve("PluginOnUnload")
if err != nil {
C.so_dlclose(handle)
return nil, err
}
return &soPlugin{
handle: handle,
onLoadFn: onLoad,
onEventFn: onEvent,
onUnloadFn: onUnload,
}, nil
}
type soPlugin struct {
handle unsafe.Pointer // dlopen handle
onLoadFn unsafe.Pointer
onEventFn unsafe.Pointer
onUnloadFn unsafe.Pointer
cbHandle cgo.Handle
mu sync.Mutex
}
func (p *soPlugin) Load(send func(string, []byte), hostInfo []byte) error {
p.mu.Lock()
defer p.mu.Unlock()
p.cbHandle = cgo.NewHandle(send)
var infoPtr *C.char
if len(hostInfo) > 0 {
infoPtr = (*C.char)(unsafe.Pointer(&hostInfo[0]))
}
ret := C.so_call_onload(p.onLoadFn, infoPtr, C.int(len(hostInfo)), C.uintptr_t(p.cbHandle))
if ret != 0 {
p.cbHandle.Delete()
p.cbHandle = 0
return errors.New("PluginOnLoad returned error")
}
return nil
}
func (p *soPlugin) Event(event string, payload []byte) error {
p.mu.Lock()
defer p.mu.Unlock()
evBytes := []byte(event)
var evPtr, plPtr *C.char
if len(evBytes) > 0 {
evPtr = (*C.char)(unsafe.Pointer(&evBytes[0]))
}
if len(payload) > 0 {
plPtr = (*C.char)(unsafe.Pointer(&payload[0]))
}
ret := C.so_call_onevent(p.onEventFn, evPtr, C.int(len(evBytes)), plPtr, C.int(len(payload)))
if ret != 0 {
return errors.New("PluginOnEvent returned error")
}
return nil
}
func (p *soPlugin) Unload() {
p.mu.Lock()
defer p.mu.Unlock()
if p.onUnloadFn != nil {
C.so_call_onunload(p.onUnloadFn)
p.onUnloadFn = nil
}
}
func (p *soPlugin) Close() error {
p.Unload()
p.mu.Lock()
defer p.mu.Unlock()
if p.cbHandle != 0 {
p.cbHandle.Delete()
p.cbHandle = 0
}
if p.handle != nil {
C.so_dlclose(p.handle)
p.handle = nil
}
return nil
}
@@ -0,0 +1,227 @@
//go:build linux && cgo
package plugins
/*
#cgo LDFLAGS: -ldl
#include <dlfcn.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/syscall.h>
#ifndef MFD_CLOEXEC
#define MFD_CLOEXEC 0x0001U
#endif
// memfd_create wrapper — creates an anonymous in-memory file descriptor.
static int so_memfd_create(void) {
return (int)syscall(SYS_memfd_create, "plugin", MFD_CLOEXEC);
}
// Write entire buffer to fd. Returns 0 on success, -1 on error.
static int so_write_all(int fd, const void* buf, size_t len) {
const char* p = (const char*)buf;
while (len > 0) {
ssize_t n = write(fd, p, len);
if (n <= 0) return -1;
p += n;
len -= (size_t)n;
}
return 0;
}
// Load a shared library directly from an in-memory file descriptor.
static void* so_open_fd(int fd, char** errOut) {
char path[64];
snprintf(path, sizeof(path), "/proc/self/fd/%d", fd);
void* h = dlopen(path, RTLD_NOW | RTLD_LOCAL);
if (!h && errOut) *errOut = (char*)dlerror();
return h;
}
static void* so_dlsym(void* h, const char* name, char** errOut) {
void* s = dlsym(h, name);
if (!s && errOut) *errOut = (char*)dlerror();
return s;
}
static int so_dlclose(void* h) {
if (h) return dlclose(h);
return 0;
}
// Go callback bridge — forward-declared here, implemented via //export below.
extern void goPluginHostCallback(
uintptr_t ctx,
const char* event, int eventLen,
const char* payload, int payloadLen);
// Call the plugin's PluginOnLoad, injecting our callback bridge + context.
static int so_call_onload(void* fn, const char* info, int infoLen, uintptr_t ctx) {
typedef int (*fn_t)(const char*, int, uintptr_t, uintptr_t);
return ((fn_t)fn)(info, infoLen, (uintptr_t)goPluginHostCallback, ctx);
}
static int so_call_onevent(void* fn, const char* ev, int evLen, const char* pl, int plLen) {
typedef int (*fn_t)(const char*, int, const char*, int);
return ((fn_t)fn)(ev, evLen, pl, plLen);
}
static void so_call_onunload(void* fn) {
typedef void (*fn_t)(void);
((fn_t)fn)();
}
*/
import "C"
import (
"errors"
"fmt"
"runtime/cgo"
"sync"
"unsafe"
)
//export goPluginHostCallback
func goPluginHostCallback(ctx C.uintptr_t, event *C.char, eventLen C.int, payload *C.char, payloadLen C.int) {
h := cgo.Handle(ctx)
send := h.Value().(func(string, []byte))
ev := C.GoStringN(event, eventLen)
var pl []byte
if payloadLen > 0 {
pl = C.GoBytes(unsafe.Pointer(payload), payloadLen)
}
send(ev, pl)
}
func loadNativePlugin(data []byte) (NativePlugin, error) {
if len(data) == 0 {
return nil, errors.New("empty plugin binary")
}
fd := C.so_memfd_create()
if fd < 0 {
return nil, errors.New("memfd_create failed")
}
if C.so_write_all(fd, unsafe.Pointer(&data[0]), C.size_t(len(data))) != 0 {
C.close(fd)
return nil, errors.New("write to memfd failed")
}
var cErr *C.char
handle := C.so_open_fd(fd, &cErr)
C.close(fd)
if handle == nil {
return nil, fmt.Errorf("dlopen: %s", C.GoString(cErr))
}
resolve := func(name string) (unsafe.Pointer, error) {
cName := C.CString(name)
defer C.free(unsafe.Pointer(cName))
var cErr *C.char
s := C.so_dlsym(handle, cName, &cErr)
if s == nil {
return nil, fmt.Errorf("dlsym(%s): %s", name, C.GoString(cErr))
}
return s, nil
}
onLoad, err := resolve("PluginOnLoad")
if err != nil {
C.so_dlclose(handle)
return nil, err
}
onEvent, err := resolve("PluginOnEvent")
if err != nil {
C.so_dlclose(handle)
return nil, err
}
onUnload, err := resolve("PluginOnUnload")
if err != nil {
C.so_dlclose(handle)
return nil, err
}
return &soPlugin{
handle: handle,
onLoadFn: onLoad,
onEventFn: onEvent,
onUnloadFn: onUnload,
}, nil
}
type soPlugin struct {
handle unsafe.Pointer
onLoadFn unsafe.Pointer
onEventFn unsafe.Pointer
onUnloadFn unsafe.Pointer
cbHandle cgo.Handle
mu sync.Mutex
}
func (p *soPlugin) Load(send func(string, []byte), hostInfo []byte) error {
p.mu.Lock()
defer p.mu.Unlock()
p.cbHandle = cgo.NewHandle(send)
var infoPtr *C.char
if len(hostInfo) > 0 {
infoPtr = (*C.char)(unsafe.Pointer(&hostInfo[0]))
}
ret := C.so_call_onload(p.onLoadFn, infoPtr, C.int(len(hostInfo)), C.uintptr_t(p.cbHandle))
if ret != 0 {
p.cbHandle.Delete()
p.cbHandle = 0
return errors.New("PluginOnLoad returned error")
}
return nil
}
func (p *soPlugin) Event(event string, payload []byte) error {
p.mu.Lock()
defer p.mu.Unlock()
evBytes := []byte(event)
var evPtr, plPtr *C.char
if len(evBytes) > 0 {
evPtr = (*C.char)(unsafe.Pointer(&evBytes[0]))
}
if len(payload) > 0 {
plPtr = (*C.char)(unsafe.Pointer(&payload[0]))
}
ret := C.so_call_onevent(p.onEventFn, evPtr, C.int(len(evBytes)), plPtr, C.int(len(payload)))
if ret != 0 {
return errors.New("PluginOnEvent returned error")
}
return nil
}
func (p *soPlugin) Unload() {
p.mu.Lock()
defer p.mu.Unlock()
if p.onUnloadFn != nil {
C.so_call_onunload(p.onUnloadFn)
p.onUnloadFn = nil
}
}
func (p *soPlugin) Close() error {
p.Unload()
p.mu.Lock()
defer p.mu.Unlock()
if p.cbHandle != 0 {
p.cbHandle.Delete()
p.cbHandle = 0
}
if p.handle != nil {
C.so_dlclose(p.handle)
p.handle = nil
}
return nil
}
@@ -0,0 +1,9 @@
//go:build !(linux && cgo) && !(darwin && cgo) && !windows
package plugins
import "errors"
func loadNativePlugin(data []byte) (NativePlugin, error) {
return nil, errors.New("native plugins not supported on this platform (requires cgo on linux/darwin, or windows)")
}
@@ -0,0 +1,138 @@
//go:build windows
package plugins
import (
"errors"
"fmt"
"sync"
"syscall"
"unsafe"
)
func loadNativePlugin(data []byte) (NativePlugin, error) {
if len(data) == 0 {
return nil, errors.New("empty plugin binary")
}
mm, err := LoadMemoryModule(data)
if err != nil {
return nil, fmt.Errorf("pe load: %w", err)
}
if err := mm.CallEntryPoint(dllProcessAttach); err != nil {
mm.Free()
return nil, fmt.Errorf("DllMain init: %w", err)
}
onLoad, err := mm.GetExport("PluginOnLoad")
if err != nil {
mm.Free()
return nil, err
}
onEvent, err := mm.GetExport("PluginOnEvent")
if err != nil {
mm.Free()
return nil, err
}
onUnload, err := mm.GetExport("PluginOnUnload")
if err != nil {
mm.Free()
return nil, err
}
setCallback, _ := mm.GetExport("PluginSetCallback")
dp := &dllPlugin{
mem: mm,
onLoadAddr: onLoad,
onEventAddr: onEvent,
onUnloadAddr: onUnload,
setCallbackAddr: setCallback,
}
return dp, nil
}
type dllPlugin struct {
mem *MemoryModule
onLoadAddr uintptr
onEventAddr uintptr
onUnloadAddr uintptr
setCallbackAddr uintptr
callbackHandle uintptr // prevent GC of the callback closure
mu sync.Mutex
}
func (p *dllPlugin) Load(send func(string, []byte), hostInfo []byte) error {
p.mu.Lock()
defer p.mu.Unlock()
// Create a stdcall callback the DLL can invoke to send events to the host.
cb := syscall.NewCallback(func(eventPtr, eventLen, payloadPtr, payloadLen uintptr) uintptr {
event := make([]byte, eventLen)
if eventLen > 0 {
copy(event, unsafe.Slice((*byte)(unsafe.Pointer(eventPtr)), eventLen))
}
payload := make([]byte, payloadLen)
if payloadLen > 0 {
copy(payload, unsafe.Slice((*byte)(unsafe.Pointer(payloadPtr)), payloadLen))
}
send(string(event), payload)
return 0
})
p.callbackHandle = cb
if p.setCallbackAddr != 0 {
syscall.SyscallN(p.setCallbackAddr, cb)
}
var infoPtr uintptr
infoLen := uintptr(len(hostInfo))
if len(hostInfo) > 0 {
infoPtr = uintptr(unsafe.Pointer(&hostInfo[0]))
}
ret, _, _ := syscall.SyscallN(p.onLoadAddr, infoPtr, infoLen, cb)
if int32(ret) != 0 {
return errors.New("PluginOnLoad returned non-zero")
}
return nil
}
func (p *dllPlugin) Event(event string, payload []byte) error {
p.mu.Lock()
defer p.mu.Unlock()
eventBytes := []byte(event)
var eventPtr, payloadPtr uintptr
eventLen := uintptr(len(eventBytes))
payloadLen := uintptr(len(payload))
if len(eventBytes) > 0 {
eventPtr = uintptr(unsafe.Pointer(&eventBytes[0]))
}
if len(payload) > 0 {
payloadPtr = uintptr(unsafe.Pointer(&payload[0]))
}
ret, _, _ := syscall.SyscallN(p.onEventAddr, eventPtr, eventLen, payloadPtr, payloadLen)
if int32(ret) != 0 {
return errors.New("PluginOnEvent returned non-zero")
}
return nil
}
func (p *dllPlugin) Unload() {
p.mu.Lock()
defer p.mu.Unlock()
if p.onUnloadAddr != 0 {
syscall.SyscallN(p.onUnloadAddr)
}
}
func (p *dllPlugin) Close() error {
p.Unload()
if p.mem != nil {
p.mem.Free()
p.mem = nil
}
return nil
}
+42 -168
View File
@@ -2,16 +2,11 @@ package plugins
import (
"context"
"encoding/json"
"errors"
"io"
"log"
"sync"
"github.com/tetratelabs/wazero"
"github.com/tetratelabs/wazero/api"
"github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1"
"github.com/vmihailenco/msgpack/v5"
"overlord-client/cmd/agent/wire"
)
@@ -26,16 +21,7 @@ type Manager struct {
type pluginInstance struct {
id string
manifest PluginManifest
ctx context.Context
cancel context.CancelFunc
runtime wazero.Runtime
module api.Module
stdin io.WriteCloser
stdout io.ReadCloser
stderr io.ReadCloser
enc *msgpack.Encoder
dec *msgpack.Decoder
encMu sync.Mutex
native NativePlugin
}
type pendingBundle struct {
@@ -135,9 +121,9 @@ func (m *Manager) FinalizeBundle(ctx context.Context, pluginId string) error {
return m.Load(ctx, manifest, combined)
}
func (m *Manager) Load(ctx context.Context, manifest PluginManifest, wasm []byte) error {
if len(wasm) == 0 {
return errors.New("empty wasm payload")
func (m *Manager) Load(ctx context.Context, manifest PluginManifest, binary []byte) error {
if len(binary) == 0 {
return errors.New("empty plugin binary")
}
pluginID := manifest.ID
if pluginID == "" {
@@ -146,71 +132,51 @@ func (m *Manager) Load(ctx context.Context, manifest PluginManifest, wasm []byte
m.mu.Lock()
if existing, ok := m.plugins[pluginID]; ok {
_ = existing.Close()
existing.native.Close()
delete(m.plugins, pluginID)
}
m.mu.Unlock()
runtimeCtx, cancel := context.WithCancel(ctx)
rt := wazero.NewRuntime(runtimeCtx)
if _, err := wasi_snapshot_preview1.Instantiate(runtimeCtx, rt); err != nil {
cancel()
return err
}
stdinR, stdinW := io.Pipe()
stdoutR, stdoutW := io.Pipe()
stderrR, stderrW := io.Pipe()
config := wazero.NewModuleConfig().
WithStdin(stdinR).
WithStdout(stdoutW).
WithStderr(stderrW).
WithStartFunctions()
compiled, err := rt.CompileModule(runtimeCtx, wasm)
np, err := loadNativePlugin(binary)
if err != nil {
cancel()
return err
}
module, err := rt.InstantiateModule(runtimeCtx, compiled, config)
if err != nil {
cancel()
return err
}
pi := &pluginInstance{
id: pluginID,
manifest: manifest,
ctx: runtimeCtx,
cancel: cancel,
runtime: rt,
module: module,
stdin: stdinW,
stdout: stdoutR,
stderr: stderrR,
enc: msgpack.NewEncoder(stdinW),
dec: msgpack.NewDecoder(stdoutR),
native: np,
}
goSafe("plugin readLoop", pi.cancel, func() {
m.readLoop(pi)
})
goSafe("plugin readStderr", pi.cancel, func() {
m.readStderr(pi)
})
if start := module.ExportedFunction("_start"); start != nil {
goSafe("plugin _start", pi.cancel, func() {
if _, err := start.Call(runtimeCtx); err != nil {
log.Printf("[plugin] %s _start error: %v", pluginID, err)
send := func(event string, payload []byte) {
var payloadVal interface{}
if len(payload) > 0 {
var parsed interface{}
if json.Unmarshal(payload, &parsed) == nil {
payloadVal = parsed
} else {
payloadVal = string(payload)
}
}
err := wire.WriteMsg(context.Background(), m.writer, wire.PluginEvent{
Type: "plugin_event",
PluginID: pluginID,
Event: event,
Payload: payloadVal,
})
if err != nil {
log.Printf("[plugin] %s send event error: %v", pluginID, err)
}
}
if err := m.sendInit(pi); err != nil {
_ = pi.Close()
hostJSON, err := json.Marshal(m.host)
if err != nil {
np.Close()
return err
}
if err := np.Load(send, hostJSON); err != nil {
np.Close()
return err
}
@@ -218,7 +184,7 @@ func (m *Manager) Load(ctx context.Context, manifest PluginManifest, wasm []byte
m.plugins[pluginID] = pi
m.mu.Unlock()
log.Printf("[plugin] loaded %s", pluginID)
log.Printf("[plugin] loaded %s (native)", pluginID)
return nil
}
@@ -229,15 +195,19 @@ func (m *Manager) Dispatch(ctx context.Context, pluginId, event string, payload
if pi == nil {
return nil
}
msg := PluginMessage{Type: "event", Event: event, Payload: payload}
return m.sendMessage(ctx, pi, msg)
data, err := json.Marshal(payload)
if err != nil {
return err
}
return pi.native.Event(event, data)
}
func (m *Manager) Close() {
m.mu.Lock()
defer m.mu.Unlock()
for id, pi := range m.plugins {
_ = pi.Close()
pi.native.Close()
delete(m.plugins, id)
}
}
@@ -246,103 +216,7 @@ func (m *Manager) Unload(pluginId string) {
m.mu.Lock()
defer m.mu.Unlock()
if pi, ok := m.plugins[pluginId]; ok {
_ = pi.Close()
pi.native.Close()
delete(m.plugins, pluginId)
}
}
func (m *Manager) sendInit(pi *pluginInstance) error {
payload := map[string]interface{}{
"manifest": pi.manifest,
"host": m.host,
}
msg := PluginMessage{Type: "init", Payload: payload}
return m.sendMessage(context.Background(), pi, msg)
}
func (m *Manager) sendMessage(ctx context.Context, pi *pluginInstance, msg PluginMessage) error {
pi.encMu.Lock()
defer pi.encMu.Unlock()
select {
case <-ctx.Done():
return ctx.Err()
default:
return pi.enc.Encode(msg)
}
}
func (m *Manager) readLoop(pi *pluginInstance) {
defer func() {
if r := recover(); r != nil {
log.Printf("[plugin] %s read loop panic: %v", pi.id, r)
}
}()
for {
var msg map[string]interface{}
if err := pi.dec.Decode(&msg); err != nil {
if errors.Is(err, io.EOF) || errors.Is(err, context.Canceled) {
return
}
log.Printf("[plugin] %s read error: %v", pi.id, err)
return
}
m.handlePluginMessage(pi.id, msg)
}
}
func (m *Manager) readStderr(pi *pluginInstance) {
defer func() {
if r := recover(); r != nil {
log.Printf("[plugin] %s stderr loop panic: %v", pi.id, r)
}
}()
buf := make([]byte, 4096)
for {
n, err := pi.stderr.Read(buf)
if n > 0 {
log.Printf("[plugin] %s stderr: %s", pi.id, string(buf[:n]))
}
if err != nil {
return
}
}
}
func (m *Manager) handlePluginMessage(pluginId string, msg map[string]interface{}) {
msgType, _ := msg["type"].(string)
if msgType == "log" {
log.Printf("[plugin] %s log: %v", pluginId, msg["payload"])
return
}
if msgType == "event" {
event, _ := msg["event"].(string)
payload := msg["payload"]
if event == "" {
return
}
err := wire.WriteMsg(context.Background(), m.writer, wire.PluginEvent{
Type: "plugin_event",
PluginID: pluginId,
Event: event,
Payload: payload,
})
if err != nil {
log.Printf("[plugin] %s send event error: %v", pluginId, err)
}
return
}
}
func (pi *pluginInstance) Close() error {
pi.cancel()
_ = pi.stdin.Close()
_ = pi.stdout.Close()
_ = pi.stderr.Close()
if pi.module != nil {
_ = pi.module.Close(context.Background())
}
if pi.runtime != nil {
_ = pi.runtime.Close(context.Background())
}
return nil
}
@@ -0,0 +1,524 @@
//go:build windows
package plugins
import (
"errors"
"fmt"
"syscall"
"unsafe"
"golang.org/x/sys/windows"
)
const (
imageDOSSignature = 0x5A4D // "MZ"
imageNTSignature = 0x00004550 // "PE\0\0"
imageOptionalHdrMagicPE32Plus = 0x20B
imageDirectoryEntryExport = 0
imageDirectoryEntryImport = 1
imageDirectoryEntryBaseReloc = 5
imageDirectoryEntryTLS = 9
imageRelBasedDir64 = 10
imageRelBasedHighLow = 3
imageRelBasedAbsolute = 0
imageSCNMemExecute = 0x20000000
imageSCNMemRead = 0x40000000
imageSCNMemWrite = 0x80000000
dllProcessAttach = 1
dllProcessDetach = 0
)
type imageDOSHeader struct {
Magic uint16
_ [28]uint16
LfaNew int32
}
type imageFileHeader struct {
Machine uint16
NumberOfSections uint16
TimeDateStamp uint32
PointerToSymbolTable uint32
NumberOfSymbols uint32
SizeOfOptionalHeader uint16
Characteristics uint16
}
type imageDataDirectory struct {
VirtualAddress uint32
Size uint32
}
type imageOptionalHeader64 struct {
Magic uint16
_ [14]byte
AddressOfEntryPoint uint32
_ [4]byte
ImageBase uint64
SectionAlignment uint32
FileAlignment uint32
_ [16]byte
SizeOfImage uint32
SizeOfHeaders uint32
_ [4]byte
_ [4]byte // Subsystem + DllCharacteristics
_ [36]byte
NumberOfRvaAndSizes uint32
DataDirectory [16]imageDataDirectory
}
type imageNTHeaders64 struct {
Signature uint32
FileHeader imageFileHeader
OptionalHeader imageOptionalHeader64
}
type imageSectionHeader struct {
Name [8]byte
VirtualSize uint32
VirtualAddress uint32
SizeOfRawData uint32
PointerToRawData uint32
_ [12]byte
Characteristics uint32
}
type imageImportDescriptor struct {
OriginalFirstThunk uint32
TimeDateStamp uint32
ForwarderChain uint32
Name uint32
FirstThunk uint32
}
type imageBaseRelocation struct {
VirtualAddress uint32
SizeOfBlock uint32
}
type imageExportDirectory struct {
_ [12]byte
Name uint32
Base uint32
NumberOfFunctions uint32
NumberOfNames uint32
AddressOfFunctions uint32
AddressOfNames uint32
AddressOfNameOrdinals uint32
}
type imageTLSDirectory64 struct {
StartAddressOfRawData uint64
EndAddressOfRawData uint64
AddressOfIndex uint64
AddressOfCallBacks uint64
SizeOfZeroFill uint32
Characteristics uint32
}
type MemoryModule struct {
base uintptr
size uintptr
entryPoint uintptr
exports map[string]uintptr
importDLLs []windows.Handle
initialized bool
tlsIndex uint32
tlsData uintptr
}
func LoadMemoryModule(data []byte) (*MemoryModule, error) {
if len(data) < int(unsafe.Sizeof(imageDOSHeader{})) {
return nil, errors.New("pe: data too small for DOS header")
}
dosHdr := (*imageDOSHeader)(unsafe.Pointer(&data[0]))
if dosHdr.Magic != imageDOSSignature {
return nil, errors.New("pe: invalid DOS signature")
}
ntOffset := int(dosHdr.LfaNew)
if ntOffset < 0 || ntOffset+int(unsafe.Sizeof(imageNTHeaders64{})) > len(data) {
return nil, errors.New("pe: NT headers out of bounds")
}
ntHdr := (*imageNTHeaders64)(unsafe.Pointer(&data[ntOffset]))
if ntHdr.Signature != imageNTSignature {
return nil, errors.New("pe: invalid NT signature")
}
if ntHdr.OptionalHeader.Magic != imageOptionalHdrMagicPE32Plus {
return nil, errors.New("pe: only PE32+ (64-bit) is supported")
}
imageSize := uintptr(ntHdr.OptionalHeader.SizeOfImage)
preferredBase := uintptr(ntHdr.OptionalHeader.ImageBase)
base, err := windows.VirtualAlloc(preferredBase, imageSize, windows.MEM_RESERVE|windows.MEM_COMMIT, windows.PAGE_READWRITE)
if err != nil || base == 0 {
base, err = windows.VirtualAlloc(0, imageSize, windows.MEM_RESERVE|windows.MEM_COMMIT, windows.PAGE_READWRITE)
if err != nil {
return nil, fmt.Errorf("pe: VirtualAlloc failed: %w", err)
}
}
mm := &MemoryModule{
base: base,
size: imageSize,
exports: make(map[string]uintptr),
tlsIndex: 0xFFFFFFFF,
}
headerSize := uintptr(ntHdr.OptionalHeader.SizeOfHeaders)
if headerSize > uintptr(len(data)) {
headerSize = uintptr(len(data))
}
copyMem(base, &data[0], headerSize)
sectionStart := ntOffset + int(unsafe.Sizeof(ntHdr.Signature)) +
int(unsafe.Sizeof(ntHdr.FileHeader)) +
int(ntHdr.FileHeader.SizeOfOptionalHeader)
for i := 0; i < int(ntHdr.FileHeader.NumberOfSections); i++ {
off := sectionStart + i*int(unsafe.Sizeof(imageSectionHeader{}))
if off+int(unsafe.Sizeof(imageSectionHeader{})) > len(data) {
mm.Free()
return nil, errors.New("pe: section header out of bounds")
}
sec := (*imageSectionHeader)(unsafe.Pointer(&data[off]))
if sec.SizeOfRawData > 0 {
rawOff := int(sec.PointerToRawData)
rawEnd := rawOff + int(sec.SizeOfRawData)
if rawEnd > len(data) {
mm.Free()
return nil, errors.New("pe: section raw data out of bounds")
}
copySize := uintptr(sec.SizeOfRawData)
if sec.VirtualSize > 0 && uintptr(sec.VirtualSize) < copySize {
copySize = uintptr(sec.VirtualSize)
}
dest := uintptr(sec.VirtualAddress)
if dest+copySize > imageSize {
if dest >= imageSize {
continue
}
copySize = imageSize - dest
}
copyMem(base+dest, &data[rawOff], copySize)
}
}
mappedNT := (*imageNTHeaders64)(unsafe.Pointer(base + uintptr(dosHdr.LfaNew)))
delta := int64(base) - int64(mappedNT.OptionalHeader.ImageBase)
if delta != 0 {
relocDir := mappedNT.OptionalHeader.DataDirectory[imageDirectoryEntryBaseReloc]
if relocDir.VirtualAddress != 0 && relocDir.Size != 0 {
if err := mm.processRelocations(relocDir, delta); err != nil {
mm.Free()
return nil, err
}
}
}
importDir := mappedNT.OptionalHeader.DataDirectory[imageDirectoryEntryImport]
if importDir.VirtualAddress != 0 && importDir.Size != 0 {
if err := mm.resolveImports(importDir); err != nil {
mm.Free()
return nil, err
}
}
for i := 0; i < int(ntHdr.FileHeader.NumberOfSections); i++ {
off := sectionStart + i*int(unsafe.Sizeof(imageSectionHeader{}))
sec := (*imageSectionHeader)(unsafe.Pointer(&data[off]))
prot := sectionProtection(sec.Characteristics)
size := uintptr(sec.VirtualSize)
if size == 0 {
size = uintptr(sec.SizeOfRawData)
}
if size == 0 {
continue
}
var oldProt uint32
_ = windows.VirtualProtect(base+uintptr(sec.VirtualAddress), size, prot, &oldProt)
}
mm.setupTLS(dllProcessAttach)
if mappedNT.OptionalHeader.AddressOfEntryPoint != 0 {
mm.entryPoint = base + uintptr(mappedNT.OptionalHeader.AddressOfEntryPoint)
}
exportDir := mappedNT.OptionalHeader.DataDirectory[imageDirectoryEntryExport]
if exportDir.VirtualAddress != 0 && exportDir.Size != 0 {
mm.parseExports(exportDir)
}
return mm, nil
}
func (mm *MemoryModule) CallEntryPoint(reason uint32) error {
if mm.entryPoint == 0 {
return nil
}
ret, _, _ := syscall.SyscallN(mm.entryPoint, mm.base, uintptr(reason), 0)
if ret == 0 && reason == dllProcessAttach {
return errors.New("pe: DllMain returned FALSE")
}
mm.initialized = (reason == dllProcessAttach)
return nil
}
func (mm *MemoryModule) GetExport(name string) (uintptr, error) {
addr, ok := mm.exports[name]
if !ok {
return 0, fmt.Errorf("pe: export %q not found", name)
}
return addr, nil
}
func (mm *MemoryModule) Free() {
if mm.base == 0 {
return
}
if mm.initialized && mm.entryPoint != 0 {
syscall.SyscallN(mm.entryPoint, mm.base, dllProcessDetach, 0)
mm.initialized = false
}
if mm.tlsIndex != 0xFFFFFFFF {
if mm.tlsData != 0 {
_ = windows.VirtualFree(mm.tlsData, 0, windows.MEM_RELEASE)
}
procTlsFree.Call(uintptr(mm.tlsIndex))
}
for _, h := range mm.importDLLs {
_ = windows.FreeLibrary(h)
}
mm.importDLLs = nil
_ = windows.VirtualFree(mm.base, 0, windows.MEM_RELEASE)
mm.base = 0
}
func (mm *MemoryModule) processRelocations(dir imageDataDirectory, delta int64) error {
offset := uintptr(dir.VirtualAddress)
end := offset + uintptr(dir.Size)
for offset < end {
block := (*imageBaseRelocation)(unsafe.Pointer(mm.base + offset))
if block.SizeOfBlock == 0 {
break
}
count := (block.SizeOfBlock - 8) / 2
entries := mm.base + offset + 8
for i := uint32(0); i < count; i++ {
entry := *(*uint16)(unsafe.Pointer(entries + uintptr(i)*2))
typ := entry >> 12
off := uintptr(entry & 0xFFF)
addr := mm.base + uintptr(block.VirtualAddress) + off
switch typ {
case imageRelBasedAbsolute:
case imageRelBasedHighLow:
val := (*uint32)(unsafe.Pointer(addr))
*val = uint32(int64(*val) + delta)
case imageRelBasedDir64:
val := (*uint64)(unsafe.Pointer(addr))
*val = uint64(int64(*val) + delta)
default:
return fmt.Errorf("pe: unsupported relocation type %d", typ)
}
}
offset += uintptr(block.SizeOfBlock)
}
return nil
}
func (mm *MemoryModule) resolveImports(dir imageDataDirectory) error {
descSize := unsafe.Sizeof(imageImportDescriptor{})
offset := uintptr(dir.VirtualAddress)
for {
desc := (*imageImportDescriptor)(unsafe.Pointer(mm.base + offset))
if desc.Name == 0 {
break
}
dllName := peString(mm.base + uintptr(desc.Name))
hDLL, err := windows.LoadLibrary(dllName)
if err != nil {
return fmt.Errorf("pe: LoadLibrary(%s): %w", dllName, err)
}
mm.importDLLs = append(mm.importDLLs, hDLL)
thunkRef := mm.base + uintptr(desc.OriginalFirstThunk)
thunkAddr := mm.base + uintptr(desc.FirstThunk)
if desc.OriginalFirstThunk == 0 {
thunkRef = thunkAddr
}
for {
ref := *(*uint64)(unsafe.Pointer(thunkRef))
if ref == 0 {
break
}
var procAddr uintptr
if ref&(1<<63) != 0 {
ordinal := uint16(ref & 0xFFFF)
procAddr, err = getProcByOrdinal(hDLL, ordinal)
} else {
nameAddr := mm.base + uintptr(ref) + 2
funcName := peString(nameAddr)
procAddr, err = windows.GetProcAddress(hDLL, funcName)
}
if err != nil {
return fmt.Errorf("pe: import resolve from %s: %w", dllName, err)
}
*(*uintptr)(unsafe.Pointer(thunkAddr)) = procAddr
thunkRef += 8
thunkAddr += 8
}
offset += descSize
}
return nil
}
func (mm *MemoryModule) parseExports(dir imageDataDirectory) {
if dir.Size == 0 {
return
}
expDir := (*imageExportDirectory)(unsafe.Pointer(mm.base + uintptr(dir.VirtualAddress)))
numNames := int(expDir.NumberOfNames)
if numNames == 0 {
return
}
namesRVA := mm.base + uintptr(expDir.AddressOfNames)
ordinalsRVA := mm.base + uintptr(expDir.AddressOfNameOrdinals)
funcsRVA := mm.base + uintptr(expDir.AddressOfFunctions)
exportStart := uintptr(dir.VirtualAddress)
exportEnd := exportStart + uintptr(dir.Size)
for i := 0; i < numNames; i++ {
nameRVA := *(*uint32)(unsafe.Pointer(namesRVA + uintptr(i)*4))
ordinal := *(*uint16)(unsafe.Pointer(ordinalsRVA + uintptr(i)*2))
funcRVA := *(*uint32)(unsafe.Pointer(funcsRVA + uintptr(ordinal)*4))
if uintptr(funcRVA) >= exportStart && uintptr(funcRVA) < exportEnd {
continue
}
name := peString(mm.base + uintptr(nameRVA))
mm.exports[name] = mm.base + uintptr(funcRVA)
}
}
func (mm *MemoryModule) setupTLS(reason uint32) {
dosHdr := (*imageDOSHeader)(unsafe.Pointer(mm.base))
ntHdr := (*imageNTHeaders64)(unsafe.Pointer(mm.base + uintptr(dosHdr.LfaNew)))
tlsDir := ntHdr.OptionalHeader.DataDirectory[imageDirectoryEntryTLS]
if tlsDir.VirtualAddress == 0 || tlsDir.Size == 0 {
return
}
tls := (*imageTLSDirectory64)(unsafe.Pointer(mm.base + uintptr(tlsDir.VirtualAddress)))
idx, _, _ := procTlsAlloc.Call()
if idx == 0xFFFFFFFF {
return
}
mm.tlsIndex = uint32(idx)
if tls.AddressOfIndex != 0 {
*(*uint32)(unsafe.Pointer(uintptr(tls.AddressOfIndex))) = mm.tlsIndex
}
dataSize := uintptr(tls.EndAddressOfRawData - tls.StartAddressOfRawData)
totalSize := dataSize + uintptr(tls.SizeOfZeroFill)
if totalSize > 0 {
tlsData, err := windows.VirtualAlloc(0, totalSize,
windows.MEM_RESERVE|windows.MEM_COMMIT, windows.PAGE_READWRITE)
if err == nil && tlsData != 0 {
if dataSize > 0 {
copyMem(tlsData, (*byte)(unsafe.Pointer(uintptr(tls.StartAddressOfRawData))), dataSize)
}
procTlsSetValue.Call(uintptr(mm.tlsIndex), tlsData)
mm.tlsData = tlsData
}
}
if tls.AddressOfCallBacks != 0 {
cbAddr := uintptr(tls.AddressOfCallBacks)
for {
cb := *(*uintptr)(unsafe.Pointer(cbAddr))
if cb == 0 {
break
}
syscall.SyscallN(cb, mm.base, uintptr(reason), 0)
cbAddr += 8
}
}
}
func peString(addr uintptr) string {
var buf []byte
for i := 0; i < 4096; i++ { // safety limit
b := *(*byte)(unsafe.Pointer(addr + uintptr(i)))
if b == 0 {
break
}
buf = append(buf, b)
}
return string(buf)
}
var (
modKernel32 = windows.NewLazySystemDLL("kernel32.dll")
procGetProcAddr = modKernel32.NewProc("GetProcAddress")
procTlsAlloc = modKernel32.NewProc("TlsAlloc")
procTlsSetValue = modKernel32.NewProc("TlsSetValue")
procTlsFree = modKernel32.NewProc("TlsFree")
)
func getProcByOrdinal(module windows.Handle, ordinal uint16) (uintptr, error) {
r, _, err := procGetProcAddr.Call(uintptr(module), uintptr(ordinal))
if r == 0 {
return 0, fmt.Errorf("GetProcAddress ordinal %d: %w", ordinal, err)
}
return r, nil
}
func sectionProtection(chars uint32) uint32 {
exec := chars&imageSCNMemExecute != 0
read := chars&imageSCNMemRead != 0
write := chars&imageSCNMemWrite != 0
switch {
case exec && write:
return windows.PAGE_EXECUTE_READWRITE
case exec && read:
return windows.PAGE_EXECUTE_READ
case exec:
return windows.PAGE_EXECUTE
case write:
return windows.PAGE_READWRITE
case read:
return windows.PAGE_READONLY
default:
return windows.PAGE_NOACCESS
}
}
func copyMem(dst uintptr, src *byte, size uintptr) {
dstSlice := unsafe.Slice((*byte)(unsafe.Pointer(dst)), size)
srcSlice := unsafe.Slice(src, size)
copy(dstSlice, srcSlice)
}
+24 -4
View File
@@ -14,6 +14,7 @@ type PluginManifest struct {
Version string `msgpack:"version,omitempty" json:"version,omitempty"`
Description string `msgpack:"description,omitempty" json:"description,omitempty"`
Binary string `msgpack:"binary,omitempty" json:"binary,omitempty"`
Binaries map[string]string `msgpack:"binaries,omitempty" json:"binaries,omitempty"`
Entry string `msgpack:"entry,omitempty" json:"entry,omitempty"`
Assets PluginAssets `msgpack:"assets,omitempty" json:"assets,omitempty"`
}
@@ -26,10 +27,20 @@ type PluginMessage struct {
}
type HostInfo struct {
ClientID string `msgpack:"clientId"`
OS string `msgpack:"os"`
Arch string `msgpack:"arch"`
Version string `msgpack:"version"`
ClientID string `msgpack:"clientId" json:"clientId"`
OS string `msgpack:"os" json:"os"`
Arch string `msgpack:"arch" json:"arch"`
Version string `msgpack:"version" json:"version"`
}
type NativePlugin interface {
Load(send func(event string, payload []byte), hostInfo []byte) error
Event(event string, payload []byte) error
Unload()
Close() error
}
func ManifestFromMap(m map[string]interface{}) (PluginManifest, error) {
@@ -41,6 +52,15 @@ func ManifestFromMap(m map[string]interface{}) (PluginManifest, error) {
manifest.Binary = stringVal(m["binary"])
manifest.Entry = stringVal(m["entry"])
if binariesRaw, ok := m["binaries"].(map[string]interface{}); ok {
manifest.Binaries = make(map[string]string, len(binariesRaw))
for k, v := range binariesRaw {
if s, ok := v.(string); ok {
manifest.Binaries[k] = s
}
}
}
if assetsRaw, ok := m["assets"].(map[string]interface{}); ok {
manifest.Assets = PluginAssets{
HTML: stringVal(assetsRaw["html"]),
-1
View File
@@ -7,7 +7,6 @@ require (
github.com/gen2brain/x264-go v0.3.1 // optional: used when cgo is enabled
github.com/kbinani/screenshot v0.0.0-20250624051815-089614a94018
github.com/pixiv/go-libjpeg v0.0.0-20190822045933-3da21a74767d // optional: turbojpeg build tag
github.com/tetratelabs/wazero v1.7.0
github.com/vmihailenco/msgpack/v5 v5.4.1
nhooyr.io/websocket v1.8.10
)
-2
View File
@@ -28,8 +28,6 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/tetratelabs/wazero v1.7.0 h1:jg5qPydno59wqjpGrHph81lbtHzTrWzwwtD4cD88+hQ=
github.com/tetratelabs/wazero v1.7.0/go.mod h1:ytl6Zuh20R/eROuyDaGPkp82O9C/DJfXAwJfQ3X6/7Y=
github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8=
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
@@ -0,0 +1,89 @@
:root {
color-scheme: dark;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: "Segoe UI", system-ui, sans-serif;
background: #0f1115;
color: #e8ecf1;
}
.container {
max-width: 820px;
margin: 40px auto;
padding: 0 20px;
}
.header h1 {
margin: 0 0 4px 0;
font-size: 28px;
}
.sub {
margin: 0 0 24px 0;
color: #9aa4b2;
}
.card {
background: #161a22;
border: 1px solid #222938;
border-radius: 12px;
padding: 18px;
margin-bottom: 18px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
}
.row {
display: grid;
grid-template-columns: 120px 1fr;
gap: 12px;
align-items: center;
margin-bottom: 12px;
}
.label {
color: #9aa4b2;
font-size: 14px;
}
.input {
padding: 10px 12px;
border-radius: 8px;
border: 1px solid #2a3346;
background: #0f131b;
color: #e8ecf1;
}
.actions {
display: flex;
gap: 10px;
margin-top: 10px;
}
.btn {
background: #3b82f6;
color: #fff;
border: none;
padding: 10px 14px;
border-radius: 8px;
cursor: pointer;
font-weight: 600;
}
.btn:hover {
background: #2563eb;
}
.log {
min-height: 120px;
background: #0f131b;
border-radius: 8px;
border: 1px solid #2a3346;
padding: 12px;
white-space: pre-wrap;
}
@@ -10,7 +10,7 @@
<main class="container">
<header class="header">
<h1>Sample Plugin</h1>
<p class="sub">Go WASM + UI plugin</p>
<p class="sub">Go Native + UI plugin</p>
</header>
<section class="card">
@@ -0,0 +1,14 @@
{
"id": "sample",
"name": "sample",
"version": "1.0.0",
"binaries": {
"windows-amd64": "sample-windows-amd64.dll"
},
"entry": "sample.html",
"assets": {
"html": "sample.html",
"css": "sample.css",
"js": "sample.js"
}
}
+2 -2
View File
@@ -177,8 +177,8 @@ const ensurePluginExtracted = (pluginId: string) =>
ensurePluginExtractedFromRoot(PLUGIN_ROOT, pluginId, sanitizePluginId);
const listPluginManifests = () =>
listPluginManifestsFromRoot(PLUGIN_ROOT, pluginState, savePluginState, ensurePluginExtracted);
const loadPluginBundle = (pluginId: string) =>
loadPluginBundleFromRoot(PLUGIN_ROOT, pluginId, ensurePluginExtracted);
const loadPluginBundle = (pluginId: string, clientOS?: string, clientArch?: string) =>
loadPluginBundleFromRoot(PLUGIN_ROOT, pluginId, ensurePluginExtracted, clientOS, clientArch);
const startBuildProcess = (buildId: string, buildConfig: any) =>
runBuildProcess(buildId, buildConfig, {
generateBuildMutex,
+1
View File
@@ -241,6 +241,7 @@ export type PluginManifest = {
version?: string;
description?: string;
binary?: string;
binaries?: Record<string, string>;
entry?: string;
assets?: {
html?: string;
@@ -72,30 +72,43 @@ export async function ensurePluginExtracted(
const zip = new AdmZip(zipPath);
const entries = zip.getEntries();
let wasmEntry: Buffer | null = null;
let htmlEntry: Buffer | null = null;
let cssEntry: Buffer | null = null;
let jsEntry: Buffer | null = null;
const nativeBinaries: Map<string, Buffer> = new Map();
for (const entry of entries) {
if (entry.isDirectory) continue;
const base = path.basename(entry.entryName);
if (base.toLowerCase().endsWith(".wasm")) {
wasmEntry = entry.getData();
} else if (base.toLowerCase().endsWith(".html")) {
const lower = base.toLowerCase();
if (lower.endsWith(".so") || lower.endsWith(".dll") || lower.endsWith(".dylib")) {
nativeBinaries.set(base, entry.getData());
} else if (lower.endsWith(".html")) {
htmlEntry = entry.getData();
} else if (base.toLowerCase().endsWith(".css")) {
} else if (lower.endsWith(".css")) {
cssEntry = entry.getData();
} else if (base.toLowerCase().endsWith(".js")) {
} else if (lower.endsWith(".js")) {
jsEntry = entry.getData();
}
}
if (!wasmEntry || !htmlEntry || !cssEntry || !jsEntry) {
throw new Error(`Invalid plugin bundle: ${safeId} (missing required files)`);
const hasNative = nativeBinaries.size > 0;
if (!hasNative) {
throw new Error(`Invalid plugin bundle: ${safeId} (no native binaries found)`);
}
if (!htmlEntry || !cssEntry || !jsEntry) {
throw new Error(`Invalid plugin bundle: ${safeId} (missing .html, .css, or .js)`);
}
const binariesMap: Record<string, string> = {};
for (const [filename, data] of nativeBinaries) {
await fs.writeFile(path.join(pluginDir, filename), data);
const platformKey = derivePlatformKey(filename);
if (platformKey) {
binariesMap[platformKey] = filename;
}
}
await fs.writeFile(path.join(pluginDir, `${safeId}.wasm`), wasmEntry);
await fs.writeFile(path.join(assetsDir, `${safeId}.html`), htmlEntry);
await fs.writeFile(path.join(assetsDir, `${safeId}.css`), cssEntry);
await fs.writeFile(path.join(assetsDir, `${safeId}.js`), jsEntry);
@@ -104,7 +117,7 @@ export async function ensurePluginExtracted(
id: safeId,
name: safeId,
version: "1.0.0",
binary: `${safeId}.wasm`,
binaries: binariesMap,
entry: `${safeId}.html`,
assets: {
html: `${safeId}.html`,
@@ -168,7 +181,9 @@ export async function loadPluginBundle(
pluginRoot: string,
pluginId: string,
ensureExtracted: (pluginId: string) => Promise<void>,
): Promise<{ manifest: PluginManifest; wasm: Uint8Array }> {
clientOS?: string,
clientArch?: string,
): Promise<{ manifest: PluginManifest; binary: Uint8Array }> {
await ensureExtracted(pluginId);
const dir = path.join(pluginRoot, pluginId);
const manifestPath = path.join(dir, "manifest.json");
@@ -177,30 +192,46 @@ export async function loadPluginBundle(
manifest.id = manifest.id || pluginId;
manifest.name = manifest.name || pluginId;
const binaryName = manifest.binary || manifest.entry || `${pluginId}.wasm`;
let wasmPath = path.join(dir, binaryName);
let binaryPath: string | null = null;
if (manifest.binaries && clientOS && clientArch) {
const key = `${clientOS}-${clientArch}`;
const filename = manifest.binaries[key];
if (filename) {
const candidate = path.join(dir, filename);
try {
await fs.access(wasmPath);
} catch {
const files = await fs.readdir(dir);
const firstWasm = files.find((f) => f.toLowerCase().endsWith(".wasm"));
if (!firstWasm) throw new Error("No .wasm found for plugin " + pluginId);
wasmPath = path.join(dir, firstWasm);
await fs.access(candidate);
binaryPath = candidate;
} catch {}
}
const wasm = new Uint8Array(await fs.readFile(wasmPath));
return { manifest, wasm };
}
if (!binaryPath) {
const files = await fs.readdir(dir);
const found = files.find(
(f) =>
f.toLowerCase().endsWith(".so") ||
f.toLowerCase().endsWith(".dll") ||
f.toLowerCase().endsWith(".dylib"),
);
if (!found) throw new Error(`No plugin binary found for ${pluginId} (${clientOS}-${clientArch})`);
binaryPath = path.join(dir, found);
}
const binary = new Uint8Array(await fs.readFile(binaryPath));
return { manifest, binary };
}
export function sendPluginBundle(
target: ClientInfo,
bundle: { manifest: PluginManifest; wasm: Uint8Array },
bundle: { manifest: PluginManifest; binary: Uint8Array },
): void {
const chunkSize = 16 * 1024;
const wasm = bundle.wasm;
const totalChunks = Math.ceil(wasm.length / chunkSize);
const data = bundle.binary;
const totalChunks = Math.ceil(data.length / chunkSize);
const initPayload = {
manifest: bundle.manifest,
size: wasm.length,
size: data.length,
chunks: totalChunks,
};
target.ws.send(
@@ -214,8 +245,8 @@ export function sendPluginBundle(
for (let i = 0; i < totalChunks; i++) {
const start = i * chunkSize;
const end = Math.min(start + chunkSize, wasm.length);
const chunk = wasm.slice(start, end);
const end = Math.min(start + chunkSize, data.length);
const chunk = data.slice(start, end);
target.ws.send(
encodeMessage({
type: "command",
@@ -235,3 +266,15 @@ export function sendPluginBundle(
}),
);
}
function derivePlatformKey(filename: string): string {
const match = filename.match(/-(linux|darwin|windows|freebsd)-(amd64|arm64|arm|386)\.(so|dll|dylib)$/i);
if (match) {
return `${match[1].toLowerCase()}-${match[2].toLowerCase()}`;
}
const lower = filename.toLowerCase();
if (lower.endsWith(".dll")) return "windows-amd64";
if (lower.endsWith(".dylib")) return "darwin-amd64";
if (lower.endsWith(".so")) return "linux-amd64";
return "";
}
@@ -14,7 +14,7 @@ type PluginManifest = {
type PluginBundle = {
manifest: PluginManifest;
wasm: Uint8Array;
binary: Uint8Array;
};
type PluginState = {
@@ -32,7 +32,7 @@ type PluginRouteDeps = {
ensurePluginExtracted: (pluginId: string) => Promise<void>;
savePluginState: () => Promise<void>;
listPluginManifests: () => Promise<PluginManifest[]>;
loadPluginBundle: (pluginId: string) => Promise<PluginBundle>;
loadPluginBundle: (pluginId: string, clientOS?: string, clientArch?: string) => Promise<PluginBundle>;
sendPluginBundle: (target: any, bundle: PluginBundle) => void;
markPluginLoading: (clientId: string, pluginId: string) => void;
isPluginLoaded: (clientId: string, pluginId: string) => boolean;
@@ -231,7 +231,7 @@ export async function handlePluginRoutes(
return Response.json({ ok: true, loading: true });
}
try {
const bundle = await deps.loadPluginBundle(pluginId);
const bundle = await deps.loadPluginBundle(pluginId, target.os, target.arch);
deps.markPluginLoading(targetId, pluginId);
deps.sendPluginBundle(target, bundle);
metrics.recordCommand("plugin_load");
@@ -276,7 +276,7 @@ export async function handlePluginRoutes(
deps.enqueuePluginEvent(targetId, pluginId, event, payload);
if (!deps.isPluginLoading(targetId, pluginId)) {
try {
const bundle = await deps.loadPluginBundle(pluginId);
const bundle = await deps.loadPluginBundle(pluginId, target.os, target.arch);
deps.markPluginLoading(targetId, pluginId);
deps.sendPluginBundle(target, bundle);
metrics.recordCommand("plugin_load");
+143 -168
View File
@@ -1,8 +1,8 @@
# Overlord Plugins
This document explains how to build Overlord plugins, how the runtime works, and what you can do from a plugin UI and a WASM module.
This document explains how to build Overlord plugins using the **native plugin system**. Plugins run as native shared libraries (`.so` on Linux, `.dylib` on macOS, in-memory DLL on Windows), giving them full access to the Go runtime and system APIs.
> TL;DR: A plugin is a zip file with 4 files at the root: `<id>.wasm`, `<id>.html`, `<id>.css`, `<id>.js`. Upload it in the Plugins page or drop it in Overlord-Server/plugins.
> TL;DR: A plugin is a zip with platform-specific binaries (`.so`/`.dll`/`.dylib`) plus `<id>.html`, `<id>.css`, `<id>.js`. Upload it in the Plugins page or drop it in Overlord-Server/plugins.
## 1) How plugins are structured
@@ -14,49 +14,54 @@ A plugin bundle is a zip file named after the plugin ID:
<pluginId>.zip
```
Inside the zip (root level), these four files are required:
Inside the zip (root level), you need:
```
<pluginId>.wasm
<pluginId>.html
<pluginId>.css
<pluginId>.js
```
- **Platform-specific binaries** named `<pluginId>-<os>-<arch>.<ext>`
- **Web assets**: `<pluginId>.html`, `<pluginId>.css`, `<pluginId>.js`
Example for plugin ID `sample`:
```
sample.zip
├─ sample.wasm
├─ sample-linux-amd64.so
├─ sample-linux-arm64.so
├─ sample-darwin-arm64.dylib
├─ sample-windows-amd64.dll
├─ sample.html
├─ sample.css
└─ sample.js
```
When the server sees `<pluginId>.zip`, it extracts to:
When the server extracts the zip:
```
Overlord-Server/plugins/<pluginId>/
├─ <pluginId>.wasm
├─ manifest.json
Overlord-Server/plugins/sample/
├─ sample-linux-amd64.so
├─ sample-linux-arm64.so
├─ sample-darwin-arm64.dylib
├─ sample-windows-amd64.dll
├─ manifest.json (auto-generated)
└─ assets/
├─ <pluginId>.html
├─ <pluginId>.css
└─ <pluginId>.js
├─ sample.html
├─ sample.css
└─ sample.js
```
The server **generates manifest.json** automatically. You do not need to include a manifest inside the zip.
### Manifest fields
The auto-generated manifest looks like:
The auto-generated manifest:
```
```json
{
"id": "sample",
"name": "sample",
"version": "1.0.0",
"binary": "sample.wasm",
"binaries": {
"linux-amd64": "sample-linux-amd64.so",
"linux-arm64": "sample-linux-arm64.so",
"darwin-arm64": "sample-darwin-arm64.dylib",
"windows-amd64": "sample-windows-amd64.dll"
},
"entry": "sample.html",
"assets": {
"html": "sample.html",
@@ -66,47 +71,126 @@ The auto-generated manifest looks like:
}
```
## 2) Build a WASM plugin (Go WASI)
The server picks the right binary for the target client's OS/arch when loading.
The plugin runtime expects a WASI-compatible WebAssembly binary. Go 1.21+ builds work well with the current wazero runtime.
## 2) Build a native plugin
Build steps (from the plugin folder):
### Plugin contract
```
GOOS=wasip1 GOARCH=wasm go build -o <pluginId>.wasm ./wasm
Plugins are Go packages that export specific functions. The core logic is shared across platforms, with thin platform-specific export wrappers.
All platforms use `-buildmode=c-shared` and export C-callable functions.
#### Linux / macOS (`.so` / `.dylib`)
```go
//export PluginOnLoad
func PluginOnLoad(hostInfo *C.char, hostInfoLen C.int, cb C.uintptr_t, ctx C.uintptr_t) C.int
//export PluginOnEvent
func PluginOnEvent(event *C.char, eventLen C.int, payload *C.char, payloadLen C.int) C.int
//export PluginOnUnload
func PluginOnUnload()
```
There is a working example in:
The host passes a callback function pointer and context during OnLoad. The plugin calls the callback to send events back.
- plugin-sample-go/wasm/main.go
Build: `CGO_ENABLED=1 go build -buildmode=c-shared -o sample-linux-amd64.so ./native`
**On Linux, shared libraries are loaded entirely in memory via `memfd_create` — no files touch disk.**
#### Windows (`.dll`)
```go
//export PluginOnLoad
func PluginOnLoad(hostInfo *C.char, hostInfoLen C.int, callbackPtr C.ulonglong) C.int
//export PluginOnEvent
func PluginOnEvent(event *C.char, eventLen C.int, payload *C.char, payloadLen C.int) C.int
//export PluginOnUnload
func PluginOnUnload()
//export PluginSetCallback
func PluginSetCallback(callbackPtr C.ulonglong)
```
The callback is a stdcall function pointer: `func(eventPtr, eventLen, payloadPtr, payloadLen uintptr) uintptr`
Build: `CGO_ENABLED=1 GOOS=windows GOARCH=amd64 go build -buildmode=c-shared -o sample-windows-amd64.dll ./native`
**On Windows, DLLs are loaded entirely in memory — no files are written to disk.**
### HostInfo JSON
```json
{
"clientId": "abc123",
"os": "windows",
"arch": "amd64",
"version": "1.0.0"
}
```
### Project structure
See `plugin-sample-go/native/` for a working example:
```
plugin-sample-go/native/
├─ main.go (shared core logic)
├─ exports_unix.go (Go plugin exports for Linux/macOS)
├─ exports_windows.go (C-shared DLL exports for Windows)
└─ go.mod
```
### Build scripts
Use the provided build scripts:
```bash
# Linux/macOS — builds for current platform by default
./build-plugin.sh
# Build for multiple targets
BUILD_TARGETS="linux-amd64 linux-arm64 darwin-arm64" ./build-plugin.sh
# Windows — builds windows-amd64 by default
build-plugin.bat
# Build for multiple targets
set BUILD_TARGETS=windows-amd64 linux-amd64
build-plugin.bat
```
## 3) Install & open a plugin
### Install / upload
- Use the UI at /plugins to upload the zip
- Or drop `<pluginId>.zip` into Overlord-Server/plugins and restart the server
- Use the UI at `/plugins` to upload the zip
- Or drop `<pluginId>.zip` into `Overlord-Server/plugins` and restart
### Open the UI
The plugin UI is served from:
Plugin UI is served from:
```
/plugins/<pluginId>?clientId=<CLIENT_ID>
```
Your HTML should load its JS/CSS from `/plugins/<pluginId>/assets/`.
Your HTML loads its JS/CSS from `/plugins/<pluginId>/assets/`.
## 4) Runtime: how events flow
Overlord plugins have **two parts**:
1. **UI (HTML/CSS/JS)** — Runs in the browser and can call server APIs or open WebSockets.
2. **WASM module** — Runs in the agent (client) process via wazero.
1. **UI (HTML/CSS/JS)** — Runs in the browser, calls server APIs.
2. **Native module** — Runs in the agent (client) process as a loaded shared library.
### UI → agent (plugin event)
From your UI JS, you can send an event to a plugin on a client:
From your UI JS:
```
POST /api/clients/<clientId>/plugins/<pluginId>/event
@@ -116,94 +200,53 @@ POST /api/clients/<clientId>/plugins/<pluginId>/event
}
```
If the plugin is not loaded yet, the server will:
If the plugin is not loaded yet, the server will load it on the client, queue the event, and deliver it once ready.
- load it on the client
- queue your event
- deliver it when the plugin reports `loaded`
### Agent → plugin (direct function call)
### Agent → plugin (WASM stdin)
The agent calls your `OnEvent(event, payload)` function directly with JSON-encoded data. No stdin/stdout pipes, no msgpack — just a direct function call.
The agent forwards that event into the WASM plugin over msgpack via stdin.
### Plugin → agent (callback)
The message envelope looks like:
Your plugin sends events back to the host using the `send` callback received during `OnLoad`:
```
{
"type": "event",
"event": "ui_message",
"payload": { ... }
}
```go
send("echo", []byte(`{"message":"hello back"}`))
```
### Plugin → agent (WASM stdout)
Your WASM can reply by writing msgpack messages to stdout. Two supported types:
- `type: "event"` → forwarded back to the server as `plugin_event`
- `type: "log"` → logged by the agent
Examples:
```
{ "type": "event", "event": "ready", "payload": "plugin ready" }
{ "type": "log", "payload": "starting capture" }
```
On Windows, the equivalent is calling the registered callback function pointer.
### Plugin lifecycle events
The agent sends lifecycle events to the server:
The agent sends these events to the server:
- `loaded` on successful load
- `unloaded` when unloaded
- `error` if load or runtime fails
These update the server-side plugin status and error display.
> Note: plugin events are **not** pushed back to the plugin UI automatically. If you need UI feedback, call a server API that returns a response to the UI or implement your own polling/WS endpoint.
## 5) What can plugins do?
Plugins can do anything **the server already exposes** to authenticated users, such as:
Since plugins run as native code, they can:
- start/stop remote desktop
- open a console session
- file browser actions (list, upload, download, edit)
- process listing and kill
- run scripts
- send commands to clients
- Call any system API (file I/O, network, processes, etc.)
- Use any Go library
- Spawn goroutines
- Access hardware
- Do anything a normal Go program can do
This is intentional: plugins **do not modify server code** and are sandboxed by the plugin CSP.
Plugins have the same capabilities as the agent itself.
### Security constraints
### UI Security constraints
Plugin pages are served with a tight CSP:
Plugin UI pages are still served with a tight CSP:
- scripts must be same-origin
- no third‑party JS/CDN
- Scripts must be same-origin
- No third-party JS/CDN
- WebSocket and fetch are allowed to same origin
That means your plugin JS must be bundled into `<pluginId>.js` and loaded from `/plugins/<pluginId>/assets/`.
Plugin UIs run in a **sandboxed iframe** with a fetch bridge.
### Sandboxed iframe isolation
Plugin UIs are rendered inside a **sandboxed iframe** to isolate them from the main dashboard. This provides:
- No access to main app DOM
- No access to main app cookies/localStorage
- No direct access to privileged APIs
Because of the sandbox, **direct network calls from the plugin are blocked** by CSP. The system injects a small bridge that replaces `window.fetch` and forwards requests to the parent page.
Practical implications:
- Use `fetch()` as usual in your plugin JS
- Only the allowed plugin API routes are permitted
- Direct WebSocket usage from the plugin frame is blocked
If your plugin needs WebSockets (e.g., remote desktop), you must build a parent‑page bridge that opens the WS in the parent and forwards frames into the plugin UI. See the remote desktop section below for the architecture.
## 6) API surface (what you can call)
## 6) API surface
### Plugin management
@@ -220,78 +263,10 @@ If your plugin needs WebSockets (e.g., remote desktop), you must build a parent
### Useful built-in endpoints
- `POST /api/clients/<clientId>/command` (input, ping, scripts, etc.)
- `POST /api/clients/<clientId>/command`
- `WS /api/clients/<clientId>/rd/ws` (remote desktop)
- `WS /api/clients/<clientId>/console/ws`
- `WS /api/clients/<clientId>/files/ws`
- `WS /api/clients/<clientId>/processes/ws`
## 7) Building a “pinnacle” plugin (remote desktop class)
A remote‑desktop‑class plugin typically has:
1. **A UI page** with a `<canvas>` and quality controls
2. **A WebSocket** connection to `/api/clients/<id>/rd/ws`
3. **Command messages** (`desktop_start`, `desktop_stop`, `desktop_select_display`, etc.)
4. **Binary frame decoding** (JPEG/raw) and drawing into the canvas
### How it would work (high level)
1. UI page reads `clientId` from query string
2. JS opens a WebSocket to `/api/clients/<clientId>/rd/ws`
3. UI sends JSON messages:
```
{ "type": "desktop_start" }
{ "type": "desktop_select_display", "display": 0 }
{ "type": "desktop_set_quality", "quality": 90, "codec": "jpeg" }
```
4. Incoming WS messages contain encoded frames (see `public/assets/remotedesktop.js` for the parsing logic)
5. Draw frames into the canvas and expose controls to the user
You do **not** need to re-implement the agent capture pipeline. The agent already supports it; your plugin just drives the existing WS endpoint.
### What you’d write (outline)
- **HTML**: Canvas + controls
- **JS**:
- parse `clientId`
- open WS
- send desktop commands
- decode frames and draw
- **WASM module** (optional):
- background tasks or message validation
- optional telemetry / state management
If you want a starting point, the existing remote desktop UI is in:
- Overlord-Server/public/remotedesktop.html
- Overlord-Server/public/assets/remotedesktop.js
You can reuse that logic inside your plugin UI with minimal changes.
## 8) Example: minimal plugin code shape
### WASM side (Go, message loop)
- listen on stdin using msgpack
- on `type=init`, send `event=ready`
- on `type=event`, process payload and emit response events
See:
- plugin-sample-go/wasm/main.go
### UI side (JS)
- call `/api/clients/<id>/plugins/<pluginId>/event`
- show responses via your own UI (or poll your own endpoints)
See:
- plugin-sample-go/sample.js
---
If you want a deeper walkthrough of a specific plugin type (file explorer, custom telemetry, or a remote-desktop clone), say which one and I’ll expand that section.
+60 -17
View File
@@ -5,40 +5,83 @@ set "ROOT=%~dp0"
set "PLUGIN_DIR=%ROOT%plugin-sample-go"
if not "%~1"=="" set "PLUGIN_DIR=%~1"
set "WASM_DIR=%PLUGIN_DIR%\wasm"
set "NATIVE_DIR=%PLUGIN_DIR%\native"
set "PLUGIN_NAME=sample"
set "WASM_OUT=%PLUGIN_DIR%\%PLUGIN_NAME%.wasm"
set "ZIP_OUT=%PLUGIN_DIR%\%PLUGIN_NAME%.zip"
if not exist "%WASM_DIR%" (
echo [error] wasm folder not found: %WASM_DIR%
if not exist "%NATIVE_DIR%" (
echo [error] native folder not found: %NATIVE_DIR%
exit /b 1
)
pushd "%WASM_DIR%"
pushd "%NATIVE_DIR%"
echo [build] go build -o "%WASM_OUT%" (GOOS=wasip1 GOARCH=wasm)
set "GOOS=wasip1"
set "GOARCH=wasm"
go build -o "%WASM_OUT%" .
set "GOOS="
set "GOARCH="
REM Build targets - default to windows-amd64 on Windows
if not defined BUILD_TARGETS set "BUILD_TARGETS=windows-amd64"
set "BUILT_FILES="
for %%T in (%BUILD_TARGETS%) do (
for /f "tokens=1,2 delims=-" %%A in ("%%T") do (
set "TARGET_OS=%%A"
set "TARGET_ARCH=%%B"
)
if "!TARGET_OS!"=="windows" (
set "EXT=dll"
) else if "!TARGET_OS!"=="darwin" (
set "EXT=dylib"
) else (
set "EXT=so"
)
set "BUILDMODE=c-shared"
set "OUTFILE=%PLUGIN_DIR%\%PLUGIN_NAME%-!TARGET_OS!-!TARGET_ARCH!.!EXT!"
echo [build] GOOS=!TARGET_OS! GOARCH=!TARGET_ARCH! CGO_ENABLED=1 go build -buildmode=!BUILDMODE! -o "!OUTFILE!"
set "GOOS=!TARGET_OS!"
set "GOARCH=!TARGET_ARCH!"
set "CGO_ENABLED=1"
go build -buildmode=!BUILDMODE! -o "!OUTFILE!" .
if errorlevel 1 (
echo [warn] go build failed, trying tinygo
echo [build] tinygo build -o "%WASM_OUT%" -target wasi -scheduler=none -gc=leaking .
tinygo build -o "%WASM_OUT%" -target wasi -scheduler=none -gc=leaking .
if errorlevel 1 (
echo [error] tinygo build failed
echo [error] build failed for !TARGET_OS!-!TARGET_ARCH!
popd
exit /b 1
)
set "BUILT_FILES=!BUILT_FILES! '%PLUGIN_DIR%\%PLUGIN_NAME%-!TARGET_OS!-!TARGET_ARCH!.!EXT!'"
)
set "GOOS="
set "GOARCH="
set "CGO_ENABLED="
popd
if exist "%ZIP_OUT%" del /f /q "%ZIP_OUT%"
powershell -NoProfile -Command "Compress-Archive -Path '%PLUGIN_DIR%\%PLUGIN_NAME%.wasm','%PLUGIN_DIR%\%PLUGIN_NAME%.html','%PLUGIN_DIR%\%PLUGIN_NAME%.css','%PLUGIN_DIR%\%PLUGIN_NAME%.js' -DestinationPath '%ZIP_OUT%'"
REM Collect all build outputs and web assets
set "ZIP_SOURCES="
for %%T in (%BUILD_TARGETS%) do (
for /f "tokens=1,2 delims=-" %%A in ("%%T") do (
set "TARGET_OS=%%A"
set "TARGET_ARCH=%%B"
)
if "!TARGET_OS!"=="windows" (
set "ZIP_SOURCES=!ZIP_SOURCES!,'%PLUGIN_DIR%\%PLUGIN_NAME%-!TARGET_OS!-!TARGET_ARCH!.dll'"
) else if "!TARGET_OS!"=="darwin" (
set "ZIP_SOURCES=!ZIP_SOURCES!,'%PLUGIN_DIR%\%PLUGIN_NAME%-!TARGET_OS!-!TARGET_ARCH!.dylib'"
) else (
set "ZIP_SOURCES=!ZIP_SOURCES!,'%PLUGIN_DIR%\%PLUGIN_NAME%-!TARGET_OS!-!TARGET_ARCH!.so'"
)
)
REM Add web assets
if exist "%PLUGIN_DIR%\%PLUGIN_NAME%.html" set "ZIP_SOURCES=!ZIP_SOURCES!,'%PLUGIN_DIR%\%PLUGIN_NAME%.html'"
if exist "%PLUGIN_DIR%\%PLUGIN_NAME%.css" set "ZIP_SOURCES=!ZIP_SOURCES!,'%PLUGIN_DIR%\%PLUGIN_NAME%.css'"
if exist "%PLUGIN_DIR%\%PLUGIN_NAME%.js" set "ZIP_SOURCES=!ZIP_SOURCES!,'%PLUGIN_DIR%\%PLUGIN_NAME%.js'"
REM Remove leading comma
set "ZIP_SOURCES=!ZIP_SOURCES:~1!"
powershell -NoProfile -Command "Compress-Archive -Path !ZIP_SOURCES! -DestinationPath '%ZIP_OUT%'"
if errorlevel 1 (
echo [error] zip failed
exit /b 1
+45 -11
View File
@@ -3,31 +3,65 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PLUGIN_DIR="${1:-${ROOT_DIR}/plugin-sample-go}"
WASM_DIR="${PLUGIN_DIR}/wasm"
NATIVE_DIR="${PLUGIN_DIR}/native"
PLUGIN_NAME="sample"
WASM_OUT="${PLUGIN_DIR}/${PLUGIN_NAME}.wasm"
ZIP_OUT="${PLUGIN_DIR}/${PLUGIN_NAME}.zip"
if [[ ! -d "${WASM_DIR}" ]]; then
echo "[error] wasm folder not found: ${WASM_DIR}" >&2
if [[ ! -d "${NATIVE_DIR}" ]]; then
echo "[error] native folder not found: ${NATIVE_DIR}" >&2
exit 1
fi
pushd "${WASM_DIR}" >/dev/null
pushd "${NATIVE_DIR}" >/dev/null
echo "[build] go build -o ${WASM_OUT} (GOOS=wasip1 GOARCH=wasm)"
if ! GOOS=wasip1 GOARCH=wasm go build -o "${WASM_OUT}" .; then
echo "[warn] go build failed, trying tinygo"
echo "[build] tinygo build -o ${WASM_OUT} -target wasi -scheduler=none -gc=leaking ."
tinygo build -o "${WASM_OUT}" -target wasi -scheduler=none -gc=leaking .
# Detect current OS/arch for default build
HOST_OS="$(go env GOOS)"
HOST_ARCH="$(go env GOARCH)"
# Build matrix - can be overridden with BUILD_TARGETS env var
# Format: "os-arch os-arch ..." e.g. "linux-amd64 linux-arm64 darwin-arm64"
DEFAULT_TARGETS="${HOST_OS}-${HOST_ARCH}"
BUILD_TARGETS="${BUILD_TARGETS:-${DEFAULT_TARGETS}}"
BUILT_FILES=()
for target in ${BUILD_TARGETS}; do
os="${target%%-*}"
arch="${target#*-}"
if [[ "${os}" == "windows" ]]; then
ext="dll"
elif [[ "${os}" == "darwin" ]]; then
ext="dylib"
else
ext="so"
fi
buildmode="c-shared"
outfile="${PLUGIN_DIR}/${PLUGIN_NAME}-${os}-${arch}.${ext}"
echo "[build] GOOS=${os} GOARCH=${arch} CGO_ENABLED=1 go build -buildmode=${buildmode} -o ${outfile}"
CGO_ENABLED=1 GOOS="${os}" GOARCH="${arch}" go build -buildmode="${buildmode}" -o "${outfile}" .
BUILT_FILES+=("${PLUGIN_NAME}-${os}-${arch}.${ext}")
done
popd >/dev/null
rm -f "${ZIP_OUT}"
# Collect files to zip
ZIP_FILES=()
for bf in "${BUILT_FILES[@]}"; do
ZIP_FILES+=("${bf}")
done
# Add web assets
for asset in "${PLUGIN_NAME}.html" "${PLUGIN_NAME}.css" "${PLUGIN_NAME}.js"; do
if [[ -f "${PLUGIN_DIR}/${asset}" ]]; then
ZIP_FILES+=("${asset}")
fi
done
if command -v zip >/dev/null 2>&1; then
(cd "${PLUGIN_DIR}" && zip -q "${ZIP_OUT}" "${PLUGIN_NAME}.wasm" "${PLUGIN_NAME}.html" "${PLUGIN_NAME}.css" "${PLUGIN_NAME}.js")
(cd "${PLUGIN_DIR}" && zip -q "${ZIP_OUT}" "${ZIP_FILES[@]}")
else
echo "[error] zip not found. Please install zip." >&2
exit 1
+1 -1
View File
@@ -2,5 +2,5 @@ go 1.24
use (
./Overlord-Client
./plugin-sample-go/wasm
./plugin-sample-go/native
)
+18 -18
View File
@@ -1,49 +1,49 @@
# Sample Go WASM Plugin (sample)
# Sample Go Plugin (sample)
This sample lives at the repo root and builds a WASI-compatible Go plugin.
This sample lives at the repo root and builds a native Go plugin.
## Files to include in the plugin zip
At the root of the zip:
- sample.wasm
- sample-linux-amd64.so / sample-darwin-arm64.dylib / sample-windows-amd64.dll
- sample.html
- sample.css
- sample.js
## Build the WASM
## Build
Recommended: Go WASI (most compatible with wazero)
Use the provided build scripts:
1) Build the WASM with Go 1.21+:
```bash
# Linux/macOS
./build-plugin.sh
GOOS=wasip1 GOARCH=wasm go build -o sample.wasm ./wasm
# Multiple targets
BUILD_TARGETS="linux-amd64 linux-arm64 darwin-arm64" ./build-plugin.sh
2) Optional TinyGo fallback:
tinygo build -o sample.wasm -target wasi -scheduler=none -gc=leaking ./wasm
# Windows
build-plugin.bat
```
## Create the zip
Create a zip named sample.zip containing:
- sample.wasm
- sample.html
- sample.css
- sample.js
The build script automatically creates `sample.zip` containing the built
binaries and web assets.
Then place sample.zip in Overlord-Server/plugins.
Then place `sample.zip` in `Overlord-Server/plugins`.
## Open the UI
Navigate to:
- /plugins/sample?clientId=<CLIENT_ID>
Click "Send event" and the WASM plugin will echo back to the server logs.
Click "Send event" and the plugin will echo back to the server logs.
## OS detection (Windows-only logic)
The plugin receives host metadata in the `init` payload. The sample plugin shows
how to detect the host OS and branch logic (see
[plugin-sample-go/wasm/main.go](plugin-sample-go/wasm/main.go)). For example:
`plugin-sample-go/native/main.go`). For example:
- If `host.os == "windows"`, you can safely run Windows-only flows (e.g., WinAPI-backed behavior).
- Otherwise, skip or provide a fallback path.
+75
View File
@@ -0,0 +1,75 @@
//go:build linux || darwin || freebsd
package main
/*
#include <stdint.h>
typedef void (*host_callback_t)(uintptr_t ctx,
const char* event, int eventLen,
const char* payload, int payloadLen);
static void invoke_host_callback(uintptr_t fn, uintptr_t ctx,
const char* ev, int evLen, const char* pl, int plLen) {
((host_callback_t)fn)(ctx, ev, evLen, pl, plLen);
}
*/
import "C"
import "unsafe"
var (
hostCallbackFn C.uintptr_t
hostCallbackCtx C.uintptr_t
)
func hostSendViaCallback(event string, payload []byte) {
fn := hostCallbackFn
if fn == 0 {
return
}
evBytes := []byte(event)
var evPtr, plPtr unsafe.Pointer
if len(evBytes) > 0 {
evPtr = unsafe.Pointer(&evBytes[0])
}
if len(payload) > 0 {
plPtr = unsafe.Pointer(&payload[0])
}
C.invoke_host_callback(fn, hostCallbackCtx,
(*C.char)(evPtr), C.int(len(evBytes)),
(*C.char)(plPtr), C.int(len(payload)))
}
//export PluginOnLoad
func PluginOnLoad(hostInfo *C.char, hostInfoLen C.int, cb C.uintptr_t, ctx C.uintptr_t) C.int {
hostCallbackFn = cb
hostCallbackCtx = ctx
setSend(hostSendViaCallback)
data := C.GoBytes(unsafe.Pointer(hostInfo), hostInfoLen)
if err := handleInit(data); err != nil {
return 1
}
return 0
}
//export PluginOnEvent
func PluginOnEvent(event *C.char, eventLen C.int, payload *C.char, payloadLen C.int) C.int {
ev := C.GoStringN(event, eventLen)
var pl []byte
if payloadLen > 0 {
pl = C.GoBytes(unsafe.Pointer(payload), payloadLen)
}
if err := handleEvent(ev, pl); err != nil {
return 1
}
return 0
}
//export PluginOnUnload
func PluginOnUnload() {
handleUnload()
}
func main() {}
@@ -0,0 +1,67 @@
//go:build windows
package main
import "C"
import (
"syscall"
"unsafe"
)
var callbackPtr uintptr
func hostSendViaCallback(event string, payload []byte) {
cb := callbackPtr
if cb == 0 {
return
}
eventBytes := []byte(event)
var evPtr, plPtr uintptr
evLen := uintptr(len(eventBytes))
plLen := uintptr(len(payload))
if len(eventBytes) > 0 {
evPtr = uintptr(unsafe.Pointer(&eventBytes[0]))
}
if len(payload) > 0 {
plPtr = uintptr(unsafe.Pointer(&payload[0]))
}
syscall.SyscallN(cb, evPtr, evLen, plPtr, plLen)
}
//export PluginSetCallback
func PluginSetCallback(cb C.ulonglong) {
callbackPtr = uintptr(cb)
setSend(hostSendViaCallback)
}
//export PluginOnLoad
func PluginOnLoad(hostInfo *C.char, hostInfoLen C.int, cb C.ulonglong) C.int {
callbackPtr = uintptr(cb)
setSend(hostSendViaCallback)
data := C.GoBytes(unsafe.Pointer(hostInfo), hostInfoLen)
if err := handleInit(data); err != nil {
return 1
}
return 0
}
//export PluginOnEvent
func PluginOnEvent(event *C.char, eventLen C.int, payload *C.char, payloadLen C.int) C.int {
ev := C.GoStringN(event, eventLen)
var pl []byte
if payloadLen > 0 {
pl = C.GoBytes(unsafe.Pointer(payload), payloadLen)
}
if err := handleEvent(ev, pl); err != nil {
return 1
}
return 0
}
//export PluginOnUnload
func PluginOnUnload() {
handleUnload()
}
func main() {}
+3
View File
@@ -0,0 +1,3 @@
module sample-plugin
go 1.24
+73
View File
@@ -0,0 +1,73 @@
package main
import (
"encoding/json"
"log"
"sync"
)
type HostInfo struct {
ClientID string `json:"clientId"`
OS string `json:"os"`
Arch string `json:"arch"`
Version string `json:"version"`
}
var (
hostInfo HostInfo
sendFn func(event string, payload []byte)
mu sync.Mutex
)
func setSend(fn func(event string, payload []byte)) {
mu.Lock()
sendFn = fn
mu.Unlock()
}
func sendEvent(event string, payload interface{}) {
mu.Lock()
fn := sendFn
mu.Unlock()
if fn == nil {
return
}
data, err := json.Marshal(payload)
if err != nil {
log.Printf("[sample] marshal error: %v", err)
return
}
fn(event, data)
}
func handleInit(hostJSON []byte) error {
if err := json.Unmarshal(hostJSON, &hostInfo); err != nil {
return err
}
log.Printf("[sample] init: clientId=%s os=%s arch=%s", hostInfo.ClientID, hostInfo.OS, hostInfo.Arch)
sendEvent("ready", map[string]string{"message": "sample plugin ready"})
return nil
}
func handleEvent(event string, payload []byte) error {
switch event {
case "ui_message":
var msg struct {
Message string `json:"message"`
}
_ = json.Unmarshal(payload, &msg)
log.Printf("[sample] got ui_message: %s", msg.Message)
sendEvent("echo", map[string]string{"message": msg.Message})
case "ping":
sendEvent("pong", nil)
default:
log.Printf("[sample] unhandled event: %s", event)
}
return nil
}
func handleUnload() {
log.Printf("[sample] unloading")
}
+92
View File
@@ -0,0 +1,92 @@
/* Code generated by cmd/cgo; DO NOT EDIT. */
/* package sample-plugin */
#line 1 "cgo-builtin-export-prolog"
#include <stddef.h>
#ifndef GO_CGO_EXPORT_PROLOGUE_H
#define GO_CGO_EXPORT_PROLOGUE_H
#ifndef GO_CGO_GOSTRING_TYPEDEF
typedef struct { const char *p; ptrdiff_t n; } _GoString_;
extern size_t _GoStringLen(_GoString_ s);
extern const char *_GoStringPtr(_GoString_ s);
#endif
#endif
/* Start of preamble from import "C" comments. */
/* End of preamble from import "C" comments. */
/* Start of boilerplate cgo prologue. */
#line 1 "cgo-gcc-export-header-prolog"
#ifndef GO_CGO_PROLOGUE_H
#define GO_CGO_PROLOGUE_H
typedef signed char GoInt8;
typedef unsigned char GoUint8;
typedef short GoInt16;
typedef unsigned short GoUint16;
typedef int GoInt32;
typedef unsigned int GoUint32;
typedef long long GoInt64;
typedef unsigned long long GoUint64;
typedef GoInt64 GoInt;
typedef GoUint64 GoUint;
typedef size_t GoUintptr;
typedef float GoFloat32;
typedef double GoFloat64;
#ifdef _MSC_VER
#if !defined(__cplusplus) || _MSVC_LANG <= 201402L
#include <complex.h>
typedef _Fcomplex GoComplex64;
typedef _Dcomplex GoComplex128;
#else
#include <complex>
typedef std::complex<float> GoComplex64;
typedef std::complex<double> GoComplex128;
#endif
#else
typedef float _Complex GoComplex64;
typedef double _Complex GoComplex128;
#endif
/*
static assertion to make sure the file is being used on architecture
at least with matching size of GoInt.
*/
typedef char _check_for_64_bit_pointer_matching_GoInt[sizeof(void*)==64/8 ? 1:-1];
#ifndef GO_CGO_GOSTRING_TYPEDEF
typedef _GoString_ GoString;
#endif
typedef void *GoMap;
typedef void *GoChan;
typedef struct { void *t; void *v; } GoInterface;
typedef struct { void *data; GoInt len; GoInt cap; } GoSlice;
#endif
/* End of boilerplate cgo prologue. */
#ifdef __cplusplus
extern "C" {
#endif
extern __declspec(dllexport) void PluginSetCallback(long long unsigned int cb);
extern __declspec(dllexport) int PluginOnLoad(char* hostInfo, int hostInfoLen, long long unsigned int cb);
extern __declspec(dllexport) int PluginOnEvent(char* event, int eventLen, char* payload, int payloadLen);
extern __declspec(dllexport) void PluginOnUnload(void);
#ifdef __cplusplus
}
#endif
+1 -1
View File
@@ -10,7 +10,7 @@
<main class="container">
<header class="header">
<h1>Sample Plugin</h1>
<p class="sub">Go WASM + UI plugin</p>
<p class="sub">Go Native + UI plugin</p>
</header>
<section class="card">
-5
View File
@@ -1,5 +0,0 @@
module plugin-sample-go/wasm
go 1.21
require github.com/vmihailenco/msgpack/v5 v5.4.1
-76
View File
@@ -1,76 +0,0 @@
package main
import (
"log"
"os"
"github.com/vmihailenco/msgpack/v5"
)
type Incoming struct {
Type string `msgpack:"type"`
Event string `msgpack:"event,omitempty"`
Payload interface{} `msgpack:"payload,omitempty"`
}
type Outgoing struct {
Type string `msgpack:"type"`
Event string `msgpack:"event,omitempty"`
Payload interface{} `msgpack:"payload,omitempty"`
}
type UiMessage struct {
Message string `msgpack:"message"`
}
type HostInfo struct {
ClientID string `msgpack:"clientId"`
OS string `msgpack:"os"`
Arch string `msgpack:"arch"`
Version string `msgpack:"version"`
}
type InitPayload struct {
Host HostInfo `msgpack:"host"`
}
func main() {
dec := msgpack.NewDecoder(os.Stdin)
enc := msgpack.NewEncoder(os.Stdout)
for {
var msg Incoming
if err := dec.Decode(&msg); err != nil {
log.Printf("decode error: %v", err)
return
}
switch msg.Type {
case "init":
var init InitPayload
if payloadBytes, err := msgpack.Marshal(msg.Payload); err == nil {
_ = msgpack.Unmarshal(payloadBytes, &init)
}
osName := init.Host.OS
if osName == "" {
osName = "unknown"
}
if osName == "windows" {
log.Printf("[sample] windows detected - you can use WinAPI-specific flows")
_ = enc.Encode(Outgoing{Type: "event", Event: "ready", Payload: "sample plugin ready (windows detected)"})
} else {
log.Printf("[sample] non-windows host detected (%s)", osName)
_ = enc.Encode(Outgoing{Type: "event", Event: "ready", Payload: "sample plugin ready"})
}
case "event":
if msg.Event == "ui_message" {
payloadBytes, _ := msgpack.Marshal(msg.Payload)
var ui UiMessage
_ = msgpack.Unmarshal(payloadBytes, &ui)
_ = enc.Encode(Outgoing{Type: "event", Event: "echo", Payload: ui.Message})
}
}
}
}