diff --git a/Overlord-Client/cmd/agent/plugins/manager.go b/Overlord-Client/cmd/agent/plugins/manager.go index 792e40c..f4ce059 100644 --- a/Overlord-Client/cmd/agent/plugins/manager.go +++ b/Overlord-Client/cmd/agent/plugins/manager.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "log" + "strings" "sync" "time" @@ -49,13 +50,11 @@ func (m *Manager) Load(ctx context.Context, manifest PluginManifest, binary []by } m.mu.Unlock() - var np PluginRuntime - var err error - if manifest.RuntimeKind == "wasm" || manifest.WASM != "" { - np, err = loadWASMPlugin(ctx, manifest, binary) - } else { - np, err = loadNativePlugin(manifest, binary) + if strings.EqualFold(strings.TrimSpace(manifest.RuntimeKind), "wasm") { + return errors.New("WASM plugins are not supported") } + + np, err := loadNativePlugin(manifest, binary) if err != nil { return err } diff --git a/Overlord-Client/cmd/agent/plugins/types.go b/Overlord-Client/cmd/agent/plugins/types.go index 27d2760..bb2d549 100644 --- a/Overlord-Client/cmd/agent/plugins/types.go +++ b/Overlord-Client/cmd/agent/plugins/types.go @@ -1,6 +1,9 @@ package plugins -import "errors" +import ( + "errors" + "strings" +) type PluginAssets struct { HTML string `msgpack:"html" json:"html"` @@ -19,16 +22,10 @@ type PluginManifest struct { Description string `msgpack:"description,omitempty" json:"description,omitempty"` Binary string `msgpack:"binary,omitempty" json:"binary,omitempty"` Binaries map[string]string `msgpack:"binaries,omitempty" json:"binaries,omitempty"` - WASM string `msgpack:"wasm,omitempty" json:"wasm,omitempty"` - Needs PluginNeeds `msgpack:"needs,omitempty" json:"needs,omitempty"` Entry string `msgpack:"entry,omitempty" json:"entry,omitempty"` Assets PluginAssets `msgpack:"assets,omitempty" json:"assets,omitempty"` } -type PluginNeeds struct { - Files []PluginFileNeed `msgpack:"files,omitempty" json:"files,omitempty"` -} - type NativeEntrypoints struct { OnLoad string `msgpack:"onLoad,omitempty" json:"onLoad,omitempty"` OnEvent string `msgpack:"onEvent,omitempty" json:"onEvent,omitempty"` @@ -37,12 +34,6 @@ type NativeEntrypoints struct { GetRuntime string `msgpack:"getRuntime,omitempty" json:"getRuntime,omitempty"` } -type PluginFileNeed struct { - Bucket string `msgpack:"bucket" json:"bucket"` - Access []string `msgpack:"access" json:"access"` - Reason string `msgpack:"reason,omitempty" json:"reason,omitempty"` -} - type PluginMessage struct { Type string `msgpack:"type"` Event string `msgpack:"event,omitempty"` @@ -72,6 +63,10 @@ type PluginRuntime interface { type NativePlugin = PluginRuntime func ManifestFromMap(m map[string]interface{}) (PluginManifest, error) { + if runtime := stringVal(m["runtime"]); strings.EqualFold(strings.TrimSpace(runtime), "wasm") || strings.TrimSpace(stringVal(m["wasm"])) != "" { + return PluginManifest{}, errors.New("WASM plugins are not supported") + } + manifest := PluginManifest{} manifest.ID = stringVal(m["id"]) manifest.Name = stringVal(m["name"]) @@ -81,7 +76,6 @@ func ManifestFromMap(m map[string]interface{}) (PluginManifest, error) { manifest.Version = stringVal(m["version"]) manifest.Description = stringVal(m["description"]) manifest.Binary = stringVal(m["binary"]) - manifest.WASM = stringVal(m["wasm"]) manifest.Entry = stringVal(m["entry"]) if entryRaw, ok := m["nativeEntrypoints"].(map[string]interface{}); ok { manifest.NativeEntrypoints = NativeEntrypoints{ @@ -109,28 +103,6 @@ func ManifestFromMap(m map[string]interface{}) (PluginManifest, error) { JS: stringVal(assetsRaw["js"]), } } - if needsRaw, ok := m["needs"].(map[string]interface{}); ok { - if filesRaw, ok := needsRaw["files"].([]interface{}); ok { - for _, item := range filesRaw { - fileRaw, ok := item.(map[string]interface{}) - if !ok { - continue - } - need := PluginFileNeed{Bucket: stringVal(fileRaw["bucket"]), Reason: stringVal(fileRaw["reason"])} - if accessRaw, ok := fileRaw["access"].([]interface{}); ok { - for _, v := range accessRaw { - if s, ok := v.(string); ok && s != "" { - need.Access = append(need.Access, s) - } - } - } - if need.Bucket != "" && len(need.Access) > 0 { - manifest.Needs.Files = append(manifest.Needs.Files, need) - } - } - } - } - if manifest.ID == "" { return PluginManifest{}, errors.New("missing plugin id") } diff --git a/Overlord-Client/cmd/agent/plugins/types_test.go b/Overlord-Client/cmd/agent/plugins/types_test.go new file mode 100644 index 0000000..4b75ae9 --- /dev/null +++ b/Overlord-Client/cmd/agent/plugins/types_test.go @@ -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) + } +} diff --git a/Overlord-Client/cmd/agent/plugins/wasm_runtime.go b/Overlord-Client/cmd/agent/plugins/wasm_runtime.go deleted file mode 100644 index d2cf6e7..0000000 --- a/Overlord-Client/cmd/agent/plugins/wasm_runtime.go +++ /dev/null @@ -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)) -} diff --git a/Overlord-Client/cmd/agent/plugins/wasm_runtime_test.go b/Overlord-Client/cmd/agent/plugins/wasm_runtime_test.go deleted file mode 100644 index 87d2f3f..0000000 --- a/Overlord-Client/cmd/agent/plugins/wasm_runtime_test.go +++ /dev/null @@ -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) - } -} diff --git a/Overlord-Client/go.mod b/Overlord-Client/go.mod index 8f52468..c643339 100644 --- a/Overlord-Client/go.mod +++ b/Overlord-Client/go.mod @@ -21,7 +21,6 @@ require ( github.com/pion/rtcp v1.2.15 github.com/pion/rtp v1.8.10 github.com/pion/webrtc/v4 v4.0.7 - github.com/tetratelabs/wazero v1.12.0 github.com/thesyncim/gopus v0.1.1 golang.org/x/crypto v0.49.0 golang.org/x/sys v0.44.0 diff --git a/Overlord-Client/go.sum b/Overlord-Client/go.sum index 926f288..35c2192 100644 --- a/Overlord-Client/go.sum +++ b/Overlord-Client/go.sum @@ -77,8 +77,6 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU= -github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0= github.com/thesyncim/gopus v0.1.1 h1:SM58S7VZGnEZ3ZR0pX5AoC33//LBxJhKkvm15MbBmik= github.com/thesyncim/gopus v0.1.1/go.mod h1:orRqwrGs5gqYRRnhqwI0Y3liqQTeDkreUpra+Kv9bQc= github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= diff --git a/Overlord-Server/public/assets/plugins.js b/Overlord-Server/public/assets/plugins.js index fb9f08a..a23b00d 100644 --- a/Overlord-Server/public/assets/plugins.js +++ b/Overlord-Server/public/assets/plugins.js @@ -146,24 +146,19 @@ function renderPlugins(plugins) { titleRow.appendChild(trustBadge); const runtimeBadge = document.createElement("span"); - const isWasm = plugin.runtime === "wasm"; const isV2 = Number(plugin.apiVersion || 1) >= 2; runtimeBadge.className = `inline-flex items-center gap-1 text-[10px] uppercase tracking-wider px-2 py-0.5 rounded-full border ${ isServerOnly ? "text-fuchsia-200 border-fuchsia-700 bg-fuchsia-950/50" - : isWasm - ? "text-cyan-300 border-cyan-700 bg-cyan-950/40" : isV2 ? "text-emerald-300 border-emerald-700 bg-emerald-950/40" : "text-slate-300 border-slate-700 bg-slate-900/60" }`; runtimeBadge.innerHTML = isServerOnly ? ' Server Extension' - : isWasm - ? ' WASM 2.0' : isV2 ? ' Plugin 2.0' - : ' Native Legacy'; + : ' Native'; titleRow.appendChild(runtimeBadge); const subtitle = document.createElement("div"); diff --git a/Overlord-Server/src/protocol.ts b/Overlord-Server/src/protocol.ts index bddb1a5..65847f8 100644 --- a/Overlord-Server/src/protocol.ts +++ b/Overlord-Server/src/protocol.ts @@ -343,7 +343,7 @@ export type PluginManifest = { id: string; name: string; apiVersion?: number; - runtime?: "native" | "wasm" | "server" | string; + runtime?: "native" | "server" | string; nativeLoader?: "memory" | "os" | string; autoLoadByDefault?: boolean; nativeEntrypoints?: PluginNativeEntrypoints; @@ -351,7 +351,6 @@ export type PluginManifest = { description?: string; binary?: string; binaries?: Record; - wasm?: string; needs?: PluginNeeds; entry?: string; assets?: { diff --git a/Overlord-Server/src/server/plugin-state-bundle.test.ts b/Overlord-Server/src/server/plugin-state-bundle.test.ts index 23b9636..242c28b 100644 --- a/Overlord-Server/src/server/plugin-state-bundle.test.ts +++ b/Overlord-Server/src/server/plugin-state-bundle.test.ts @@ -45,7 +45,7 @@ describe("loadPluginBundle", () => { expect(bundle.size).toBe("test-binary".length); }); - test("loads wasm manifest independently of client platform", async () => { + test("rejects an extracted WASM manifest", async () => { const root = await createTempRoot(); const pluginDir = join(root, "wasm-demo"); await mkdir(pluginDir, { recursive: true }); @@ -62,13 +62,23 @@ describe("loadPluginBundle", () => { }), ); - const bundle = await loadPluginBundle(root, "wasm-demo", async () => {}, "Windows 11", "amd64"); + await expect(loadPluginBundle(root, "wasm-demo", async () => {}, "Windows 11", "amd64")) + .rejects.toThrow("WASM plugins are not supported"); + }); - expect(bundle.manifest.apiVersion).toBe(2); - expect(bundle.manifest.runtime).toBe("wasm"); - expect(bundle.manifest.wasm).toBe("plugin.wasm"); - expect(bundle.binaryPath).toBe(join(root, "wasm-demo", "plugin.wasm")); - expect(bundle.size).toBe(4); + test("rejects a bundle containing a WASM module", async () => { + const root = await createTempRoot(); + const AdmZip = require("adm-zip"); + const zip = new AdmZip(); + zip.addFile("config.json", Buffer.from(JSON.stringify({ name: "WASM Demo", runtime: "wasm" }))); + zip.addFile("wasm-demo.html", Buffer.from("
")); + 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 () => { diff --git a/Overlord-Server/src/server/plugin-state-bundle.ts b/Overlord-Server/src/server/plugin-state-bundle.ts index bc9452f..76a7ef1 100644 --- a/Overlord-Server/src/server/plugin-state-bundle.ts +++ b/Overlord-Server/src/server/plugin-state-bundle.ts @@ -66,11 +66,15 @@ export async function ensurePluginExtracted( } if (!zipStat) { - if (manifestStat) return; + if (manifestStat) { + await rejectUnsupportedWasmManifest(manifestPath, safeId); + return; + } throw new Error(`Plugin bundle not found: ${safeId}`); } if (manifestStat && manifestStat.mtimeMs >= zipStat.mtimeMs) { + await rejectUnsupportedWasmManifest(manifestPath, safeId); return; } @@ -101,7 +105,6 @@ export async function ensurePluginExtracted( let jsEntry: Buffer | null = null; let serverEntry: Buffer | null = null; let configEntry: Buffer | null = null; - let wasmEntry: { filename: string; data: Buffer } | null = null; const sourceEntries: Map = new Map(); const nativeBinaries: Map = new Map(); @@ -113,7 +116,7 @@ export async function ensurePluginExtracted( if (lower.endsWith(".so") || lower.endsWith(".dll") || lower.endsWith(".dylib")) { nativeBinaries.set(base, entry.getData()); } else if (lower.endsWith(".wasm")) { - wasmEntry = { filename: base, data: entry.getData() }; + throw new Error(`Invalid plugin bundle: ${safeId} (WASM plugins are not supported)`); } else if (lower === "server.js") { serverEntry = entry.getData(); } else if (lower.endsWith(".html")) { @@ -139,6 +142,10 @@ export async function ensurePluginExtracted( } } + if (declaresWasmPlugin(extraConfig)) { + throw new Error(`Invalid plugin bundle: ${safeId} (WASM plugins are not supported)`); + } + const uiTsEntry = getConfiguredSourceEntry(extraConfig, ["uiEntry", "ui.entry", "build.ui", "build.uiEntry"], sourceEntries) || (sourceEntries.has("src/ui.ts") ? "src/ui.ts" : "") || (sourceEntries.has("src/index.ts") ? "src/index.ts" : ""); @@ -163,11 +170,6 @@ export async function ensurePluginExtracted( binariesMap[platformKey] = filename; } } - if (wasmEntry) { - await fs.writeFile(path.join(pluginDir, wasmEntry.filename), wasmEntry.data); - binariesMap["wasm32-wasi"] = wasmEntry.filename; - } - if (sourceEntries.size > 0) { await fs.rm(path.join(pluginDir, "src"), { recursive: true, force: true }); for (const [relPath, data] of sourceEntries) { @@ -199,7 +201,7 @@ export async function ensurePluginExtracted( try { await fs.rm(serverScriptPath, { force: true }); } catch {} } - const runtime = normalizePluginRuntime(extraConfig.runtime, wasmEntry !== null); + const runtime = normalizePluginRuntime(extraConfig.runtime); const nativeLoader = normalizeNativeLoader(extraConfig.nativeLoader); const nativeEntrypoints = normalizeNativeEntrypoints(extraConfig.nativeEntrypoints); const needs = normalizePluginNeeds(extraConfig.needs); @@ -207,7 +209,7 @@ export async function ensurePluginExtracted( const manifest: PluginManifest = { id: safeId, name: extraConfig.name || safeId, - apiVersion: Number(extraConfig.apiVersion) === 2 || runtime === "wasm" ? 2 : 1, + apiVersion: Number(extraConfig.apiVersion) === 2 ? 2 : 1, runtime, ...(nativeLoader && { nativeLoader }), ...(extraConfig.autoLoadByDefault === true && { autoLoadByDefault: true }), @@ -215,7 +217,6 @@ export async function ensurePluginExtracted( version: extraConfig.version || "1.0.0", description: extraConfig.description, ...(typeof extraConfig.binary === "string" && extraConfig.binary && { binary: extraConfig.binary }), - ...(runtime === "wasm" && { wasm: String(extraConfig.wasm || wasmEntry?.filename || binariesMap["wasm32-wasi"] || "") }), ...(needs && { needs }), binaries: binariesMap, entry: `${safeId}.html`, @@ -280,6 +281,10 @@ export async function listPluginManifests( try { const raw = await fs.readFile(manifestPath, "utf-8"); const manifest = JSON.parse(raw) as PluginManifest; + if (declaresWasmPlugin(manifest)) { + warnPlugin(`[plugin] skipped ${ent.name}: WASM plugins are not supported`); + continue; + } const id = manifest.id || ent.name; const name = manifest.name || ent.name; if (pluginState.enabled[id] === undefined) { @@ -318,14 +323,8 @@ export async function loadPluginBundle( manifest.id = manifest.id || pluginId; manifest.name = manifest.name || pluginId; - if (manifest.runtime === "wasm" || manifest.wasm || manifest.binaries?.["wasm32-wasi"]) { - const wasmName = manifest.wasm || manifest.binaries?.["wasm32-wasi"]; - if (!wasmName) { - throw new Error(`No WASM module configured for ${pluginId}`); - } - const binaryPath = path.join(dir, wasmName); - const stat = await fs.stat(binaryPath); - return { manifest: { ...manifest, runtime: "wasm", apiVersion: 2 }, binaryPath, size: stat.size }; + if (declaresWasmPlugin(manifest)) { + throw new Error(`WASM plugins are not supported: ${pluginId}`); } manifest.runtime = manifest.runtime || "native"; @@ -632,13 +631,30 @@ export function normalizePluginNeeds(raw: any): PluginNeeds | undefined { return files.length > 0 ? { files } : undefined; } -function normalizePluginRuntime(runtime: any, hasWasm: boolean): "native" | "wasm" | "server" { +function normalizePluginRuntime(runtime: any): "native" | "server" { const value = typeof runtime === "string" ? runtime.trim().toLowerCase() : ""; - if (value === "wasm" || hasWasm) return "wasm"; if (value === "server") return "server"; return "native"; } +function declaresWasmPlugin(value: any): boolean { + if (!value || typeof value !== "object") return false; + if (typeof value.runtime === "string" && value.runtime.trim().toLowerCase() === "wasm") return true; + if (typeof value.wasm === "string" && value.wasm.trim()) return true; + if (!value.binaries || typeof value.binaries !== "object" || Array.isArray(value.binaries)) return false; + return Object.entries(value.binaries).some(([key, filename]) => + key.trim().toLowerCase().includes("wasm") + || (typeof filename === "string" && filename.trim().toLowerCase().endsWith(".wasm")) + ); +} + +async function rejectUnsupportedWasmManifest(manifestPath: string, pluginId: string): Promise { + const manifest = JSON.parse(await fs.readFile(manifestPath, "utf-8")); + if (declaresWasmPlugin(manifest)) { + throw new Error(`WASM plugins are not supported: ${pluginId}`); + } +} + function normalizeNativeLoader(loader: any): "memory" | "os" | undefined { const value = typeof loader === "string" ? loader.trim().toLowerCase() : ""; if (["memory", "mem", "reflective", "manual"].includes(value)) return "memory"; diff --git a/plugins/PLUGINS.md b/plugins/PLUGINS.md index 1568058..ac49ac2 100644 --- a/plugins/PLUGINS.md +++ b/plugins/PLUGINS.md @@ -1,6 +1,6 @@ # Overlord Plugins -This is the short entry point for plugin authors. The detailed docs are split by topic so server-only build extensions, WASM agent plugins, legacy native plugins, signing, and examples can evolve independently. +This is the short entry point for plugin authors. The detailed docs are split by topic so server-only build extensions, native agent plugins, signing, and examples can evolve independently. ## Quick Start @@ -12,7 +12,7 @@ A Plugin 2.0 bundle is a zip with a root `config.json`, browser assets, and opti .html .css .js optional when src/ui.ts exists - .wasm optional agent-side WASM module + --.* optional native agent library server.js optional server-side runtime src/ optional TypeScript source ``` @@ -25,19 +25,16 @@ Upload the zip from the Plugins page, or place it under `Overlord-Server/plugins |-------|-----------| | Bundle layout, `config.json`, TypeScript UI/server entries | [docs/bundle-format.md](docs/bundle-format.md) | | Server-only plugins, plugin data, RPC/SSE, build hooks, build settings/buttons | [docs/server-side-plugins.md](docs/server-side-plugins.md) | -| WASM Plugin 2.0 agent modules and permissioned file bridges | [docs/wasm-plugins.md](docs/wasm-plugins.md) | | Install/upload flow, Plugin Manager behavior, auto-load rules | [docs/install-and-manage.md](docs/install-and-manage.md) | | Sample plugin directory guide | [docs/samples.md](docs/samples.md) | -| Legacy native plugins: OS APIs, native ABI, build scripts, Linux static-agent shim | [docs/legacy-native-plugins.md](docs/legacy-native-plugins.md) | +| Native plugins: OS APIs, native ABI, build scripts, Linux static-agent shim | [docs/legacy-native-plugins.md](docs/legacy-native-plugins.md) | | Plugin signing and trusted keys | [docs/signing.md](docs/signing.md) | ## Runtime Choices Use `runtime: "server"` for server-side extensions. These can add server UI, store files, expose RPCs, subscribe to plugin events, and hook the build pipeline. Server-only plugins are shown as server extensions in the Plugin Manager, are not sent to clients, and do not use auto-load. -Use `runtime: "wasm"` for Plugin 2.0 agent code. WASM plugins are sandboxed on the agent and access host features only through declared needs and Overlord bridge APIs. - -Use legacy native plugins when you need direct OS APIs, native libraries, platform-specific behavior, or other privileged agent-side functionality that the WASM sandbox does not expose. Native plugins run with the agent's privileges and require one binary per OS/architecture. +Use `runtime: "native"` for agent-side plugins that need direct OS APIs, native libraries, or platform-specific behavior. Native plugins run with the agent's privileges and require one binary per OS/architecture. ## Current Samples @@ -45,9 +42,6 @@ Use legacy native plugins when you need direct OS APIs, native libraries, platfo |-----------|---------| | `sample-build-hooks` | Server-only build plugin with its own Build page button and artifact replacement example. | | `sample-ts-fullstack` | TypeScript UI plus TypeScript server runtime. | -| `sample-wasm` | C/WASI Plugin 2.0 WASM example. | -| `sample-wasm-hostinfo` | TinyGo WASM HostInfo example. | -| `sample-wasm-platform-note` | Rust WASM platform-aware example. | -| `sample-c`, `sample-cpp`, `sample-go`, `sample-rust` | Legacy native v1 examples. | +| `sample-c`, `sample-cpp`, `sample-go`, `sample-rust` | Native agent plugin examples. | Start with [docs/server-side-plugins.md](docs/server-side-plugins.md) if you are building uploaders, custom build buttons, artifact post-processing, or internal release hooks. diff --git a/plugins/docs/README.md b/plugins/docs/README.md index 771fa27..4305b01 100644 --- a/plugins/docs/README.md +++ b/plugins/docs/README.md @@ -6,8 +6,7 @@ These docs are split from `plugins/PLUGINS.md` so each plugin area has a smaller |------|--------| | [bundle-format.md](bundle-format.md) | Bundle layout, manifest fields, nav entries, TypeScript entrypoints. | | [server-side-plugins.md](server-side-plugins.md) | Server-only plugin runtime, RPC/SSE, persistent data, build hooks, build UI settings/actions. | -| [wasm-plugins.md](wasm-plugins.md) | Plugin 2.0 WASM runtime and agent-side host bridges. | | [install-and-manage.md](install-and-manage.md) | Uploading, opening, enabling, auto-load, Plugin Manager behavior. | | [samples.md](samples.md) | Included sample plugins and what each demonstrates. | -| [legacy-native-plugins.md](legacy-native-plugins.md) | Legacy native plugins, direct OS/API use, native ABI, build scripts, static Linux shim. | +| [legacy-native-plugins.md](legacy-native-plugins.md) | Native plugins, direct OS/API use, native ABI, build scripts, static Linux shim. | | [signing.md](signing.md) | Ed25519 plugin signing and trusted key management. | diff --git a/plugins/docs/bundle-format.md b/plugins/docs/bundle-format.md index 67a5252..46f9fa2 100644 --- a/plugins/docs/bundle-format.md +++ b/plugins/docs/bundle-format.md @@ -10,7 +10,7 @@ Plugin bundles are zip files named after the plugin ID. `config.json` must be at .html .css .js optional when src/ui.ts exists - .wasm optional agent-side WASM module + --.* optional native agent library server.js optional server runtime src/ ui.ts compiled to assets/.js @@ -25,8 +25,7 @@ After extraction the server stores assets under `Overlord-Server/plugins//autoload` | Configure auto-load for client-side plugins. Server-only plugins reject this. | | `POST /api/plugins//rpc` | Call a server-side plugin RPC method. | | `GET /api/plugins//stream` | Subscribe to server-side plugin broadcasts. | diff --git a/plugins/docs/legacy-native-plugins.md b/plugins/docs/legacy-native-plugins.md index e98d4ac..ce65948 100644 --- a/plugins/docs/legacy-native-plugins.md +++ b/plugins/docs/legacy-native-plugins.md @@ -1,12 +1,12 @@ -# Legacy Native Plugins +# Native Plugins -Legacy native v1 plugins run as shared libraries inside the agent. They are still supported and still useful when you need capabilities that the WASM runtime intentionally does not expose. +Native plugins run as shared libraries inside the agent. -Use legacy native plugins when you need direct OS APIs, native libraries, custom process/thread behavior, platform-specific integrations, or code that cannot reasonably be compiled to the Plugin 2.0 WASM ABI. +Use native plugins when you need direct OS APIs, native libraries, custom process/thread behavior, or platform-specific integrations. Native plugins run with the same privileges as the agent. They are not sandboxed. -## What Legacy Plugins Can Do +## What Native Plugins Can Do | Capability | Notes | |------------|-------| @@ -16,11 +16,9 @@ Native plugins run with the same privileges as the agent. They are not sandboxed | Emit plugin events | Send events back to the UI/server through the host callback. | | Receive UI/server commands | Handle `PluginOnEvent` calls from plugin UI actions or server-side routing. | -WASM Plugin 2.0 is preferred for sandboxed, portable agent modules. Legacy native plugins are the escape hatch for privileged native behavior. - ## Bundle Layout -A legacy bundle is a zip with web assets plus one or more platform binaries: +A native bundle is a zip with web assets plus one or more platform binaries: ```text .zip @@ -253,7 +251,7 @@ Most plugin authors can ignore this, but it is useful when debugging Linux nativ ## Build Scripts -Legacy samples include `build.bat` and `build.sh`. They compile native binaries and zip them with web assets. +Native samples include `build.bat` and `build.sh`. They compile native binaries and zip them with web assets. Default usage: @@ -316,4 +314,4 @@ The native plugin can reply by invoking the host callback. Those callback events ## Security Notes -Legacy native plugins are trusted executable code. They can call OS APIs, read/write files available to the agent process, spawn subprocesses, open sockets, and interact with native libraries. Use plugin signing, per-user plugin access, and code review before deploying third-party native plugins. +Native plugins are trusted executable code. They can call OS APIs, read/write files available to the agent process, spawn subprocesses, open sockets, and interact with native libraries. Use plugin signing, per-user plugin access, and code review before deploying third-party native plugins. diff --git a/plugins/docs/samples.md b/plugins/docs/samples.md index 1321d33..2db6588 100644 --- a/plugins/docs/samples.md +++ b/plugins/docs/samples.md @@ -6,12 +6,9 @@ The `plugins/` directory includes small examples that exercise each plugin style |-----------|--------------| | `sample-build-hooks` | Server-only build plugin, custom Build page action, build settings, and replacing the produced artifact with a `.txt` file. | | `sample-ts-fullstack` | TypeScript UI and TypeScript server runtime with shared local modules. | -| `sample-wasm` | Plugin 2.0 WASM module built from C/WASI. | -| `sample-wasm-hostinfo` | TinyGo WASM plugin that queries HostInfo. | -| `sample-wasm-platform-note` | Rust WASM plugin that branches by HostInfo/platform. | -| `sample-c` | Legacy native C plugin for the smallest unloadable ABI surface. | -| `sample-cpp` | Legacy native C++ plugin using exported C ABI functions. | -| `sample-go` | Legacy native Go plugin. Go plugins are intentionally not unloaded because the Go runtime cannot be safely unloaded from shared libraries. | -| `sample-rust` | Legacy native Rust plugin with C ABI exports and unload-friendly runtime handling. | +| `sample-c` | Native C plugin for the smallest unloadable ABI surface. | +| `sample-cpp` | Native C++ plugin using exported C ABI functions. | +| `sample-go` | Native Go plugin. Go plugins are intentionally not unloaded because the Go runtime cannot be safely unloaded from shared libraries. | +| `sample-rust` | Native Rust plugin with C ABI exports and unload-friendly runtime handling. | Use `sample-build-hooks` as the starting point for uploaders, artifact publishers, or custom release buttons. diff --git a/plugins/docs/wasm-plugins.md b/plugins/docs/wasm-plugins.md deleted file mode 100644 index b2697e0..0000000 --- a/plugins/docs/wasm-plugins.md +++ /dev/null @@ -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 | diff --git a/plugins/sample-ts-fullstack/build.bat b/plugins/sample-ts-fullstack/build.bat index 1614f8f..e34e882 100644 --- a/plugins/sample-ts-fullstack/build.bat +++ b/plugins/sample-ts-fullstack/build.bat @@ -4,8 +4,6 @@ setlocal enabledelayedexpansion set "PLUGIN_DIR=%~dp0" set "PLUGIN_NAME=sample-ts-fullstack" -set "NATIVE_DIR=%PLUGIN_DIR%\native" -set "WASM_OUT=%PLUGIN_DIR%\%PLUGIN_NAME%.wasm" set "ZIP_OUT=%PLUGIN_DIR%\%PLUGIN_NAME%.zip" if not exist "%PLUGIN_DIR%\config.json" ( @@ -13,32 +11,10 @@ if not exist "%PLUGIN_DIR%\config.json" ( exit /b 1 ) -set "WASM_SOURCE=" -if exist "%NATIVE_DIR%" ( - for %%S in ("%NATIVE_DIR%\*.c") do ( - if not defined WASM_SOURCE set "WASM_SOURCE=%%~fS" - ) -) - -if defined WASM_SOURCE ( - where clang >nul 2>&1 - if errorlevel 1 ( - echo [error] clang was not found. Install a WASI-capable clang or provide %WASM_OUT% yourself. - exit /b 1 - ) - - echo [build] clang --target=wasm32-wasi -O2 "!WASM_SOURCE!" ^> "%WASM_OUT%" - clang --target=wasm32-wasi -O2 -Wl,--no-entry -Wl,--allow-undefined -Wl,--export=overlord_alloc -Wl,--export=overlord_free -Wl,--export=overlord_on_load -Wl,--export=overlord_on_event -Wl,--export=overlord_on_unload -o "%WASM_OUT%" "!WASM_SOURCE!" - if errorlevel 1 ( - echo [error] WASM build failed - exit /b 1 - ) -) - if exist "%ZIP_OUT%" del /f /q "%ZIP_OUT%" pushd "%PLUGIN_DIR%" -powershell -NoProfile -ExecutionPolicy Bypass -Command "$zip = '%ZIP_OUT%'; $items = @(); foreach ($file in @('config.json', '%PLUGIN_NAME%.html', '%PLUGIN_NAME%.css', '%PLUGIN_NAME%.js', '%PLUGIN_NAME%.wasm', 'server.js')) { if (Test-Path -LiteralPath $file -PathType Leaf) { $items += $file } }; foreach ($dir in @('src', 'assets')) { if (Test-Path -LiteralPath $dir -PathType Container) { $items += $dir } }; if ($items.Count -eq 0) { throw 'No plugin files found to package' }; Compress-Archive -Path $items -DestinationPath $zip -Force" +powershell -NoProfile -ExecutionPolicy Bypass -Command "$zip = '%ZIP_OUT%'; $items = @(); foreach ($file in @('config.json', '%PLUGIN_NAME%.html', '%PLUGIN_NAME%.css', '%PLUGIN_NAME%.js', 'server.js')) { if (Test-Path -LiteralPath $file -PathType Leaf) { $items += $file } }; foreach ($dir in @('src', 'assets')) { if (Test-Path -LiteralPath $dir -PathType Container) { $items += $dir } }; if ($items.Count -eq 0) { throw 'No plugin files found to package' }; Compress-Archive -Path $items -DestinationPath $zip -Force" set "ZIP_STATUS=%ERRORLEVEL%" popd if not "%ZIP_STATUS%"=="0" exit /b %ZIP_STATUS% diff --git a/plugins/sample-wasm-hostinfo/README.md b/plugins/sample-wasm-hostinfo/README.md deleted file mode 100644 index fd4640f..0000000 --- a/plugins/sample-wasm-hostinfo/README.md +++ /dev/null @@ -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`. diff --git a/plugins/sample-wasm-hostinfo/build.bat b/plugins/sample-wasm-hostinfo/build.bat deleted file mode 100644 index 7be4707..0000000 --- a/plugins/sample-wasm-hostinfo/build.bat +++ /dev/null @@ -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% diff --git a/plugins/sample-wasm-hostinfo/native/main.go b/plugins/sample-wasm-hostinfo/native/main.go deleted file mode 100644 index d66ec4f..0000000 --- a/plugins/sample-wasm-hostinfo/native/main.go +++ /dev/null @@ -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() {} diff --git a/plugins/sample-wasm-hostinfo/sample-wasm-hostinfo.css b/plugins/sample-wasm-hostinfo/sample-wasm-hostinfo.css deleted file mode 100644 index 7203fb6..0000000 --- a/plugins/sample-wasm-hostinfo/sample-wasm-hostinfo.css +++ /dev/null @@ -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; -} diff --git a/plugins/sample-wasm-hostinfo/sample-wasm-hostinfo.html b/plugins/sample-wasm-hostinfo/sample-wasm-hostinfo.html deleted file mode 100644 index 3a469d7..0000000 --- a/plugins/sample-wasm-hostinfo/sample-wasm-hostinfo.html +++ /dev/null @@ -1,12 +0,0 @@ -
-

Sample WASM Host Info

- -
Waiting for host info
-
-
Client-
-
Version-
-
OS-
-
Arch-
-
-

-
diff --git a/plugins/sample-wasm-hostinfo/sample-wasm-hostinfo.js b/plugins/sample-wasm-hostinfo/sample-wasm-hostinfo.js deleted file mode 100644 index ce71590..0000000 --- a/plugins/sample-wasm-hostinfo/sample-wasm-hostinfo.js +++ /dev/null @@ -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); - } -})(); diff --git a/plugins/sample-wasm-platform-note/README.md b/plugins/sample-wasm-platform-note/README.md deleted file mode 100644 index cf4eec7..0000000 --- a/plugins/sample-wasm-platform-note/README.md +++ /dev/null @@ -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`. diff --git a/plugins/sample-wasm-platform-note/build.bat b/plugins/sample-wasm-platform-note/build.bat deleted file mode 100644 index c4d4cd3..0000000 --- a/plugins/sample-wasm-platform-note/build.bat +++ /dev/null @@ -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% diff --git a/plugins/sample-wasm-platform-note/native/Cargo.lock b/plugins/sample-wasm-platform-note/native/Cargo.lock deleted file mode 100644 index 75539ad..0000000 --- a/plugins/sample-wasm-platform-note/native/Cargo.lock +++ /dev/null @@ -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" diff --git a/plugins/sample-wasm-platform-note/native/Cargo.toml b/plugins/sample-wasm-platform-note/native/Cargo.toml deleted file mode 100644 index 3c336d2..0000000 --- a/plugins/sample-wasm-platform-note/native/Cargo.toml +++ /dev/null @@ -1,8 +0,0 @@ -[package] -name = "sample_wasm_platform_note" -version = "0.1.0" -edition = "2021" - -[lib] -crate-type = ["cdylib"] -path = "src/lib.rs" diff --git a/plugins/sample-wasm-platform-note/native/src/lib.rs b/plugins/sample-wasm-platform-note/native/src/lib.rs deleted file mode 100644 index 7c1c938..0000000 --- a/plugins/sample-wasm-platform-note/native/src/lib.rs +++ /dev/null @@ -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 { - 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 { - 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 -} diff --git a/plugins/sample-wasm-platform-note/native/target/.rustc_info.json b/plugins/sample-wasm-platform-note/native/target/.rustc_info.json deleted file mode 100644 index 19ad8dc..0000000 --- a/plugins/sample-wasm-platform-note/native/target/.rustc_info.json +++ /dev/null @@ -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":{}} \ No newline at end of file diff --git a/plugins/sample-wasm-platform-note/native/target/CACHEDIR.TAG b/plugins/sample-wasm-platform-note/native/target/CACHEDIR.TAG deleted file mode 100644 index 20d7c31..0000000 --- a/plugins/sample-wasm-platform-note/native/target/CACHEDIR.TAG +++ /dev/null @@ -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/ diff --git a/plugins/sample-wasm-platform-note/native/target/wasm32-wasip1/CACHEDIR.TAG b/plugins/sample-wasm-platform-note/native/target/wasm32-wasip1/CACHEDIR.TAG deleted file mode 100644 index 20d7c31..0000000 --- a/plugins/sample-wasm-platform-note/native/target/wasm32-wasip1/CACHEDIR.TAG +++ /dev/null @@ -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/ diff --git a/plugins/sample-wasm-platform-note/sample-wasm-platform-note.css b/plugins/sample-wasm-platform-note/sample-wasm-platform-note.css deleted file mode 100644 index f2c9af3..0000000 --- a/plugins/sample-wasm-platform-note/sample-wasm-platform-note.css +++ /dev/null @@ -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; -} diff --git a/plugins/sample-wasm-platform-note/sample-wasm-platform-note.html b/plugins/sample-wasm-platform-note/sample-wasm-platform-note.html deleted file mode 100644 index 4a74db2..0000000 --- a/plugins/sample-wasm-platform-note/sample-wasm-platform-note.html +++ /dev/null @@ -1,16 +0,0 @@ -
-

Sample WASM Platform Note

- -
Waiting to write platform note
-
-
OS-
-
Arch-
-
mkdir-
-
write-
-
Desktop file-
-
Desktop size-
-
Desktop list-
-
Desktop read-
-
-

-
diff --git a/plugins/sample-wasm-platform-note/sample-wasm-platform-note.js b/plugins/sample-wasm-platform-note/sample-wasm-platform-note.js deleted file mode 100644 index d8188e0..0000000 --- a/plugins/sample-wasm-platform-note/sample-wasm-platform-note.js +++ /dev/null @@ -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); - } -})(); diff --git a/plugins/sample-wasm/README.md b/plugins/sample-wasm/README.md deleted file mode 100644 index 4457d75..0000000 --- a/plugins/sample-wasm/README.md +++ /dev/null @@ -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. diff --git a/plugins/sample-wasm/build.bat b/plugins/sample-wasm/build.bat deleted file mode 100644 index 076c095..0000000 --- a/plugins/sample-wasm/build.bat +++ /dev/null @@ -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% diff --git a/plugins/sample-wasm/native/sample_wasm.c b/plugins/sample-wasm/native/sample_wasm.c deleted file mode 100644 index c372b11..0000000 --- a/plugins/sample-wasm/native/sample_wasm.c +++ /dev/null @@ -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 -#include -#include -#include - -__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) {} diff --git a/plugins/sample-wasm/sample-wasm.css b/plugins/sample-wasm/sample-wasm.css deleted file mode 100644 index fd00383..0000000 --- a/plugins/sample-wasm/sample-wasm.css +++ /dev/null @@ -1,3 +0,0 @@ -#sample-wasm-output { - min-height: 7rem; -} diff --git a/plugins/sample-wasm/sample-wasm.html b/plugins/sample-wasm/sample-wasm.html deleted file mode 100644 index a3bad1d..0000000 --- a/plugins/sample-wasm/sample-wasm.html +++ /dev/null @@ -1,6 +0,0 @@ -
-

Sample WASM

-

Plugin 2.0 runtime smoke test.

- -

-
diff --git a/plugins/sample-wasm/sample-wasm.js b/plugins/sample-wasm/sample-wasm.js deleted file mode 100644 index 44a1d1c..0000000 --- a/plugins/sample-wasm/sample-wasm.js +++ /dev/null @@ -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}`); - }); -})();