Skip to content

plugin development

Alexander Herling edited this page Nov 5, 2026 · 2 revisions

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 time a call waits for its requests does not count against the call's own time limit - each request at most up to its own limit, and 60 seconds per call in all; a call whose requests take longer together ends with plugin_http_time_budget. 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"]
names = []                                     # whole file names: ["Dockerfile", "*.mk"]
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
                                               # ("0.19.0" or later with [match] names)

[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.

Which plugin a file goes to

[match] claims files three ways: extensions (what follows the last dot, without it, any case), names (whole file names) and magic (bytes at an offset). A file larger than max_size is never offered.

names takes whole file names, never a folder: compared without regard to case, with * as the only wildcard (Dockerfile, Dockerfile.*, *.mk, .env.*). At most 32 names of at most 64 characters each; no path separator or character Windows forbids in a name, none twice, and none made of * and . alone. A manifest with names needs min_app_version = "0.19.0" or later - an older rCommander refuses the package.

rCommander asks the plugins in this order, each group by plugin name, up to four in all, and the first that does not turn the file down in probe shows it:

  1. the plugin the person gave the extension to (Extensions given to a plugin);
  2. an exact name in names (Dockerfile, nginx.conf);
  3. the extension in extensions;
  4. a name pattern with * in names (Dockerfile.*, *.conf);
  5. magic.

So an exact name wins over every extension, and an extension over a pattern: Dockerfile.dev goes to a plugin claiming Dockerfile.*, while Dockerfile.toml stays with the one claiming toml. A plugin that claims broadly - *.conf, say - should decline in probe the files it cannot read, so the next one gets them.

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