mplugins WASM tier — design#
The declarative tier (pill + label/exec + menus + settings) is feature-complete. This document designs the WASM tier: sandboxed, in-shell plugin UI (the assistant panel, dashboards, etc.) authored in Rust (or any language) and compiled to WebAssembly.
Status: implemented (W1–W5 + W2c). The capability/UI/SDK tiers are tested headless; the in-shell panel surface (W2c) is feature-gated (
mshell --features wasm-plugins) and verified in a running shell. The realassistant-panelGemini chat ships in margo-plugins asplugin.wasm.
Why WASM (not native, not Lua)#
- Native
.soplugins need a stable ABI margo doesn't have (gtk4/relm4/glib + rustc version lock) and run unsandboxed — rejected. - WASM is sandboxed (the host grants only the capabilities it exposes), language-agnostic (Rust → wasm primarily), and ABI-stable via the component model. This is how authors get "real Rust plugins" safely.
Architecture#
plugin.wasm (guest, sandboxed) mshell (host, GTK)
exports: init(settings) wasmtime component runtime
update(event) -> Ui renders Ui tree → GTK in a
imports: log / http / notify / … layer-shell panel; routes events
- Runtime:
wasmtime+ the component model, typed interfaces in WIT. - UI model: the guest returns a declarative UI tree (
Ui) — a small node set:vbox/hbox,label,button,entry,scroll,list,markdown. The host renders it to GTK and diffs on eachupdate. The host is the only thing that touches GTK; the guest never does. - Events: GTK interactions (
click(id),input(id, text),submit(id)) are delivered to the guest'supdate(event), which returns a newUi. - Capabilities (host imports = the sandbox boundary):
get_setting(key)— reuse the declarative[[setting]]tier (API keys, …).http(request) -> stream— outbound HTTP with streaming chunks (delivered back as repeatedupdate(StreamChunk)events) for token streams.notify(summary, body),log(level, msg),clipboard,open(url).- Nothing else — no raw filesystem / process by default.
- Surface: the rendered tree lives in a layer-shell panel (same machinery as the existing menus), opened from the plugin's bar pill.
- SDK: an
mplugin-sdkcrate wraps the WIT bindings; authorscargo build --target wasm32-wasip2and shipplugin.wasmin the plugin folder. Manifest gainsentry = "plugin.wasm"+entryKind = "wasm".
Milestones (built in order)#
- W1 — Runtime foundation: ✅
mshell-plugin-hostcrate; loads a component, links aloghost import, calls a guest export. wasmtime is feature-gated (wasm) so non-WASM builds stay lean. Verified:--features wasmcompiles (wasmtime 27 component model). - W2 — UI model + view/update: ✅ host/guest contract carries a flat node
list (
vbox/hbox/label/button/entry, children by id, rooted at "root");view()+update(event).mshell-plugin-hostexposes GTK-freeUiNode/UiEvent+ aPluginInstance. Runtime-verified end to end by ahello-guestcomponent + integration test (load → view → update(click) → round-trip). - W2b — GTK renderer + event loop: ✅
mshell-plugin-uirenders aUiNodetree to GTK and drives click/submit →update→ re-render. Feature-gated; builds + clippy clean under--features wasm. - W2c — frame integration: ✅ a plugin's panel is hosted in a
gtk::Popoveranchored to its bar pill — noframe.rs/menu coupling. A manifestopens_panelwidget carriespanel_entry(abs.wasmpath) +panel_settings(JSON) onto its derived custom widget (via the plugin bridge);custom.rsbuilds thePluginPaneland pops it up on click. Feature-gated (mshell --features wasm-plugins, chained mshell → core → frame) so default builds pull no wasmtime; without the feature the pill falls back toon_click. Built; the live surface is verified in a running wasm-plugins shell. - W3 — Capabilities: ✅ the
hostinterface now exposesget-setting(reads the declarative[[setting]]store),notify(best-effortnotify-send), and one-shothttp(http-request→http-response, blocking viaureq; the host does the I/O, the guest never touches the network).instantiatetakes the resolved settings map. Runtime-verified end to end: the guest reads aurlsetting, fetches it, and renders the body — tested against a local one-shot HTTP server (no external network). - W4 — Streaming + rich nodes: ✅ added
scroll(a scrollable log) andmarkdown(a message bubble; lightweight bold/italic/code→ Pango) node kinds, plushttp-start: the host runs the request on a worker thread and streams the body back as host-originatedstream-chunk/stream-endevents, drained byPluginInstance::pump(driven from a glib timeout in the live shell whilestreams_active). Runtime-verified end to end against a local server, drivingpumpmanually — the role the GTK timeout plays live. - W5 — SDK + real port: ✅ (SDK + proof) the
mplugin-sdkcrate gives authors an ergonomicEltree builder (vbox/scroll/markdown/button/entry/…) flattened to the wire format, the host capabilities, aComponenttrait, and anexport_component!macro. Proven by an SDK-built streaming chat guest (sdk-guest): a host test loads it and runs a full turn — submit a line → stream the reply into an "ai" bubble — against a local server. To make the SDK'sexport!work across crates, the contract now exports aninterface guest(view/update) rather than bare world functions. ✅ Real port:assistant-panelships in margo-plugins as a builtplugin.wasm(manifestentry/entry_kind) — a streaming Gemini chat that parses thealt=ssestream into bubbles, runtime-verified against a local Gemini-shaped SSE server. All milestones complete.
Risks / open decisions#
- Build cost: wasmtime is large (compile time + binary size). Feature-gate it so the default shell build is unaffected unless WASM plugins are enabled.
- The UI protocol is the hard part — the
Ui/event/diff model is the core design; start minimal (W2) and grow node types only as a real plugin needs them (driven by the assistant-panel port). - Async/streaming across the boundary — map host async (reqwest stream) to
guest
update(StreamChunk)events on the GTK main loop. - Component model vs hand-rolled ABI — use the component model (typed WIT, first-class in wasmtime) over a brittle hand-rolled ABI.
- Trust: capabilities ARE the sandbox. Only expose vetted host functions;
httpegress is the main thing to surface to users at install/enable.
Capability enforcement (deny-by-default)#
The sensitive host calls that reach outside the per-plugin filesystem
sandbox are gated on a manifest-declared allowlist. A plugin opts in with a
[capabilities] table in its manifest.toml:
[capabilities]
process = true # spawn subprocesses: the `run` / `process-start` calls
network = true # outbound HTTP: `http` / `http-start`
clipboard = true # read/write the clipboard: `copy` / `clipboard-read`
Every field defaults to false, so a manifest with no [capabilities] table
grants nothing. The shell threads the granted set to the WASM host
(mshell-plugins → config panel_capabilities → PluginPanel::new →
PluginRuntime::instantiate), and the host (HostState) refuses any ungated
call — returning a capability error to the guest and logging a denied … warn,
never performing the action. The read-file/write-file calls stay always-on
because they are already path-sandboxed (sandbox.rs) and cannot escape the
plugin's data dir. Enforcement is covered by
denies_network_without_capability in tests/loads_guest.rs.
Relationship to the declarative tier#
WASM plugins reuse the declarative pieces: the same [[setting]] form (so
API keys/config use the existing UI + 0600 storage) and the same bar-pill
placement. WASM only adds the panel UI the declarative tier can't express.