Skip to content

plugin development

Alexander Herling edited this page Oct 29, 2026 · 1 revision

Plugin development

Pre-alpha. Two kinds can be written, packaged and checked with the current build: viewer plugins and secret sources (a password manager as the source of a connection's secret). rCommander does not ask a secret source yet - connections take their secrets from one in a later release. The contract (rcommander:plugin@0.3.0) may still change before its 1.0; a plugin is rebuilt against a new one, and the host still runs plugins built against 0.1.0 and 0.2.0.

An rCommander plugin is a signed WebAssembly component. It runs in a sandbox with no file system, no processes, no network and no WASI: it reaches exactly the interfaces its kind is made of and its manifest grants, and nothing can be asked for at run time. rCommander renders what a plugin returns - a plugin never draws a window of its own. Installing and using plugins is Plugins.

What you need

  • Rust with the target wasm32-unknown-unknown (rustup target add wasm32-unknown-unknown).
  • wasm-tools 1.261.0, the version rCommander's own plugins are made with: cargo install wasm-tools --version 1.261.0 --locked.
  • rcommander.exe of the release you build for: rcommander plugin ... writes, packages, signs and checks plugins.

On Windows rcommander.exe is a program with a window: started from a console it prints into that console, but the prompt may come back before it is done. In cmd start /wait rcommander plugin ..., in PowerShell Start-Process -Wait, waits for it and its exit code.

Start from the scaffold

rcommander plugin new --type viewer --out my_viewer
cd my_viewer
cargo test
cargo build --release --target wasm32-unknown-unknown
wasm-tools component new target/wasm32-unknown-unknown/release/my_viewer.wasm -o target/my_viewer.wasm

plugin new writes a small, working viewer: CSV and TSV files as a Markdown table, any other file as text. It compiles, packages and passes conformance before you change a line, so the first failure you see is about your code. The folder's name becomes the crate's (--name sets another), and the scaffold gets:

  • its own id, a UUIDv7 in manifest.toml - keep it across versions;
  • its own key pair: the private half in plugin-signing.key, the public half in manifest.toml. A .gitignore written before the key keeps it, target/ and *.rcplug out of version control;
  • src/lib.rs (the logic, with what the host promises a viewer at the top), src/table.rs, src/types.rs (the contract's shapes as plain Rust), src/lib_tests.rs - all of it runs with cargo test without a WebAssembly toolchain;
  • src/guest.rs, the translation into the contract's vocabulary, built only for wasm32 with wit-bindgen 0.62;
  • wit/rcommander.wit, the contract; locales/en.json; README.md; CHANGES.md, a ## <version> section per version in one to three sentences for the people who install it.

A secret source

rcommander plugin new --type secrets --out my_source

A secret source answers rCommander while it connects: status (ready, locked, unreachable, unlicensed), search (ids, titles, users and addresses of the entries matching a query - never a value) and get (one secret of one entry: password, private key, passphrase or the current one-time code). It receives no secret of rCommander's, and it never asks for a manager's master password: a locked manager answers locked and the person unlocks it in the manager itself.

It reaches its manager through http, granted in the manifest as one port of this machine:

[capabilities]
net_http = { loopback = 8087 }   # plain HTTP to localhost:8087, nothing else

The install preview shows the port, so installing is the person's confirmation, and an update that moves it asks again. A request to anything else - another port, another host, https, rCommander's own MCP port - is refused before it leaves; a redirect is answered, never followed; an answer larger than [limits] max_response_bytes is refused. The scaffold speaks to an example bridge with three plain-text requests; replace them with your manager's local API.

What the host promises a viewer

  • The file is a handle, never a path. source gives its name, size and bytes, read in windows of at most 1 MiB. There is no file system to reach anything else.
  • One document, several views. open names every output the document offers - text, highlighted (coloured text), markdown, image, tree, pages - the first shown, each one listed in the manifest's [viewer] outputs; the host refuses any other. The person switches between them with 7.
  • One store per document. What open learned stays for the calls after it until the file is closed; memory, fuel and time are counted per call. Reading a byte earns fuel, so a viewer that streams its file is not starved by its size.
  • Moving on cancels. Quick View moves to the next file: ask should-stop between windows and end with cancelled. A call that keeps running is ended.
  • Classes, not colours. Coloured text names a token class per span (key, string, number, comment, ...); the theme colours it, and only the lines on screen are asked for.
  • Every answer is checked. Outputs the document did not name, tokens outside their line, a tree too deep or too wide, text over the limit, a raster of the wrong size - refused or cut, and a plugin that crashes three times in five minutes is suspended.

The manifest

manifest_version = 1
plugin_type = "viewer"
api_version = "0.1.0"                          # the contract built against
id = "019d0000-0000-7000-8000-0000000000a1"   # UUIDv7, stable across versions
name = "CSV table"
version = "1.0.0"                              # strict SemVer
key_id = "csv_table-release-v1"
public_key = "...base64 Ed25519..."

[match]                                        # which files rCommander offers the plugin
extensions = ["csv", "tsv"]
magic = []                                     # [{ offset = 0, hex = "25504446" }]
max_size = 268435456

[viewer]
outputs = ["markdown", "text"]

[capabilities]                                 # a viewer is granted nothing

[metadata]
description = "Shows CSV files as a table."
author = "Jane Doe"
license = "MIT"
min_app_version = "0.1.0"                      # the first rCommander that accepts api_version

[limits]                                       # optional: memory_bytes, fuel, timeout_milliseconds
timeout_milliseconds = 30000

At most 1 MiB, unknown keys refused. [[settings]] declares the plugin's own settings (type = "choice" with values, or "text"), shown on its page in Settings; a text setting with masked = true (a licence key, a token) is typed hidden and never shown to an AI assistant. A failure code the plugin reports starts with its id (<id>.<condition>) and has its text in locales/en.json; the host drops any other code.

Package, sign, check

rcommander plugin package --manifest manifest.toml --component target/my_viewer.wasm \
  --locales locales --key plugin-signing.key --output my_viewer.rcplug
rcommander plugin verify my_viewer.rcplug --trusted-key my_viewer-release-v1=<public_key>
rcommander plugin conformance my_viewer.rcplug --trusted-key my_viewer-release-v1=<public_key>
  • package checks the manifest, the translations and the component's imports, signs the package and verifies the result. --key may be left out when RCOMMANDER_PLUGIN_SIGNING_KEY holds the key's PEM text. --development writes an unsigned package, which only development mode installs - for your own tests, never for a release.
  • verify checks a package as installing it would and says what it is; for a key it does not know it prints the fingerprint to compare.
  • conformance answers whether this rCommander would run your package, and exits 1 if not. --json prints the report for a CI job.
  • keygen --out <file> writes another key pair, for a release key kept apart from the one plugin new wrote. A key file is never overwritten.

A .rcplug is a ZIP of exactly manifest.toml, component.wasm, signature.ed25519 and locales/<lang>.json; the Ed25519 signature covers all of them, so neither the component nor a translation can be swapped after the release.

What conformance checks

Check What it asks
package The package verifies as installing would: archive, manifest, API version, translations, signature, limits, and every import the manifest grants
world The component exports the world its kind requires
viewer_empty_source probe, open and the first output the document names, on an empty file named like one [match] claims, end in an answer or a refusal - never a crash, an exhausted budget or an answer against the rules
secrets_no_service A secret source's status and a search with its granted port refused, as when the manager is not running, end in an answer or a refusal - never a crash

It does not check that your viewer shows a real file correctly: the report says what was verified, not that the plugin is good.

Imports

A component may import only rcommander:plugin interfaces - and of those only what its kind and manifest grant. One that imports anything else (a wasi: interface a dependency pulled in) is refused when packaged, installed and run. wasm-tools component wit my_viewer.wasm shows its imports; rCommander's scripts/check-plugin-imports.sh and the CI workflow refuse any outside the contract.

CI

sdk/ci/plugin.yml of rCommander's repository is a GitHub Actions workflow for a plugin's own repository: test, build, refuse imports outside the contract, package, verify and run conformance on a Windows runner with the rCommander release it pins (RCOMMANDER_VERSION). Only a push to the default branch or a tag signs, with the key from the repository secret PLUGIN_SIGNING_KEY; a pull request is checked unsigned in development mode.

Licence

The scaffold and the CI workflow are MIT licensed, unlike rCommander itself (GPL-3.0-or-later): a plugin built from them may carry any licence.

The contract

wit/rcommander.wit is the contract. The reference below is generated from it.

Contract reference: rcommander:plugin@0.3.0

Every world a plugin can be built against, and every interface in the contract, as the WIT states them. A world's name is also the scaffold's: rcommander plugin new --type <name> for the world <name>-plugin.

Worlds

World Imports Exports Scaffold
viewer-plugin host, source, sink, settings viewer --type viewer
secrets-plugin host, settings, http secrets --type secrets

Interface types

enum failure-kind

not-found, exists, access-denied, not-supported, bad-data, wrong-password, cancelled, io, other

record failure

Field Type
kind failure-kind
code option<string>
params list<tuple<string, string>>

enum entry-kind

file, folder, link

record entry

Field Type
name string
kind entry-kind
size option<u64>
modified option<string>
attributes u32

record source-info

Field Type
name string
size u64
modified option<string>

type source = u32: a handle valid for this call or session, never a path

type sink = u32

Interface host

always; rDownloader's host without wait and secret-available

  • log: func(level: string, message: string)
  • now-unix-seconds: func() -> u64
  • random-bytes: func(count: u32) -> list<u8>

Interface source

by kind; rDownloader's source: a handle, never a path

Uses failure, source, source-info from types.

  • info: func(src: source) -> source-info
  • read-at: func(src: source, offset: u64, length: u32) -> result<list<u8>, failure> - <= 1 MiB
  • progress: func(done: u64, total: option<u64>)
  • should-stop: func() -> bool

Interface sink

by kind; rDownloader's sink, addressed by handle

Uses failure, sink from types.

  • write-at: func(out: sink, offset: u64, bytes: list<u8>) -> result<_, failure> - <= 1 MiB
  • committed: func(out: sink) -> u64
  • sync: func(out: sink) -> result<_, failure>
  • progress: func(done: u64, total: option<u64>)
  • should-stop: func() -> bool

Interface settings

by manifest; rDownloader's destination-settings

  • setting: func(name: string) -> option<string> - The value of the declared setting name for the call in progress: what the person chose, else the manifest's default. none for a name the manifest does not declare.

Interface http

capability net_http; rDownloader's http without templates or cookies

Uses failure from types.

  • request: func(method: string, url: string, headers: list<tuple<string, string>>, body: list<u8>) -> result<http-response, failure> - One request to a target the manifest grants; a redirect is answered, never followed.

record http-response

Field Type
status u16
headers list<tuple<string, string>>
body list<u8>

Interface viewer

Uses failure, source, sink from types.

  • probe: func(src: source) -> bool
  • open: func(src: source, purpose: purpose, theme: theme) -> result<document, failure>
  • text: func(out: sink) -> result<string, failure> - the encoding written
  • tokens: func(first-line: u32, count: u32) -> result<list<list<token>>, failure> - highlighted
  • image: func(out: sink) -> result<string, failure> - png, jpeg, gif, bmp, webp, rgba:<w>x<h>
  • page: func(index: u32, width: u32, height: u32, out: sink) -> result<_, failure>
  • children: func(node: option<u64>) -> result<list<tree-node>, failure>
  • search: func(needle: string, from-page: u32, backwards: bool) -> result<option<u32>, failure>
  • thumbnail: func(src: source, size: u32, out: sink) -> result<string, failure>

enum purpose

lister, quick-view

enum output

text, highlighted, markdown, image, tree, pages

record theme

Field Type
dark bool
background string
foreground string
accent string

record document

first shown

Field Type
outputs list<output>
title option<string>
pages option<u32>

record tree-node

Field Type
id u64
label string
value option<string>
has-children bool

enum token-class

keyword, key, string, number, literal, comment, punctuation, operator, type-name, function, invalid

record token

UTF-8 bytes within its line

Field Type
start u32
len u32
class token-class

Interface secrets

Uses failure from types.

  • status: func() -> source-state
  • search: func(query: string, limit: u32) -> result<list<secret-entry>, failure> - never a value
  • get: func(id: string, field: secret-field) -> result<string, failure> - the one secret answer

enum source-state

ready, locked, unreachable, unlicensed

enum secret-field

password, private-key, passphrase, totp

record secret-entry

Field Type
id string
title string
user option<string>
url option<string>

Clone this wiki locally