Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

KindForIt is lent
A file plugin (world plugin)A kind of text file: a pipeline, a POM, a CODEOWNERSThe 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 classThe 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 pipelinesHTTPS 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 **/*.yml matches many files that are not yours.
  • read(path, symbol): what ototo read path#symbol returns, or nothing to leave it to Otōto, which then shows the lines of the outline’s entry of that name.
  • find(symbol): what ototo read symbol returns 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. CODEOWNERS is 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 read you 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.