Plugins and Marketplace
Plugins extend Wayland Core with extra providers and tools. The plugin subcommand installs them from a registry, lists what you have, and removes them. Plugins resolve from a local registry by default, or from a remote GitHub source.
See what is available
Section titled “See what is available”wayland-core plugin availableThis reads the local default registry shipped with the binary. To point at your own directory of TOML manifests instead:
wayland-core plugin available --registry-dir ./my-registryInstall
Section titled “Install”Names are kebab-case. The default source is local, which reads the embedded registry or the directory you pass:
wayland-core plugin install wayland-honchoTo install from a remote source, use a github://<org> source spec, which resolves against GitHub Releases on that org:
wayland-core plugin install wayland-honcho --source github://ferroxlabsPlugins install under the platform data directory by default (for example ~/Library/Application Support/wayland-core/plugins on macOS). You can override the location with --install-root, though that is mainly for tests and sandboxes.
List and remove
Section titled “List and remove”wayland-core plugin list # installed pluginswayland-core plugin remove wayland-honchoSigning and verification
Section titled “Signing and verification”Remote artifacts are verified before they are trusted. Releases are signed, and the engine checks the signature against a pinned marketplace public key (ed25519) before swapping anything into place. The same signing discipline gates self-update.
Plugin runtimes
Section titled “Plugin runtimes”Two plugin runtimes exist. They share the same plugin.toml manifest format and the same [permissions] block, but differ in how the plugin code runs and what isolation they provide.
| Subprocess | WASM | |
|---|---|---|
| Code format | Native binary | .wasm Component Model component |
| Isolation | None (inherits engine uid, env, and filesystem) | wasmtime sandbox: memory cap, fuel budget, epoch timeout |
| Secrets reach code | Full env (after env_clear allowlist) | Never. Existence-check only via secret-exists. |
| Network from plugin code | Unrestricted (plugin’s own syscalls) | Host-proxied, allowlist-gated |
| Restart on crash | Yes: crash budget (3 strikes), backoff 100 ms / 500 ms / 2 s | N/A. Each call is a fresh component instance. |
| Implementation crate | wcore-plugin-subprocess | wcore-plugin-wasm |
Subprocess plugins
Section titled “Subprocess plugins”Subprocess plugins are native binaries. The engine spawns the binary, talks to it over JSON-Lines on stdin and stdout, and routes tool calls back through the same transport.
The implementation lives in crates/wcore-plugin-subprocess/src/runner.rs.
Manifest
Section titled “Manifest”Drop a plugin.toml alongside the binary:
plugin_api_version = "1.0"
[plugin]name = "my-tool"version = "0.1.0"description = "A native plugin"entry = "my-tool"license = "MIT"
[permissions]register_tools = truetool_namespace = "MyTool"
[runtime]kind = "subprocess"
[runtime.subprocess]binary_path = "my-tool"args = []binary_path is resolved relative to the directory containing plugin.toml.
Wire protocol
Section titled “Wire protocol”The host sends one JSON object per newline-terminated line on stdin; the plugin responds on stdout in the same framing. Each request carries a monotonic id; the response echoes the same id. The verb sequence is:
{"id":1,"verb":"init"}: plugin replies withmanifest_versionand a capability list.{"id":2,"verb":"list_tools"}: plugin replies with atoolsarray of{name, description, input_schema}descriptors.{"id":3,"verb":"call_tool","name":"…","input":{…}}: plugin replies with{stdout, structured, is_error}.{"id":4,"verb":"shutdown"}: plugin replies with{"id":4,"kind":"ack"}and exits.
Lines from the plugin’s stdout are capped at 8 MiB. A line exceeding that cap without a terminating newline tears the transport down, triggering the crash budget.
Environment isolation
Section titled “Environment isolation”spawn_binary calls Command::env_clear() before spawning. Only an explicit allowlist is forwarded: PATH, HOME, USER, LANG, TZ, locale vars (LC_ALL, LC_CTYPE, LC_MESSAGES, LC_MONETARY, LC_NUMERIC, LC_TIME), TMPDIR, and Windows essentials (SYSTEMROOT, WINDIR, COMSPEC, PATHEXT, USERPROFILE, APPDATA, LOCALAPPDATA, PROGRAMFILES, TEMP, TMP, and others). Variables such as OPENAI_API_KEY, ANTHROPIC_API_KEY, and WAYLAND_VAULT_PASSPHRASE are withheld.
Crash budget and restart
Section titled “Crash budget and restart”Each SubprocessPluginRunner holds an Arc<AtomicU8> consecutive-failure counter. The following events count as one strike: broken pipe or EOF, RPC timeout (30 seconds), JSON parse error, subprocess-side protocol error, and unexpected non-zero exit. On a strike:
- If
crash_count < 3: the runner restarts the subprocess in place, with backoff of 100 ms (strike 1), 500 ms (strike 2). It replays the init and list-tools handshake, then retries the failed call once on the fresh instance. - If
crash_count >= 3: the plugin is auto-disabled for the lifetime of this runner. Further calls return an error immediately.
A successful call resets the counter (consecutive-only semantics).
WASM plugins
Section titled “WASM plugins”WASM plugins are WebAssembly Component Model components. The engine runs them inside a wasmtime sandbox with hard limits on memory, CPU (fuel), and wall-clock time. Each tool call instantiates a fresh component with no shared mutable state between calls.
The implementation lives in crates/wcore-plugin-wasm.
Manifest
Section titled “Manifest”plugin_api_version = "1.0"
[plugin]name = "my-wasm-tool"version = "0.1.0"description = "A WASM plugin"entry = "my-wasm-tool.wasm"license = "Apache-2.0"
[permissions]register_tools = truetool_namespace = "MyWasmTool"allow_network = falseallow_workspace_read = falseallow_workspace_write = falseallow_tool_invoke = falsepermitted_secrets = []http_allowlist = []
[runtime]kind = "wasm"All allow_* flags default to false and permitted_secrets defaults to empty. Omitting them from the manifest has the same effect. The composition root is fail-closed: every capability defaults to the Deny* adapter unless explicitly granted.
WIT worlds
Section titled “WIT worlds”Plugin behavior is defined by two WIT worlds in crates/wcore-plugin-wasm/wit/:
wayland-tool (tool.wit): for plugins that register tools. The component imports the shared host interface and exports the tool interface, which exposes:
execute(req: request) -> result<response, string>: runs the tool.schema() -> string: returns the JSON Schema for the tool’s input.description() -> string: returns the human-readable description.metadata() -> tool-metadata: returns the full metadata record includingname,description,input-schema,category,is-deferred,max-result-size, andcaps-version.
wayland-hook (hook.wit): for plugins that intercept lifecycle events. The component imports host and exports the hook interface, which exposes five callbacks: pre-tool-call, post-tool-call, on-session-start, on-session-end, and on-error. The pre-tool-call callback can veto a tool call by returning Err.
Both worlds share the wayland:host/host interface from wayland-host.wit (package wayland:host@1.0.0).
Host interface
Section titled “Host interface”The host interface exposes the following functions to the WASM guest:
| Function | What it does |
|---|---|
log(level, msg) | Emit a structured log entry. level is one of trace, debug, info, warn, error. |
now-millis() -> u64 | Current wall-clock time in milliseconds since Unix epoch. |
workspace-read(path) -> result<list<u8>, string> | Read a workspace-relative file. Path must not contain .. or a leading /. |
workspace-write(path, body) -> result<_, string> | Write a workspace-relative file. Same path constraints. |
http-request(req) -> result<http-resp, string> | Make an outbound HTTP request through the host’s allowlisted client. |
secret-exists(name) -> bool | Check whether a named secret is available to this plugin. |
tool-invoke(name, input) -> result<string, string> | Invoke another registered tool by name. |
emit-message(role, text) | Emit a message into the host’s conversation log. |
is-cancelled() -> bool | Poll for cooperative cancellation. |
Every capability is fail-closed. Unless the corresponding allow_* or permitted_secrets flag is set in the manifest, the adapter returns a permission-denied error (or false for secret-exists) without calling through to the host.
Permissions and adapter selection
Section titled “Permissions and adapter selection”At load time the runner reads the manifest’s [permissions] block and selects either a Gated* or Deny* adapter for each capability:
| Permission flag | Capability gated |
|---|---|
allow_network = true | http-request. Also requires a non-empty http_allowlist; an empty allowlist denies all requests even when allow_network = true. |
allow_workspace_read = true | workspace-read |
allow_workspace_write = true | workspace-write |
allow_tool_invoke = true | tool-invoke |
permitted_secrets = ["NAME", …] | secret-exists. Only names in the list return true. Secret values are never exposed to WASM. Credentials reach the network only via http-request, where the host injects them. |
Setting allow_network = true without populating http_allowlist still results in every request being denied by the GatedHostHttp adapter.
Security model
Section titled “Security model”The WASM sandbox enforces isolation at the wasmtime level:
- Memory cap: 10 MiB cumulative across all linear memories in the component instance (
DEFAULT_MEMORY_BYTES). Growth beyond the cap is denied byWasmResourceLimiter. - Fuel budget: 500,000,000 fuel units per execution (
DEFAULT_FUEL). Execution traps when fuel is exhausted. - Epoch timeout: 60 seconds wall-clock per execution (
DEFAULT_TIMEOUT_SECS), enforced by a background epoch-ticker thread that increments the engine’s epoch every 500 ms. - No threads: the wasmtime engine is configured with wasm threads disabled. Each plugin instance is single-threaded.
- Secrets never leave the host:
secret-existsreturns a boolean only. Secret values are injected by the host at the HTTP boundary insideGatedHostHttp, not passed to WASM. - Response leak scan: HTTP response bodies are scanned for any byte string matching a permitted secret value before being returned to the guest. Matches are replaced with
<REDACTED>. - SSRF guard: the HTTP adapter blocks requests to loopback addresses, RFC 1918 ranges, link-local (169.254.x.x), and cloud-metadata hostnames (including
metadata.google.internal,metadata.goog,kubernetes.default). - Per-plugin workspace isolation: each plugin gets an isolated directory under
<user-data-dir>/wayland/plugin-workspace/<sanitized-name>-<hash>, created at0700. Workspace reads and writes are path-containment-checked;..traversal and symlink escapes are refused. - Fresh instance per call:
LoadedWasmPlugin::call_toolbuilds a newStore<HostState>, a newLinker, and a new component instance for every invocation. There is no shared mutable state between calls.
Engine configuration
Section titled “Engine configuration”build_engine() configures wasmtime with:
wasm_component_model(true): required for the Tool and Hook WIT worlds.consume_fuel(true): enables the fuel budget knob.epoch_interruption(true): enables the wall-clock timeout knob.async_support(true): required forinstantiate_async.debug_info(false): debug info is never included in plugin sandboxes.
The epoch ticker thread is named wcore-plugin-wasm-epoch-ticker and is dropped (and joined) when the WasmPluginRunner is dropped.
Authoring a plugin
Section titled “Authoring a plugin”A plugin ships a manifest plus the code it registers into the plugin API: providers and tools the engine picks up once installed. The marketplace format defines the registry entry and manifest fields.
For subprocess plugins, implement the JSON-Lines wire protocol described above. For WASM plugins, implement one of the two WIT worlds (wayland-tool or wayland-hook) and compile your component to target wasm32-unknown-unknown or wasm32-wasip2. For the full contract, see the plugin and marketplace authoring reference and docs/marketplace-format.md in the engine repository.