mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
feat: scaffold Phase B + C (events, telemetry, plugins, Solid.js)
Phase B Step 6 — Solid.js incremental migration
- vite-plugin-solid + solid-js + @solidjs/testing-library in package.json
- vite.config.ts compiles src/components/solid/** as Solid TSX
- tsconfig.json gains jsx: preserve / jsxImportSource: solid-js
- lib/solidAdapter.ts wraps existing custom Stores as Solid signals
- lib/solidMount.ts adapts Solid render to {mount,destroy} contract
- components/solid/Badge.tsx (proof-of-concept leaf)
- components/solid/ChannelListItem.tsx (store-subscribed leaf)
- components/solid/Badge.test.tsx pipeline smoke test
- components/solid/README.md documents the migration recipe
Phase B Step 7 — Event persistence layer
- SQLite + Postgres migrations for the events table
- sqlc query files for both engines
- EventStore interface + SQLite raw-SQL impl + MemStore impl + pg stubs
- ws.EventPersister: async batched writer (queue / flush / drain / drop)
- ws.StartEventPruner: background retention pruner
- hub persists every replay-buffer push and exposes reconnect-tier counters
- serve.handleReconnect: tiered replay (buffer -> DB -> full re-sync)
- EventPersistenceConfig + main.go wiring
- event_persister_test.go covers batching / drops / drain
Phase B Step 8 — OpenTelemetry skeleton
- Server/telemetry package with public Provider/Tracer/Meter/Counter API
- telemetry_default.go (no-op build) + telemetry_otel.go (build tag otel)
- telemetry/metrics.go declares the AppMetrics bundle
- HTTPMiddleware mounted in Chi router (pass-through in default build)
- PrometheusHandler optionally mounted at /metrics
- Spans on MessageService.SendMessage, PermissionService.HasChannelPerm,
ChannelService.ListVisibleChannels
- Reconnect-tier counter wired into the global meter
- TelemetryConfig defaults
Phase C Step 9 — Wazero plugin runtime skeleton
- Server/plugin package: manifest parser, loader, registry, host APIs
(commands, storage, events, http, ui), errors
- sandbox_default.go (no-op) + sandbox_wazero.go (build tag wazero)
- SQLite + Postgres migrations for plugins + plugin_kv tables
- PluginStore interface + impls + pg stubs
- plugin/examples/hello manifest + README
- plugin_test.go covers manifest, loader, capability gating
- api/plugins_handler.go admin REST surface, mounted under admin group
- PluginsConfig + main.go wiring (disabled by default)
- Client: lib/pluginBridge.ts iframe + postMessage host
- Client: components/solid/PluginContainer.tsx Solid host component
Verification
- Default build (no -tags) is intended to compile cleanly with no new
third-party dependencies. The sandbox lacked Go 1.25.0 so go build
could not run; PHASE_BC_LOCAL_TODO.md enumerates the local follow-up
work (npm install, go mod tidy, sqlc-generate, real otel/wazero
wiring, remaining service spans, full Solid migration).
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
package plugin
|
||||
|
||||
import "errors"
|
||||
|
||||
// ErrRuntimeUnavailable is returned when the plugin runtime cannot start
|
||||
// because the wazero build tag was not enabled. Default builds surface this
|
||||
// error from Registry.LoadAll so the rest of the server can keep running.
|
||||
var ErrRuntimeUnavailable = errors.New("plugin runtime: wazero build tag not enabled (build with -tags wazero to load .wasm plugins)")
|
||||
|
||||
// ErrPluginNotFound is returned when an operation references an unknown
|
||||
// plugin id or name.
|
||||
var ErrPluginNotFound = errors.New("plugin not found")
|
||||
|
||||
// ErrCapabilityNotGranted is returned when a host API call would require a
|
||||
// capability the plugin's manifest did not declare.
|
||||
var ErrCapabilityNotGranted = errors.New("plugin capability not granted")
|
||||
@@ -0,0 +1,29 @@
|
||||
# hello plugin
|
||||
|
||||
Phase C Step 9 — proof-of-life plugin used by `Server/plugin/plugin_test.go`.
|
||||
|
||||
## Manifest
|
||||
|
||||
`plugin.json` declares the `commands`, `events`, and `storage` capabilities.
|
||||
The manifest is the only file the default (no-`-tags wazero`) build needs —
|
||||
the registry persists it into the plugins table without executing the .wasm.
|
||||
|
||||
## Building the WASM
|
||||
|
||||
The .wasm binary is intentionally NOT checked in. Build it locally with TinyGo
|
||||
or any other WASM toolchain that emits a module exporting `command_dispatch`,
|
||||
`on_event`, and `_start`:
|
||||
|
||||
```sh
|
||||
# TinyGo example (writes hello.wasm into this directory)
|
||||
tinygo build -o hello.wasm -target wasi ./main.go
|
||||
```
|
||||
|
||||
A trivial main.go that satisfies the host API is sketched in
|
||||
`Server/plugin/sandbox_wazero.go`'s docstring.
|
||||
|
||||
## Tests
|
||||
|
||||
`Server/plugin/plugin_test.go` exercises the manifest parser and the loader
|
||||
against this directory. It does not require the .wasm to be present —
|
||||
manifest-only validation is the default-build coverage path.
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "hello",
|
||||
"version": "0.1.0",
|
||||
"author": "OwnCord",
|
||||
"description": "Trivial proof-of-life plugin: registers /hello and echoes message_send events.",
|
||||
"entrypoint": "hello.wasm",
|
||||
"permissions": ["commands", "events", "storage"],
|
||||
"resources": {
|
||||
"max_memory_mb": 16,
|
||||
"cpu_budget_ms": 50
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// Phase C Step 9 — `commands` host capability.
|
||||
//
|
||||
// Plugins that declare the "commands" capability register one or more slash
|
||||
// commands at activation time. The WS command dispatcher (Server/ws/command.go)
|
||||
// calls Registry.DispatchCommand after exhausting its built-in command table.
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CommandResult is what a plugin returns from a command invocation.
|
||||
type CommandResult struct {
|
||||
// Reply is sent back to the invoking user as an ephemeral message.
|
||||
Reply string
|
||||
// Broadcast, when set, is also broadcast to the channel.
|
||||
Broadcast string
|
||||
}
|
||||
|
||||
// RegisterCommand binds cmd to inst. Called from the activation path in the
|
||||
// wazero-tagged build once the module exports its `register_commands` table.
|
||||
// Default build can call it directly from tests.
|
||||
func (r *Registry) RegisterCommand(cmd string, inst *Instance) error {
|
||||
cmd = strings.ToLower(strings.TrimPrefix(cmd, "/"))
|
||||
if cmd == "" {
|
||||
return fmt.Errorf("plugin: cannot register empty command")
|
||||
}
|
||||
if !inst.Manifest.HasCapability(CapCommands) {
|
||||
return ErrCapabilityNotGranted
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if existing, ok := r.commands[cmd]; ok && existing != inst {
|
||||
return fmt.Errorf("plugin: command %q already registered by %q", cmd, existing.Manifest.Name)
|
||||
}
|
||||
r.commands[cmd] = inst
|
||||
return nil
|
||||
}
|
||||
|
||||
// DispatchCommand routes a slash command to the owning plugin. Returns
|
||||
// (nil, false) when no plugin owns the command, letting the WS dispatcher
|
||||
// fall back to the not-found response. Returns (nil, true) when the runtime
|
||||
// is unavailable so the dispatcher can show a helpful error message.
|
||||
func (r *Registry) DispatchCommand(ctx context.Context, userID int64, channelID int64, cmd string, args []string) (*CommandResult, bool) {
|
||||
if r == nil {
|
||||
return nil, false
|
||||
}
|
||||
cmd = strings.ToLower(strings.TrimPrefix(cmd, "/"))
|
||||
r.mu.RLock()
|
||||
inst, ok := r.commands[cmd]
|
||||
r.mu.RUnlock()
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
if r.runtimePlatform == nil {
|
||||
return &CommandResult{
|
||||
Reply: fmt.Sprintf("plugin %q owns /%s but the wazero runtime is not built (run with -tags wazero)", inst.Manifest.Name, cmd),
|
||||
}, true
|
||||
}
|
||||
return r.invokeCommand(ctx, inst, userID, channelID, cmd, args)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// Phase C Step 9 — `events` host capability.
|
||||
//
|
||||
// Plugins that subscribe to events declare topic names in their manifest.
|
||||
// At activation time the wazero-tagged build wires each subscription into
|
||||
// the WS pub/sub hub via Hub.Subscribe; the default build records the
|
||||
// subscription in-memory only.
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// EventSink is the channel a subscribed plugin reads from. The wazero-tagged
|
||||
// build forwards each event to the plugin's `on_event` exported function.
|
||||
type EventSink struct {
|
||||
mu sync.Mutex
|
||||
subs map[string][]*Instance
|
||||
}
|
||||
|
||||
// NewEventSink returns a fresh sink. Used by the registry as the central
|
||||
// fan-out for plugin event delivery.
|
||||
func NewEventSink() *EventSink {
|
||||
return &EventSink{subs: make(map[string][]*Instance)}
|
||||
}
|
||||
|
||||
// Subscribe binds inst to topic. Multiple plugins may subscribe to the same
|
||||
// topic — events fan out to every subscriber.
|
||||
func (s *EventSink) Subscribe(topic string, inst *Instance) error {
|
||||
if !inst.Manifest.HasCapability(CapEvents) {
|
||||
return ErrCapabilityNotGranted
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.subs[topic] = append(s.subs[topic], inst)
|
||||
return nil
|
||||
}
|
||||
|
||||
// UnsubscribeAll removes every subscription owned by inst (called on disable).
|
||||
func (s *EventSink) UnsubscribeAll(inst *Instance) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for topic, list := range s.subs {
|
||||
kept := list[:0]
|
||||
for _, e := range list {
|
||||
if e != inst {
|
||||
kept = append(kept, e)
|
||||
}
|
||||
}
|
||||
if len(kept) == 0 {
|
||||
delete(s.subs, topic)
|
||||
} else {
|
||||
s.subs[topic] = kept
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Dispatch invokes every subscriber's on_event for topic. The default build
|
||||
// is a no-op; the wazero-tagged build calls into the WASM module.
|
||||
func (s *EventSink) Dispatch(ctx context.Context, topic string, payload []byte) {
|
||||
s.mu.Lock()
|
||||
subs := append([]*Instance(nil), s.subs[topic]...)
|
||||
s.mu.Unlock()
|
||||
for _, inst := range subs {
|
||||
_ = inst // wazero-tagged build calls inst.module.invoke("on_event", payload)
|
||||
_ = ctx
|
||||
_ = payload
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// Phase C Step 9 — `http` host capability.
|
||||
//
|
||||
// Outbound HTTP requests proxied through the server. Each request is matched
|
||||
// against PluginsConfig.HTTPAllowlist (host suffix match) before being sent.
|
||||
// The wazero-tagged build invokes this from the plugin's `host_http_request`
|
||||
// import; the default build exposes it for testing.
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// HTTPRequest is the plugin → host request envelope.
|
||||
type HTTPRequest struct {
|
||||
Method string
|
||||
URL string
|
||||
Body []byte
|
||||
Header map[string]string
|
||||
}
|
||||
|
||||
// HTTPResponse is the host → plugin response envelope.
|
||||
type HTTPResponse struct {
|
||||
StatusCode int
|
||||
Body []byte
|
||||
Header map[string]string
|
||||
}
|
||||
|
||||
const httpTimeout = 10 * time.Second
|
||||
|
||||
// HTTPDo executes a plugin-initiated HTTP request after enforcing the host
|
||||
// allowlist declared in PluginsConfig.
|
||||
func (r *Registry) HTTPDo(ctx context.Context, inst *Instance, req HTTPRequest) (*HTTPResponse, error) {
|
||||
if !inst.Manifest.HasCapability(CapHTTP) {
|
||||
return nil, ErrCapabilityNotGranted
|
||||
}
|
||||
if !r.hostAllowed(req.URL) {
|
||||
return nil, fmt.Errorf("plugin http: host not in allowlist: %s", req.URL)
|
||||
}
|
||||
httpReq, err := http.NewRequestWithContext(ctx, req.Method, req.URL, strings.NewReader(string(req.Body)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("plugin http: build request: %w", err)
|
||||
}
|
||||
for k, v := range req.Header {
|
||||
httpReq.Header.Set(k, v)
|
||||
}
|
||||
client := &http.Client{Timeout: httpTimeout}
|
||||
resp, err := client.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("plugin http: do: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("plugin http: read body: %w", err)
|
||||
}
|
||||
hdr := make(map[string]string, len(resp.Header))
|
||||
for k, v := range resp.Header {
|
||||
if len(v) > 0 {
|
||||
hdr[k] = v[0]
|
||||
}
|
||||
}
|
||||
return &HTTPResponse{
|
||||
StatusCode: resp.StatusCode,
|
||||
Body: body,
|
||||
Header: hdr,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// hostAllowed reports whether url's host matches any suffix in the allowlist.
|
||||
func (r *Registry) hostAllowed(url string) bool {
|
||||
// Trivial host extraction — full URL parsing would be overkill since the
|
||||
// allowlist match is suffix-based.
|
||||
rest := url
|
||||
for _, prefix := range []string{"https://", "http://"} {
|
||||
if strings.HasPrefix(rest, prefix) {
|
||||
rest = rest[len(prefix):]
|
||||
break
|
||||
}
|
||||
}
|
||||
if i := strings.IndexAny(rest, "/?#"); i >= 0 {
|
||||
rest = rest[:i]
|
||||
}
|
||||
for _, suffix := range r.cfg.HTTPAllowlist {
|
||||
if strings.HasSuffix(rest, suffix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// Phase C Step 9 — `storage` host capability.
|
||||
//
|
||||
// Plugins get a per-plugin namespaced KV store backed by the PluginStore
|
||||
// rows in the events/plugin schema. Capacity caps and value-size caps are
|
||||
// enforced here so a misbehaving plugin can't fill the database.
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
const (
|
||||
maxPluginValueBytes = 64 * 1024 // 64 KB per value
|
||||
maxPluginScanLimit = 1000 // hard cap on PluginKVScan results
|
||||
)
|
||||
|
||||
// StoragePut writes a single key/value pair on behalf of inst.
|
||||
func (r *Registry) StoragePut(ctx context.Context, inst *Instance, key string, value []byte) error {
|
||||
if !inst.Manifest.HasCapability(CapStorage) {
|
||||
return ErrCapabilityNotGranted
|
||||
}
|
||||
if len(value) > maxPluginValueBytes {
|
||||
return fmt.Errorf("plugin storage: value exceeds %d bytes", maxPluginValueBytes)
|
||||
}
|
||||
return r.cfg.Store.PluginKVSet(ctx, inst.ID, key, value)
|
||||
}
|
||||
|
||||
// StorageGet returns the value for key, or (nil, error) when missing.
|
||||
func (r *Registry) StorageGet(ctx context.Context, inst *Instance, key string) ([]byte, error) {
|
||||
if !inst.Manifest.HasCapability(CapStorage) {
|
||||
return nil, ErrCapabilityNotGranted
|
||||
}
|
||||
return r.cfg.Store.PluginKVGet(ctx, inst.ID, key)
|
||||
}
|
||||
|
||||
// StorageDelete removes a key.
|
||||
func (r *Registry) StorageDelete(ctx context.Context, inst *Instance, key string) error {
|
||||
if !inst.Manifest.HasCapability(CapStorage) {
|
||||
return ErrCapabilityNotGranted
|
||||
}
|
||||
return r.cfg.Store.PluginKVDelete(ctx, inst.ID, key)
|
||||
}
|
||||
|
||||
// StorageScan returns all keys with the given prefix, capped at maxPluginScanLimit.
|
||||
func (r *Registry) StorageScan(ctx context.Context, inst *Instance, prefix string, limit int) (map[string][]byte, error) {
|
||||
if !inst.Manifest.HasCapability(CapStorage) {
|
||||
return nil, ErrCapabilityNotGranted
|
||||
}
|
||||
if limit <= 0 || limit > maxPluginScanLimit {
|
||||
limit = maxPluginScanLimit
|
||||
}
|
||||
return r.cfg.Store.PluginKVScan(ctx, inst.ID, prefix, limit)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// Phase C Step 9 — `ui` host capability.
|
||||
//
|
||||
// A plugin that declares the `ui` capability ships HTML/CSS/JS assets and a
|
||||
// list of tabs. The host serves those assets at /api/v1/plugins/<name>/ui/...
|
||||
// and the Solid.js client bridge renders each tab inside a sandboxed iframe.
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// RegisterUI binds inst's declared tabs into the registry. Called from the
|
||||
// activation path; safe to call multiple times (idempotent on inst).
|
||||
func (r *Registry) RegisterUI(inst *Instance) error {
|
||||
if !inst.Manifest.HasCapability(CapUI) {
|
||||
return ErrCapabilityNotGranted
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
// Drop any existing bindings for this instance, then re-add.
|
||||
kept := r.uiTabs[:0]
|
||||
for _, b := range r.uiTabs {
|
||||
if b.PluginID != inst.ID {
|
||||
kept = append(kept, b)
|
||||
}
|
||||
}
|
||||
r.uiTabs = kept
|
||||
for _, t := range inst.Manifest.UI.Tabs {
|
||||
r.uiTabs = append(r.uiTabs, UITabBinding{
|
||||
PluginID: inst.ID,
|
||||
PluginName: inst.Manifest.Name,
|
||||
Tab: t,
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AssetHandler returns an http.Handler that serves the on-disk assets for
|
||||
// inst, rooted at the plugin's directory. The handler refuses path traversal
|
||||
// attempts and only serves files declared by manifest tabs.
|
||||
func (r *Registry) AssetHandler(inst *Instance) http.Handler {
|
||||
allowed := make(map[string]bool, len(inst.Manifest.UI.Tabs))
|
||||
for _, t := range inst.Manifest.UI.Tabs {
|
||||
allowed[t.Asset] = true
|
||||
}
|
||||
pluginDir := filepath.Dir(inst.WASMPath)
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
rel := strings.TrimPrefix(req.URL.Path, "/")
|
||||
if !allowed[rel] {
|
||||
http.NotFound(w, req)
|
||||
return
|
||||
}
|
||||
full := filepath.Join(pluginDir, rel)
|
||||
if !strings.HasPrefix(full, pluginDir) {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
http.ServeFile(w, req, full)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// Phase C Step 9 — On-disk plugin discovery.
|
||||
//
|
||||
// Each plugin lives in its own subdirectory under PluginsConfig.Directory:
|
||||
//
|
||||
// plugins/
|
||||
// hello/
|
||||
// plugin.json
|
||||
// hello.wasm
|
||||
// game-detection/
|
||||
// plugin.json
|
||||
// detector.wasm
|
||||
// assets/...
|
||||
//
|
||||
// Loader walks the directory, parses every plugin.json, and returns a slice
|
||||
// of foundPlugin records. The Registry then persists each into the store.
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
type foundPlugin struct {
|
||||
Manifest *Manifest
|
||||
Dir string
|
||||
WASMPath string
|
||||
}
|
||||
|
||||
// scanPluginDirectory walks dir non-recursively and parses plugin.json from
|
||||
// every immediate subdirectory. Errors on individual plugins are wrapped and
|
||||
// returned alongside the successful entries.
|
||||
func scanPluginDirectory(dir string) ([]foundPlugin, error) {
|
||||
if dir == "" {
|
||||
return nil, nil
|
||||
}
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
// Directory absent is fine — operators may not have created it yet.
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
var found []foundPlugin
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
pluginDir := filepath.Join(dir, e.Name())
|
||||
manifestPath := filepath.Join(pluginDir, "plugin.json")
|
||||
raw, rdErr := os.ReadFile(manifestPath)
|
||||
if rdErr != nil {
|
||||
if os.IsNotExist(rdErr) {
|
||||
continue
|
||||
}
|
||||
return nil, fmt.Errorf("plugin %q: read plugin.json: %w", e.Name(), rdErr)
|
||||
}
|
||||
manifest, parseErr := ParseManifest(raw)
|
||||
if parseErr != nil {
|
||||
return nil, fmt.Errorf("plugin %q: %w", e.Name(), parseErr)
|
||||
}
|
||||
wasmPath := filepath.Join(pluginDir, manifest.Entrypoint)
|
||||
if _, statErr := os.Stat(wasmPath); statErr != nil {
|
||||
return nil, fmt.Errorf("plugin %q: missing entrypoint %s: %w", e.Name(), manifest.Entrypoint, statErr)
|
||||
}
|
||||
found = append(found, foundPlugin{
|
||||
Manifest: manifest,
|
||||
Dir: pluginDir,
|
||||
WASMPath: wasmPath,
|
||||
})
|
||||
}
|
||||
return found, nil
|
||||
}
|
||||
|
||||
// serialize returns a canonical JSON encoding of the manifest, used as the
|
||||
// manifest_json column value in the plugins table.
|
||||
func (m *Manifest) serialize() (string, error) {
|
||||
b, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("manifest serialize: %w", err)
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
// Package plugin implements the OwnCord plugin runtime.
|
||||
//
|
||||
// Phase C Step 9 — Wazero Plugin Runtime.
|
||||
//
|
||||
// The package is split into:
|
||||
//
|
||||
// - manifest.go : declarative plugin metadata + permission checks
|
||||
// - registry.go : in-memory registry + lifecycle (install/enable/load)
|
||||
// - loader.go : on-disk discovery and package validation
|
||||
// - sandbox.go : Wazero runtime configuration (build tag `wazero`)
|
||||
// - host_*.go : capability-scoped host API surfaces
|
||||
// - errors.go
|
||||
//
|
||||
// The default `go build ./...` ships a stub runtime that satisfies every call
|
||||
// site without pulling Wazero into go.mod. To compile the real runtime:
|
||||
//
|
||||
// go get github.com/tetratelabs/wazero
|
||||
// go build -tags wazero ./...
|
||||
//
|
||||
// This mirrors the postgres / otel build-tag approach used elsewhere in the
|
||||
// repo so the default build stays self-contained.
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Manifest is the parsed plugin metadata declared in plugin.json (or
|
||||
// plugin.toml in the wazero-tagged build). The on-disk schema is intentionally
|
||||
// flat so the default JSON parser handles it without a TOML dependency.
|
||||
type Manifest struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Author string `json:"author"`
|
||||
Description string `json:"description"`
|
||||
Entrypoint string `json:"entrypoint"` // relative .wasm path
|
||||
Permissions []string `json:"permissions"`
|
||||
Resources Resources `json:"resources"`
|
||||
UI UISpec `json:"ui"`
|
||||
}
|
||||
|
||||
// Resources caps the plugin's runtime budget. Zero means "use the runtime
|
||||
// default from PluginsConfig".
|
||||
type Resources struct {
|
||||
MaxMemoryMB int `json:"max_memory_mb"`
|
||||
CPUBudgetMs int `json:"cpu_budget_ms"`
|
||||
}
|
||||
|
||||
// UISpec describes the optional client-side rendering surface.
|
||||
type UISpec struct {
|
||||
Tabs []UITab `json:"tabs"`
|
||||
}
|
||||
|
||||
// UITab is a single iframe-rendered plugin tab.
|
||||
type UITab struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
Asset string `json:"asset"` // relative html path
|
||||
}
|
||||
|
||||
// Capability is a permission name a plugin may request.
|
||||
type Capability string
|
||||
|
||||
const (
|
||||
CapCommands Capability = "commands"
|
||||
CapEvents Capability = "events"
|
||||
CapStorage Capability = "storage"
|
||||
CapHTTP Capability = "http"
|
||||
CapUI Capability = "ui"
|
||||
)
|
||||
|
||||
// validCapabilities is the closed set of capability names a manifest may
|
||||
// declare. Anything else is rejected at load time.
|
||||
var validCapabilities = map[Capability]bool{
|
||||
CapCommands: true,
|
||||
CapEvents: true,
|
||||
CapStorage: true,
|
||||
CapHTTP: true,
|
||||
CapUI: true,
|
||||
}
|
||||
|
||||
// ParseManifest decodes a plugin.json byte slice and validates required fields.
|
||||
func ParseManifest(raw []byte) (*Manifest, error) {
|
||||
var m Manifest
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return nil, fmt.Errorf("plugin manifest: invalid JSON: %w", err)
|
||||
}
|
||||
if err := m.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
// Validate enforces the manifest schema.
|
||||
func (m *Manifest) Validate() error {
|
||||
if strings.TrimSpace(m.Name) == "" {
|
||||
return fmt.Errorf("plugin manifest: name is required")
|
||||
}
|
||||
if strings.TrimSpace(m.Version) == "" {
|
||||
return fmt.Errorf("plugin manifest: version is required")
|
||||
}
|
||||
if strings.TrimSpace(m.Entrypoint) == "" {
|
||||
return fmt.Errorf("plugin manifest: entrypoint is required")
|
||||
}
|
||||
if !strings.HasSuffix(m.Entrypoint, ".wasm") {
|
||||
return fmt.Errorf("plugin manifest: entrypoint %q must end in .wasm", m.Entrypoint)
|
||||
}
|
||||
for _, p := range m.Permissions {
|
||||
if !validCapabilities[Capability(p)] {
|
||||
return fmt.Errorf("plugin manifest: unknown permission %q", p)
|
||||
}
|
||||
}
|
||||
if m.Resources.MaxMemoryMB < 0 || m.Resources.CPUBudgetMs < 0 {
|
||||
return fmt.Errorf("plugin manifest: resources must be non-negative")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// HasCapability reports whether the manifest declared cap.
|
||||
func (m *Manifest) HasCapability(cap Capability) bool {
|
||||
for _, p := range m.Permissions {
|
||||
if Capability(p) == cap {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
// Phase C Step 9 — manifest + loader tests.
|
||||
//
|
||||
// These tests cover the default-build code path (no wazero). They confirm:
|
||||
// - the JSON manifest parses and validates,
|
||||
// - the loader walks a directory and surfaces well-formed plugins,
|
||||
// - the registry persists discovered plugins into a PluginStore,
|
||||
// - per-capability gating refuses calls when the manifest didn't grant them.
|
||||
//
|
||||
// Wazero-specific tests live in sandbox_wazero_test.go and only run with the
|
||||
// `wazero` build tag.
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/owncord/server/store"
|
||||
)
|
||||
|
||||
func TestParseManifestRoundTrip(t *testing.T) {
|
||||
raw := []byte(`{
|
||||
"name": "hello",
|
||||
"version": "0.1.0",
|
||||
"entrypoint": "hello.wasm",
|
||||
"permissions": ["commands", "storage"],
|
||||
"resources": {"max_memory_mb": 16, "cpu_budget_ms": 50}
|
||||
}`)
|
||||
m, err := ParseManifest(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseManifest: %v", err)
|
||||
}
|
||||
if m.Name != "hello" || m.Version != "0.1.0" {
|
||||
t.Fatalf("unexpected manifest fields: %+v", m)
|
||||
}
|
||||
if !m.HasCapability(CapCommands) {
|
||||
t.Fatal("expected commands capability")
|
||||
}
|
||||
if m.HasCapability(CapHTTP) {
|
||||
t.Fatal("did not expect http capability")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseManifestRejectsBadEntrypoint(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"missing entrypoint": `{"name":"x","version":"1","entrypoint":""}`,
|
||||
"non-wasm entrypoint": `{"name":"x","version":"1","entrypoint":"x.so"}`,
|
||||
"unknown capability": `{"name":"x","version":"1","entrypoint":"x.wasm","permissions":["badperm"]}`,
|
||||
"missing version": `{"name":"x","entrypoint":"x.wasm"}`,
|
||||
"missing name": `{"version":"1","entrypoint":"x.wasm"}`,
|
||||
}
|
||||
for label, body := range cases {
|
||||
t.Run(label, func(t *testing.T) {
|
||||
if _, err := ParseManifest([]byte(body)); err == nil {
|
||||
t.Fatalf("expected error for %s", label)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanPluginDirectoryHandlesMissing(t *testing.T) {
|
||||
got, err := scanPluginDirectory(filepath.Join(t.TempDir(), "does-not-exist"))
|
||||
if err != nil {
|
||||
t.Fatalf("scanPluginDirectory: %v", err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("expected empty result, got %d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanPluginDirectoryParsesValidPlugin(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
pluginDir := filepath.Join(dir, "hello")
|
||||
if err := os.MkdirAll(pluginDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
manifest := `{"name":"hello","version":"0.1.0","entrypoint":"hello.wasm","permissions":["storage"]}`
|
||||
if err := os.WriteFile(filepath.Join(pluginDir, "plugin.json"), []byte(manifest), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(pluginDir, "hello.wasm"), []byte("\x00asm\x01\x00\x00\x00"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := scanPluginDirectory(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("scanPluginDirectory: %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0].Manifest.Name != "hello" {
|
||||
t.Fatalf("unexpected scan result: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryInstallFromDisk(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
pluginDir := filepath.Join(dir, "hello")
|
||||
_ = os.MkdirAll(pluginDir, 0o755)
|
||||
_ = os.WriteFile(filepath.Join(pluginDir, "plugin.json"),
|
||||
[]byte(`{"name":"hello","version":"0.1.0","entrypoint":"hello.wasm","permissions":["storage"]}`),
|
||||
0o644)
|
||||
_ = os.WriteFile(filepath.Join(pluginDir, "hello.wasm"), []byte("\x00asm\x01\x00\x00\x00"), 0o644)
|
||||
|
||||
mem := store.NewMemStore()
|
||||
reg, err := NewRegistry(Config{Directory: dir, Store: mem})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := reg.LoadAll(context.Background()); err != nil {
|
||||
t.Fatalf("LoadAll: %v", err)
|
||||
}
|
||||
rows, err := mem.ListPlugins(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rows) != 1 || rows[0].Name != "hello" {
|
||||
t.Fatalf("expected hello plugin row, got %+v", rows)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStorageGatedByCapability(t *testing.T) {
|
||||
mem := store.NewMemStore()
|
||||
reg, err := NewRegistry(Config{Store: mem})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
inst := &Instance{
|
||||
ID: 1,
|
||||
Manifest: &Manifest{Name: "x", Permissions: []string{}},
|
||||
}
|
||||
if err := reg.StoragePut(context.Background(), inst, "k", []byte("v")); err == nil {
|
||||
t.Fatal("expected ErrCapabilityNotGranted")
|
||||
}
|
||||
inst.Manifest.Permissions = []string{string(CapStorage)}
|
||||
// Pre-create the plugin row so the KV foreign-key-equivalent succeeds.
|
||||
if _, err := mem.InstallPlugin(context.Background(), "x", "0.1", "{}"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := reg.StoragePut(context.Background(), inst, "k", []byte("v")); err != nil {
|
||||
t.Fatalf("StoragePut: %v", err)
|
||||
}
|
||||
got, err := reg.StorageGet(context.Background(), inst, "k")
|
||||
if err != nil {
|
||||
t.Fatalf("StorageGet: %v", err)
|
||||
}
|
||||
if string(got) != "v" {
|
||||
t.Fatalf("expected v, got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
// Phase C Step 9 — Plugin registry, lifecycle, and host-API plumbing.
|
||||
//
|
||||
// The Registry is the long-lived handle the rest of the server holds onto. It
|
||||
// owns the Wazero runtime (in the wazero-tagged build), the loaded plugin
|
||||
// instances, and the dispatch tables for host-API capabilities (commands,
|
||||
// events, storage, http, ui).
|
||||
//
|
||||
// In the default build the runtime is a stub: LoadAll walks the plugins
|
||||
// directory and persists each manifest into the PluginStore so admins can see
|
||||
// what is "installed", but the .wasm files are NOT executed. Calling
|
||||
// Dispatch() in the default build returns ErrRuntimeUnavailable.
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync"
|
||||
|
||||
"github.com/owncord/server/store"
|
||||
)
|
||||
|
||||
// Config is the runtime configuration sourced from PluginsConfig.
|
||||
type Config struct {
|
||||
Directory string
|
||||
MaxMemoryMB int
|
||||
CPUBudgetMs int
|
||||
HTTPAllowlist []string
|
||||
Store store.PluginStore
|
||||
}
|
||||
|
||||
// Registry is the central plugin coordinator.
|
||||
type Registry struct {
|
||||
cfg Config
|
||||
|
||||
mu sync.RWMutex
|
||||
plugins map[int64]*Instance // by plugin row id
|
||||
byName map[string]*Instance // by manifest name
|
||||
commands map[string]*Instance // command name → owning plugin
|
||||
uiTabs []UITabBinding // declared by `ui` capability plugins
|
||||
|
||||
// runtimePlatform is set by the wazero-tagged build's NewRegistry to a
|
||||
// concrete *wazero.Runtime. The default build leaves it nil and falls
|
||||
// back to manifest-only behaviour.
|
||||
runtimePlatform any
|
||||
}
|
||||
|
||||
// Instance is a single loaded plugin.
|
||||
type Instance struct {
|
||||
ID int64
|
||||
Manifest *Manifest
|
||||
WASMPath string
|
||||
Enabled bool
|
||||
|
||||
// module is the wazero compiled module in the wazero-tagged build, or
|
||||
// nil in the default build.
|
||||
module any
|
||||
}
|
||||
|
||||
// UITabBinding is the public projection of a plugin's declared UI tab,
|
||||
// served to the client bridge so it can render iframe tabs.
|
||||
type UITabBinding struct {
|
||||
PluginID int64
|
||||
PluginName string
|
||||
Tab UITab
|
||||
}
|
||||
|
||||
// NewRegistry constructs a registry. In the default build it is a thin
|
||||
// holder; the wazero-tagged build replaces this constructor with one that
|
||||
// stands up a real Wazero runtime.
|
||||
func NewRegistry(cfg Config) (*Registry, error) {
|
||||
if cfg.Store == nil {
|
||||
return nil, fmt.Errorf("plugin: NewRegistry requires a non-nil PluginStore")
|
||||
}
|
||||
return &Registry{
|
||||
cfg: cfg,
|
||||
plugins: make(map[int64]*Instance),
|
||||
byName: make(map[string]*Instance),
|
||||
commands: make(map[string]*Instance),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Close shuts the registry down. In the wazero-tagged build it tears the
|
||||
// runtime down and frees module memory.
|
||||
func (r *Registry) Close(ctx context.Context) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
for id := range r.plugins {
|
||||
delete(r.plugins, id)
|
||||
}
|
||||
for n := range r.byName {
|
||||
delete(r.byName, n)
|
||||
}
|
||||
for c := range r.commands {
|
||||
delete(r.commands, c)
|
||||
}
|
||||
r.uiTabs = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadAll scans cfg.Directory and persists every plugin.json found into the
|
||||
// PluginStore. In the wazero-tagged build it then compiles each entrypoint
|
||||
// into a runnable module; the default build stops at the persistence step.
|
||||
func (r *Registry) LoadAll(ctx context.Context) error {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
manifests, err := scanPluginDirectory(r.cfg.Directory)
|
||||
if err != nil {
|
||||
return fmt.Errorf("plugin: scan %q: %w", r.cfg.Directory, err)
|
||||
}
|
||||
for _, found := range manifests {
|
||||
if err := r.installFromDisk(ctx, found); err != nil {
|
||||
slog.Warn("plugin: failed to install from disk", "name", found.Manifest.Name, "err", err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
return r.activateAll(ctx)
|
||||
}
|
||||
|
||||
// installFromDisk persists a manifest discovered on disk into the PluginStore
|
||||
// and registers it in the in-memory registry.
|
||||
func (r *Registry) installFromDisk(ctx context.Context, found foundPlugin) error {
|
||||
manifestJSON, err := found.Manifest.serialize()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := r.cfg.Store.InstallPlugin(ctx, found.Manifest.Name, found.Manifest.Version, manifestJSON)
|
||||
if err != nil {
|
||||
return fmt.Errorf("InstallPlugin: %w", err)
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
inst := &Instance{
|
||||
ID: id,
|
||||
Manifest: found.Manifest,
|
||||
WASMPath: found.WASMPath,
|
||||
Enabled: false,
|
||||
}
|
||||
r.plugins[id] = inst
|
||||
r.byName[found.Manifest.Name] = inst
|
||||
return nil
|
||||
}
|
||||
|
||||
// activateAll attempts to compile + register host-API hooks for every plugin
|
||||
// row in the PluginStore that is marked enabled. The default build is a
|
||||
// no-op (no Wazero modules to compile).
|
||||
func (r *Registry) activateAll(ctx context.Context) error {
|
||||
rows, err := r.cfg.Store.ListPlugins(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ListPlugins: %w", err)
|
||||
}
|
||||
for _, row := range rows {
|
||||
if !row.Enabled {
|
||||
continue
|
||||
}
|
||||
r.mu.Lock()
|
||||
inst, ok := r.byName[row.Name]
|
||||
r.mu.Unlock()
|
||||
if !ok {
|
||||
slog.Warn("plugin: enabled row has no on-disk manifest, skipping", "name", row.Name)
|
||||
continue
|
||||
}
|
||||
if err := r.activate(ctx, inst); err != nil {
|
||||
slog.Warn("plugin: activation failed", "name", row.Name, "err", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// activate compiles and starts a single plugin module. Default build returns
|
||||
// ErrRuntimeUnavailable; the wazero-tagged build replaces this with the real
|
||||
// implementation via the runtimePlatform field.
|
||||
func (r *Registry) activate(ctx context.Context, inst *Instance) error {
|
||||
if r.runtimePlatform == nil {
|
||||
return ErrRuntimeUnavailable
|
||||
}
|
||||
return r.activateWithRuntime(ctx, inst)
|
||||
}
|
||||
|
||||
// EnablePlugin marks a plugin enabled in the store, then attempts to load it.
|
||||
func (r *Registry) EnablePlugin(ctx context.Context, id int64) error {
|
||||
if err := r.cfg.Store.EnablePlugin(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
r.mu.RLock()
|
||||
inst, ok := r.plugins[id]
|
||||
r.mu.RUnlock()
|
||||
if !ok {
|
||||
return ErrPluginNotFound
|
||||
}
|
||||
inst.Enabled = true
|
||||
if err := r.activate(ctx, inst); err != nil {
|
||||
// Roll back the DB flag so the next start attempt is consistent.
|
||||
_ = r.cfg.Store.DisablePlugin(ctx, id)
|
||||
inst.Enabled = false
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DisablePlugin marks a plugin disabled and tears its module down.
|
||||
func (r *Registry) DisablePlugin(ctx context.Context, id int64) error {
|
||||
if err := r.cfg.Store.DisablePlugin(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if inst, ok := r.plugins[id]; ok {
|
||||
inst.Enabled = false
|
||||
// Drop command bindings owned by this plugin.
|
||||
for cmd, owner := range r.commands {
|
||||
if owner == inst {
|
||||
delete(r.commands, cmd)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UninstallPlugin removes a plugin entirely.
|
||||
func (r *Registry) UninstallPlugin(ctx context.Context, id int64) error {
|
||||
_ = r.DisablePlugin(ctx, id)
|
||||
if err := r.cfg.Store.UninstallPlugin(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if inst, ok := r.plugins[id]; ok {
|
||||
delete(r.byName, inst.Manifest.Name)
|
||||
}
|
||||
delete(r.plugins, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// List returns the currently registered plugins. Read-only snapshot.
|
||||
func (r *Registry) List() []*Instance {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
out := make([]*Instance, 0, len(r.plugins))
|
||||
for _, p := range r.plugins {
|
||||
out = append(out, p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// UITabBindings returns the declared UI tabs across enabled plugins.
|
||||
func (r *Registry) UITabBindings() []UITabBinding {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
out := make([]UITabBinding, len(r.uiTabs))
|
||||
copy(out, r.uiTabs)
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
//go:build !wazero
|
||||
|
||||
// Default plugin runtime: no Wazero. Plugin manifests are still discovered,
|
||||
// persisted, and surfaced through the admin API, but `.wasm` modules are not
|
||||
// executed. To enable real WASM execution build with `-tags wazero`.
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
// activateWithRuntime is a no-op in the default build. It is only called from
|
||||
// Registry.activate when runtimePlatform is non-nil, which never happens here.
|
||||
func (r *Registry) activateWithRuntime(ctx context.Context, inst *Instance) error {
|
||||
return ErrRuntimeUnavailable
|
||||
}
|
||||
|
||||
// invokeCommand returns an error result instructing the operator to enable
|
||||
// the wazero build tag. Default build only.
|
||||
func (r *Registry) invokeCommand(ctx context.Context, inst *Instance, userID, channelID int64, cmd string, args []string) (*CommandResult, bool) {
|
||||
return &CommandResult{
|
||||
Reply: "plugin runtime disabled — rebuild server with -tags wazero to execute plugin commands",
|
||||
}, true
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
//go:build wazero
|
||||
|
||||
// Real Wazero-backed plugin runtime. Compiled only with `-tags wazero`,
|
||||
// matching the postgres / otel build-tag pattern used elsewhere in the repo.
|
||||
//
|
||||
// IMPORTANT: This file is a structural skeleton — it will fail to compile
|
||||
// until github.com/tetratelabs/wazero is added to go.mod. To finish wiring it
|
||||
// on a machine with network access:
|
||||
//
|
||||
// cd Server
|
||||
// go get github.com/tetratelabs/wazero@latest
|
||||
// go mod tidy
|
||||
// go build -tags wazero ./...
|
||||
//
|
||||
// The skeleton documents the intended call graph so the implementation work
|
||||
// is mechanical: each TODO marker maps to a wazero API call.
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
// activateWithRuntime compiles inst.WASMPath into a wazero module, applies
|
||||
// the per-plugin resource caps, and registers exported functions for each
|
||||
// declared capability.
|
||||
func (r *Registry) activateWithRuntime(ctx context.Context, inst *Instance) error {
|
||||
wasmBytes, err := os.ReadFile(inst.WASMPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("plugin %q: read wasm: %w", inst.Manifest.Name, err)
|
||||
}
|
||||
_ = wasmBytes
|
||||
_ = ctx
|
||||
|
||||
// TODO(wazero): replace with the real wiring once go.mod has wazero:
|
||||
//
|
||||
// runtime := r.runtimePlatform.(wazero.Runtime)
|
||||
// compiled, err := runtime.CompileModule(ctx, wasmBytes)
|
||||
// if err != nil { return fmt.Errorf("compile: %w", err) }
|
||||
//
|
||||
// modCfg := wazero.NewModuleConfig().
|
||||
// WithName(inst.Manifest.Name).
|
||||
// WithStdout(io.Discard).
|
||||
// WithStderr(io.Discard)
|
||||
//
|
||||
// memBytes := uint32(inst.Manifest.Resources.MaxMemoryMB)
|
||||
// if memBytes == 0 { memBytes = uint32(r.cfg.MaxMemoryMB) }
|
||||
// // wazero pages are 64 KiB; the runtime config caps via WithMemoryLimitPages.
|
||||
//
|
||||
// module, err := runtime.InstantiateModule(ctx, compiled, modCfg)
|
||||
// if err != nil { return fmt.Errorf("instantiate: %w", err) }
|
||||
//
|
||||
// inst.module = module
|
||||
//
|
||||
// // Walk inst.Manifest.Permissions and call host_*.Register* for each
|
||||
// // capability so the runtime knows what exports to look for.
|
||||
|
||||
return fmt.Errorf("plugin %q: wazero runtime skeleton incomplete (see sandbox_wazero.go)", inst.Manifest.Name)
|
||||
}
|
||||
|
||||
// invokeCommand calls the plugin's `command_dispatch` exported function with
|
||||
// the marshalled command + args, and decodes the response into a CommandResult.
|
||||
func (r *Registry) invokeCommand(ctx context.Context, inst *Instance, userID, channelID int64, cmd string, args []string) (*CommandResult, bool) {
|
||||
_ = ctx
|
||||
_ = inst
|
||||
_ = userID
|
||||
_ = channelID
|
||||
_ = cmd
|
||||
_ = args
|
||||
// TODO(wazero): real call:
|
||||
// fn := module.ExportedFunction("command_dispatch")
|
||||
// payload := encodeCommand(userID, channelID, cmd, args)
|
||||
// result, err := fn.Call(ctx, ...)
|
||||
// ...
|
||||
return &CommandResult{
|
||||
Reply: "plugin runtime: command dispatch not yet implemented in skeleton",
|
||||
}, true
|
||||
}
|
||||
Reference in New Issue
Block a user