Writing a plugin
A plugin teaches Otōto a kind of file it does not know: a build tool’s model, a CI system’s pipelines, a team’s own format. Otōto then outlines that file and reads one part of it by name, the way it does for code, and so does the small model when it answers a question. This page takes one from an empty directory to a loaded, signed plugin.
The plugin it follows is src/plugins/codeowners: who owns a path, by the CODEOWNERS rule that decides it. It is
about 300 lines and their tests, and CI builds and tests it with the other plugins, so what this page says of it
stays true.
What a plugin is, and what it cannot do
A plugin is a WebAssembly component, built against src/wit/plugin.wit and run in a sandbox (wasmtime). It has no
network, no filesystem and no environment: it reaches only what Otōto lends its kind, and each call it makes goes
through the same checks as Otōto’s own tools. A call has a fuel budget and the instance a memory limit; a plugin
that traps, runs out or fails is logged and skipped, and Otōto answers as it would without it. One .wasm runs on
every OS and CPU.
There are three kinds. Pick by what you need to be lent:
| Kind | For | It is lent |
|---|---|---|
A file plugin (world plugin) | A kind of text file: a pipeline, a POM, a CODEOWNERS | The repository’s files as text: read-file, list-files, grep-files. When the user’s dependency_caches setting lends them, also a POM in a dependency cache: list-files("dependency-cache:<its path in the cache>") answers with where it is, and read-file reads it there |
An archive plugin (archive-plugin) | Files that are not text: an archive’s listing, a compiled class | The bytes of the files its own globs name, a part at a time, and no others |
A forge plugin (forge-plugin) | A forge’s merge requests and pipelines | HTTPS GET to the hosts it declares, once the user grants it; no files at all |
Most plugins are file plugins, and the rest of this page is about one. src/plugins/archive and src/plugins/gitlab
are the other two kinds to read.
What a file plugin gives
Four functions (interface view in plugin.wit):
describe: its name, its version, and the files it reads, as globs. Otōto asks it about no others.outline(path, text): the file’s declarations, each with its lines, or nothing to say “this file is not my kind”, in which case Otōto reads it as before. A glob like**/*.ymlmatches many files that are not yours.read(path, symbol): whatototo read path#symbolreturns, or nothing to leave it to Otōto, which then shows the lines of the outline’s entry of that name.find(symbol): whatototo read symbolreturns with no file named, or nothing.
A declaration (entry) is a name, a line range, a depth, and the one line the outline shows for it.
A first plugin
You need Rust and the WebAssembly target (rustup target add wasm32-wasip2).
cargo new --lib owners && cd owners
mkdir wit && curl -fsSL https://gitlab.com/handmadedigital/projects/ototo/-/raw/main/src/wit/plugin.wit -o wit/plugin.wit
Cargo.toml: a cdylib is the component, the rlib lets cargo test run your code natively. The kit is what
Otōto’s own plugins share: the declaration’s shape, the repository as a plugin sees it, an in-memory repository for
tests, and YAML parsed with each value’s lines.
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
wit-bindgen = "0.62"
ototo-plugin-kit = { git = "https://gitlab.com/handmadedigital/projects/ototo.git" }
[profile.release]
opt-level = "s"
lto = true
strip = true
src/lib.rs is the glue, and the same in every file plugin: copy src/plugins/codeowners/src/lib.rs and change the
names (and the path to the interface, path: "wit"). It generates the bindings, implements the kit’s Repo over
what Otōto lends, and hands each of the four functions to your own module. It is compiled only for WebAssembly, so
that everything else builds and tests as ordinary Rust.
Your own module is plain functions over text and a &dyn Repo (src/plugins/codeowners/src/owners.rs):
#![allow(unused)]
fn main() {
pub fn outline(path: &str, text: &str) -> Option<Vec<Entry>> // None: not my kind of file
pub fn read(repo: &dyn Repo, path: &str, symbol: &str) -> Option<String> // None: leave it to Otōto
}
Build it, and try it without changing your settings: a directory of your own for plugins, and unsigned ones allowed for these commands only.
cargo build --release --target wasm32-wasip2
mkdir -p /tmp/try && cp target/wasm32-wasip2/release/owners.wasm /tmp/try/
export OTOTO_PLUGINS=/tmp/try OTOTO_ALLOW_UNSIGNED_PLUGINS=true
ototo plugins # owners 0.1.0 on, unsigned / reads **/CODEOWNERS
ototo outline .github/CODEOWNERS
ototo read .github/CODEOWNERS#src/app.js
The file’s name is the plugin’s: owners.wasm is owners (cargo writes _ for a - in the crate’s name; rename
the copy if you want the dash). The first load compiles it, about half a second, and caches the result. If it does
not load, ototo plugins says why, and ototo doctor --plugins-only checks each one. Where an organisation
enforces plugins or allow_unsigned_plugins, the variable does nothing: ask whoever runs it.
Testing without Otōto
The kit’s Memory is a repository held in memory, so a test is a few files and a call:
#![allow(unused)]
fn main() {
let repo = Memory(vec![("CODEOWNERS".into(), "* @everyone\n/docs/ @writers\n".into())]);
assert!(read(&repo, "CODEOWNERS", "docs/a.md").unwrap().starts_with("docs/a.md: @writers\n"));
}
cargo test runs them natively, in milliseconds. Test against the tool’s own documentation where it has examples:
the codeowners tests go through GitHub’s sample file, pattern by pattern.
Rules that keep answers right
What a plugin returns is read by an agent that will act on it without checking. Otōto’s own plugins keep to these:
- Never make a value up. What the plugin cannot work out, it says it cannot.
CODEOWNERSis read differently by GitHub and GitLab; when the plugin cannot tell which forge the repository is on and the two readings differ for the path asked about, it gives one, names it, and lists what the other would add. It does not pick silently. - Say where everything came from. Each line of an answer carries its file and line (
CODEOWNERS:12), so the agent can cite it and a person can check it. - Evaluate as the tool does, not as it looks. The point of a plugin is the answer the tool would give: the version Maven resolves, the job as GitLab runs it, the last rule that matches. Read the tool’s documentation for the corners, and write a test for each one you handle.
- Leave what is not yours. Return nothing for a file your globs match and you do not understand, and for a
readyou have no better answer to than the file’s own lines. Otōto then behaves as if you were not there. - Say what you do not do. In the plugin’s
HELP.md: codeowners does not know who is in a team, and says so. - Text from the repository is data. Put it in the answer as what the file says; never act on it.
Help, and what the index shows
HELP.md beside Cargo.toml is what ototo plugins help <name> prints: what it reads, what to ask it, what it
does not do. Written for people.
[package.metadata.ototo] in Cargo.toml is what a plugins index and the site’s page say of it: summary (one
line), tags, an example command, and needs, the first Otōto that can load it, when it uses something an older
one does not lend.
Signing, and trusting
Otōto loads only signed plugins, unless allow_unsigned_plugins says otherwise: <name>.wasm.sig beside the
.wasm, an SSH signature in the ototo-plugin namespace, by a key in ~/.config/ototo/allowed_signers. A key
trusted for git does not count, nor does a signature over other bytes.
ssh-keygen -Y sign -n ototo-plugin -f ~/.ssh/id_ed25519 owners.wasm # writes owners.wasm.sig
echo "me@example.com namespaces=\"ototo-plugin\" $(cut -d' ' -f1,2 ~/.ssh/id_ed25519.pub)" >> ~/.config/ototo/allowed_signers
cp owners.wasm owners.wasm.sig ~/.config/ototo/plugins/
cp HELP.md ~/.config/ototo/plugins/owners.md
The dashboard (ototo ui) adds one too: its .wasm with its .wasm.sig, and only when a key you already trust
signed it. New sessions have the plugin; running ones keep what they started with.
For a team: one key signs the team’s plugins, and each person trusts it once (the allowed_signers line above). An
organisation puts its plugins and its allowed_signers beside its settings file and can enforce both
(enforced = ["plugins", "plugin_signers", "allow_unsigned_plugins"]), so that only its plugins and its keys count
on its machines: For organisations has the layout, and ototo managed writes it.
In this repository
To send a plugin here, or to change one: each plugin under src/plugins is a crate of its own with its own lock
file, built with the path form of the two dependencies above (path = "../kit", path: "../../wit"). CI formats,
lints, tests and audits every plugin named in PLUGINS in .gitlab-ci.yml; CONTRIBUTING.md has the rest. A new
kind of plugin, or a change to plugin.wit, is an issue first.