Writing extensions in Rust¶
A native extension is a keyword extension written as an ordinary Rust program. Sevak starts it, sends it what the user typed after its keyword, and shows the rows it answers with. It speaks the same JSON protocol as a script plugin and goes through the same approval, so everything in the script plugin guide applies. What changes is the language, a compiled program per platform, and the honest consequences of shipping a binary (see Security model).
You write a function from a query to a list of rows. The
sevak-extension-sdk
crate (only serde and serde_json as dependencies) does the protocol.
| Workflow | Script plugin | Native extension | |
|---|---|---|---|
| Written in | the visual builder | Python, Node, PowerShell, anything | Rust |
| Ships as | a folder | a folder | a folder with one program per platform |
| Needs on the user's computer | nothing | the interpreter | nothing |
| Can you read it before running it? | yes | yes | no, it is compiled |
| Sandboxed? | no | no | no |
Choose a native extension when you want speed, a single self-contained program, or an existing Rust library. For a few lines of glue, a script plugin is easier to read and to review.
Quick start¶
cargo install --git https://github.com/ninad-k/Sevak sevak-ext --locked # once
sevak-ext init my-extension --keyword hi --description "Says hi."
cd my-extension
cargo build --release
(sevak-ext builds Sevak's own plugin code, so it needs a C compiler like the
app does. In a clone of the Sevak repository use cargo run -p sevak-ext -- in
place of sevak-ext.)
Three ways to start, all from the same template
(templates/rust-extension/):
sevak-ext init <folder>writes the project.cargo generate --git https://github.com/ninad-k/Sevak templates/rust-extension --name my-extension(asks for the keyword and description).- Copy the folder and replace the four markers
{{project-name}},{{keyword}},{{description}}and{{authors}}by hand (listed in itsREADME.md).
The template is a complete, working extension and the same code as
examples/rust-hello
(a test keeps them identical), which is built and tested in Sevak's own CI.
Try it in Sevak¶
Put the folder where Sevak looks for plugins, with the program named as
plugin.toml says for your computer:
<plugins folder>/my-extension/plugin.toml
<plugins folder>/my-extension/bin/my-extension-linux-x86_64 (your platform's name)
| OS | Plugins folder |
|---|---|
| Windows | %APPDATA%\sevak\plugins |
| macOS | ~/Library/Application Support/sevak/plugins |
| Linux | ~/.config/sevak/plugins |
Choose Reload index in the tray menu. Sevak shows the Sevak: new native extension dialog (publisher, version, declared permissions, the program's SHA-256, what it runs). Choose Allow, then type your keyword and a space.
Rebuilding changes the program, which changes what the approval covers, so Sevak asks again after each rebuild (and refuses to start a program that changed since it was allowed). That is the same rule that protects users from a swapped binary.
The SDK¶
use sevak_extension_sdk::{run, Item, Query};
fn main() {
run(|query: &Query| {
if query.is_empty() {
return Ok(vec![Item::new("Type your name").subtitle("Say hello")]);
}
Ok(vec![Item::new(format!("Hello, {}!", query.text())).copy_on_enter()])
});
}
| Piece | What it is |
|---|---|
run(f) |
the whole protocol for the common case; runs until Sevak sends shutdown or closes stdin |
Extension::new(f).on_execute(g).on_initialize(h).run() |
also handle custom actions coming back, and the initialize message |
run_oneshot(f) |
for mode = "oneshot": the query is the last argument, one {"items": [...]} document is printed |
Query |
raw(), text() (trimmed), is_empty(), words(), word(n), rest_from(n), split_first(), args() (a "quoted phrase" is one argument), parse::<T>() |
Item |
Item::new(title) then .key(), .subtitle(), .icon(), .action(), .copy_on_enter(), .score(), .text_view(), .tile(); .problems() lists what Sevak would cut or drop |
Action |
the closed set: copy_text, open_url (only http, https, mailto), open_path, custom (comes back to your program), launch (only with capabilities = ["launch"]) |
Icon |
Icon::builtin("copy") or Icon::file("icon.png") (inside your folder) |
Error |
return Err(Error::new("...")), or use ? on any std::error::Error; Sevak logs it with your extension's id and shows no rows for that query |
log(text) |
stderr, which Sevak captures into its log; never print to stdout, it carries the protocol |
plugin_dir(), plugin_data_dir(), plugin_id(), sevak_version() |
what Sevak tells you through the environment |
What the SDK does for you: titles, subtitles and keys are cut to the lengths
Sevak keeps, scores are clamped below Sevak's own keyword rows, an answer holds
at most 50 rows and a line never exceeds the host's 1 MiB limit; if several
queries are waiting only the newest is answered (Sevak drops answers to older
ones anyway); a panic in your function becomes an error for that query and the
loop keeps running. In a debug build the SDK prints each row's problems() to
stderr.
Things to design for (the same as for any persistent plugin):
- Answer quickly. A query waits
timeout_ms(default 50 ms); a later answer still arrives and the list updates. Keep slow work off the query path and answer from a cache. - Keep state on disk. An idle process is stopped after
idle_timeout_secs(default 300) and started again on the next query. Useplugin_data_dir(). Sevak removes that folder when the extension is uninstalled. - Make keys stable (
project-sevak, not a timestamp): usage statistics are keyed by them.
Testing without Sevak¶
The protocol loop is a function of a reader and a writer, so the extension's
logic and the whole conversation are testable in cargo test:
use std::io::Cursor;
use sevak_extension_sdk::{Extension, Item};
let mut out = Vec::new();
let input = "{\"type\":\"query\",\"request_id\":1,\"input\":\"hi\"}
".to_owned();
Extension::new(|query| Ok(vec![Item::new(query.text())]))
.serve(Cursor::new(input), &mut out)
.unwrap();
assert!(String::from_utf8(out).unwrap().contains("\"title\":\"hi\""));
examples/rust-hello/tests/host.rs
goes further: it runs the built program through Sevak's real script-plugin host
(approval, pipes, the closed actions, a custom action coming back) in a
temporary folder. Use it as a pattern if you depend on sevak-plugins from a
checkout of the repository; outside the repository the unit tests above are the
right level.
The manifest¶
A native extension is a script plugin folder whose plugin.toml has an
[extension] table instead of command / script. All the script plugin keys
(the manifest: keyword, name,
description, mode, timeout_ms, inherit_env, capabilities, files, ...)
work as they do there.
protocol = 1
id = "script:my-extension" # required, see below
keyword = "hi"
name = "My extension"
description = "Says hi."
[extension]
version = "0.1.0" # required, semver; bump it for every release
author = "Ada Lovelace" # required; the publisher shown in the Allow dialog
license = "Apache-2.0" # required, an SPDX expression
min_sevak = "0.1.0" # the oldest Sevak you tested with
repository = "https://github.com/ada/my-extension"
homepage = "https://example.com" # optional
permissions = ["network"] # see below
[extension.binaries]
windows-x86_64 = "bin/my-extension-windows-x86_64.exe"
macos-aarch64 = "bin/my-extension-macos-aarch64"
linux-x86_64 = "bin/my-extension-linux-x86_64"
| Field | Rules |
|---|---|
id |
Required for a package: script:<name> where <name> is lower case letters, digits and - (at most 48), the same as the gallery id and the folder it installs to. The package names itself so the folder cannot be chosen by whoever serves it. Never change it: it is part of result ids and usage statistics. |
extension.version |
semver (1.2.3). The gallery and Settings compare versions to offer updates. |
extension.author |
the publisher, plain text (control characters are refused, direction-changing characters are removed when shown). It is your word, not a verified identity. |
extension.license |
an SPDX expression such as Apache-2.0 or MIT OR Apache-2.0. |
extension.min_sevak |
Sevak refuses to load the extension on an older version, and Settings does not offer it to one. |
extension.repository / homepage |
https:// addresses, shown as text; Sevak never opens or fetches them. |
extension.permissions |
lower case words, at most 8, each at most 24 characters. Known: network (connects to the internet or your network), filesystem (reads or writes files outside its own folders), processes (starts other programs), clipboard (reads or changes the clipboard itself), system (changes system settings). Others are shown as written, labelled as the author's own. An empty list says "needs none". Declared, not enforced (see below). |
extension.binaries |
platform to the program's path inside the folder. Platforms: windows-x86_64, windows-aarch64, macos-x86_64, macos-aarch64, linux-x86_64, linux-aarch64. Paths are plain relative paths (no .., no drive, no backslash); a Windows program ends in .exe and the others do not; two platforms cannot share a path. List only the platforms you build. |
Do not set command or script as well: Sevak runs the program for the
platform it is on, and a computer with no build for it shows the extension as
"Not loaded: this extension has no build for ...".
Because plugin.toml is part of what the approval covers, the declared
publisher, version and permissions are bound to the allowance, and so is the
program (its bytes are hashed) and the folder's location.
Security model¶
State this to your users; Sevak does.
What Sevak does (the same rules as script plugins and workflows, reused, not reinvented):
- Never runs on install. An installed or copied native extension is new and unapproved. Nothing starts until the user chooses Allow in a dialog titled Sevak: new native extension. Declined ("Not now") asks again at the next start; disabled extensions are never asked about.
- The dialog shows what the user is deciding. The name and keyword, the publisher, version and licence, the source address, the permissions you declared (marked "the author's statement; Sevak does not enforce them"), the program's full SHA-256, the folder and location, the exact command, the environment it receives, and a plain statement that it is not sandboxed. Text an author wrote is stripped of control and direction-changing characters and cut to length, so it cannot fake lines or hide.
- The approval is bound to the program's bytes (and the manifest, and the folder's location). A rebuilt program, a changed manifest or a moved folder asks again. Before each start Sevak checks again that nothing changed since the allowance and refuses to start if it did (a swap between the dialog and the start does not run).
- Scrubbed environment. The program gets a small base set (
PATH, home and temp folders, locale, display variables, what Windows programs need) plusSEVAK_PLUGIN_ID,SEVAK_PLUGIN_DIR,SEVAK_PLUGIN_DATA,SEVAK_VERSIONand only the variables your manifest lists ininherit_env(shown in the dialog). API keys, tokens and credentials in Sevak's own environment do not reach it. - Process tree kill. When Sevak stops the extension, restarts it after a crash, or quits, it ends the whole tree of processes it started.
- A closed set of actions. Your rows can copy text, open a web or mail
link, open a path, send a payload back to you, and (with the
launchcapability, shown in the dialog) start an application. Sevak performs these itself and drops anything else; it cannot be made to paste, elevate or run a command through your rows. - Every answer is validated: sizes capped, scores clamped, bad rows skipped. A misbehaving extension cannot crash Sevak or stall typing.
What Sevak does not do. A native extension is not sandboxed beyond the above. After the user allows it, it is a program running with the user's account permissions, exactly like any program they installed. It can read and write the user's files, use the network, start other programs and read the clipboard, whatever it declared. The declared permissions are information for the person deciding, not a limit: nothing stops a program that declares none from using the network. Sevak does not inspect the program, check it against malware lists, verify who published it, or watch what it does after it starts.
What the user is trusting when they allow your extension:
- that the publisher named in the dialog is who they think it is (Sevak cannot check; the gallery's review is the check for gallery entries);
- that the program does what its description says and nothing else;
- that the program they were shown (its SHA-256) is the one you built from the source you linked, if they care to check;
- for a gallery install, the same trust as for every gallery file: Sevak's repository and its release process (see Gallery trust).
Operating systems add checks to programs that were downloaded by a browser
(macOS Gatekeeper and notarization, Windows SmartScreen). A program that Sevak
writes into the plugins folder carries no such mark, so those checks do not
apply to it: Sevak's own dialog is the only gate. On macOS on Apple silicon the
program must carry at least an ad-hoc signature to run; Rust's linker adds one
for aarch64-apple-darwin builds, so a normal cargo build is enough.
Keep your side honest: declare every permission your program uses, link the source, keep dependencies few, publish builds made from tagged source in CI, and say in your description what it contacts.
Package it¶
sevak-ext validate . # check plugin.toml (and which programs are built)
sevak-ext pack . --binary linux-x86_64=target/release/my-extension --out dist
sevak-ext pack . --split --out dist \
--binary linux-x86_64=build/linux/my-extension \
--binary windows-x86_64=build/windows/my-extension.exe
sevak-ext validate dist/my-extension-0.1.0.sevakext # check a whole package
A .sevakext file is a zip with a flat layout:
plugin.toml the manifest, byte for byte what is installed
checksums.sha256 "<sha256> <path>" for every other file (sha256sum's format)
bin/<program> one program per platform the package carries
... optional extra files (--include icon.png)
Packing is deterministic (sorted entries, fixed timestamps and permissions), so
the same inputs give the same bytes and the same checksum. --split writes one
package per platform (<id>-<version>-<platform>.sevakext), which is what the
gallery lists, so a user downloads only the build for their computer. Without
it, one package carries every program you passed; Sevak installs only the
program for the platform it runs on.
validate and pack run Sevak's own checks (they link the same code as the
app), so what they accept is what Sevak installs: safe paths, no links, size
limits (a package up to 32 MiB, a file up to 64 MiB, 128 MiB unpacked, 100 files),
every file covered by checksums.sha256 and matching it, a manifest valid for
every platform it declares, and only the programs the manifest declares (an
undeclared bin/ file or .exe is refused). Files other than the program and
plugin.toml are not covered by the approval unless you list them in files;
list data your program depends on.
Building for each platform¶
| Platform key | Rust target |
|---|---|
windows-x86_64 |
x86_64-pc-windows-msvc |
windows-aarch64 |
aarch64-pc-windows-msvc |
macos-x86_64 |
x86_64-apple-darwin |
macos-aarch64 |
aarch64-apple-darwin |
linux-x86_64 |
x86_64-unknown-linux-gnu (or -musl for the widest reach) |
linux-aarch64 |
aarch64-unknown-linux-gnu |
The template's release profile (opt-level = "z", lto, codegen-units = 1,
strip) keeps programs small; do not use panic = "abort", because the SDK
turns a panic into an error for that query. A sketch of a CI job for your own
repository (adjust it; Sevak's own, with reproducibility settings, is described
in Building and publishing native extensions with
CI):
jobs:
build:
strategy:
matrix:
include:
- { os: ubuntu-latest, target: x86_64-unknown-linux-gnu, key: linux-x86_64, ext: "" }
- { os: windows-latest, target: x86_64-pc-windows-msvc, key: windows-x86_64, ext: ".exe" }
- { os: macos-latest, target: aarch64-apple-darwin, key: macos-aarch64, ext: "" }
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- run: rustup target add ${{ matrix.target }}
- run: cargo build --release --locked --target ${{ matrix.target }}
- uses: actions/upload-artifact@v4
with:
name: ${{ matrix.key }}
path: target/${{ matrix.target }}/release/my-extension${{ matrix.ext }}
package:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
- run: |
cargo install --git https://github.com/ninad-k/Sevak sevak-ext --locked
sevak-ext pack . --split --out dist \
--binary linux-x86_64=linux-x86_64/my-extension \
--binary windows-x86_64=windows-x86_64/my-extension.exe \
--binary macos-aarch64=macos-aarch64/my-extension
Submit it to the gallery¶
The whole path, from a first pull request to what review looks like and how an entry is updated or removed, is in Publishing to the gallery and How review works. In short:
The gallery (Settings > Extensions) lists extensions kept in Sevak's own repository, because that is the only place Sevak downloads from (see Gallery trust). To propose yours, open a pull request that adds:
- The packages, one per platform you support, in
gallery/extensions/<id>/named<id>-<version>-<platform>.sevakext(sevak-ext pack --splitwrites exactly these names). Each is at most 10 MiB. - An entry in
gallery/index.json.sevak-ext entry <the packages>prints it, with the SHA-256 of each file:
{
"id": "my-extension",
"kind": "native",
"name": "My extension",
"description": "One or two sentences. Says what it contacts, if anything.",
"author": "Ada Lovelace",
"version": "0.1.0",
"license": "Apache-2.0",
"tags": ["native"],
"min_sevak": "0.1.0",
"permissions": ["network"],
"repository": "https://github.com/ada/my-extension",
"homepage": "https://github.com/ada/my-extension",
"platforms": {
"linux-x86_64": {"source": "gallery/extensions/my-extension/my-extension-0.1.0-linux-x86_64.sevakext", "sha256": "..."},
"windows-x86_64": {"source": "gallery/extensions/my-extension/my-extension-0.1.0-windows-x86_64.sevakext", "sha256": "..."}
}
}
source is a path relative to the repository root, never an address. The
entry's version, publisher, licence, minimum Sevak, repository and permissions
must equal what the package's plugin.toml says: Sevak refuses an install
where the page and the manifest disagree, and a test checks it for every entry.
3. Checks: node scripts/gallery-check.mjs (addresses, files, hashes, no
orphans; --update refreshes the hashes) and cargo test -p sevak-plugins
--test gallery_content (opens every package and compares it with its entry).
What reviewers look for, because people install what is listed:
- the source is public at the linked address, builds with a documented command, and the committed binaries are what that source produces at a tagged commit (reviewers may rebuild and compare checksums; say what toolchain you used);
- declared permissions match what the code does (they read the network, file and process use in the source), and the description says what the extension contacts;
- an open-source licence, no obfuscation or packing, no bundled secondary downloader or updater, no telemetry without saying so;
- a unique keyword that no built-in, search engine or other entry uses;
- small: one package at most 10 MiB, usually under 2.
Two limits to plan for:
- A release carries the list. A build reads the list from the tag of its own
version, not from
main, so an entry added to the repository reaches users with the next Sevak release (see Gallery trust). Users can always install your extension by hand from your own releases in the meantime: download the package, copy the folder as in Try it in Sevak (or unzip the.sevakext), and Sevak asks before it runs. - There is no signature yet. The checksum in the list comes from the same repository as the file; see the limits listed in Gallery trust.
Building and publishing native extensions with CI¶
The extensions Sevak ships itself (jwt, cron and regex) live in
examples/native/<id>/,
each its own cargo project with its own Cargo.lock and an empty [workspace]
table (so it does not join the app's workspace and is built from exactly the
dependencies it lists), a plugin.toml, a README that lists every dependency
and its licence, unit tests, and a tests/host.rs that runs the built program
through Sevak's real plugin host. The workflow
.github/workflows/native-extensions.yml
builds, tests and packages them. A new folder with a plugin.toml is picked up
without editing the workflow. (The shared test harness is
examples/native/_support/host.rs; the folder has no manifest, so it is not an
extension.)
What runs when
| Trigger | What happens |
|---|---|
A pull request touching examples/native/** (or the SDK, the packer or the workflow) |
per extension: cargo fmt --check, clippy -D warnings, cargo test --locked; then a release build for all six platforms, a smoke test where the runner can run the program, and a package per platform checked with sevak-ext validate. Packages are uploaded as artifacts (30 days) with their SHA-256 in the job summary. Nothing is committed. |
Run workflow (workflow_dispatch) with an extension id or all |
the same, for that extension |
the same with publish ticked, started on main |
additionally the publish-pr job: puts the packages in gallery/extensions/<id>/, adds or replaces the entry in gallery/index.json (sevak-ext entry and scripts/gallery-add-native.mjs; tags already chosen in the index are kept), runs node scripts/gallery-check.mjs --update and the plain check, runs cargo test -p sevak-plugins --test gallery_content, commits to a new branch gallery/native-extensions-<run id> and opens a pull request. It never pushes to main and never merges. |
Only publish-pr has write permission (contents: write, pull-requests: write);
the workflow's default is read-only, actions are pinned to commit SHAs, and the
extension input reaches scripts through the environment, never as script text.
A workflow run needs write access to the repository, so only maintainers can
start one. A pull request opened with the workflow's token does not start other
workflows: close and reopen it to run CI before merging. (The repository
setting "Allow GitHub Actions to create and approve pull requests" must be on.)
Making the builds reproducible. A gallery binary should come from public
source by a public command. The workflow builds with the toolchain pinned in
rust-toolchain.toml, cargo build --release --locked --target <triple>, the
release profile of each Cargo.toml (opt-level = "z", lto,
codegen-units = 1, strip, unwinding panics), --remap-path-prefix for the
checkout, ~/.cargo and ~/.rustup (so runner paths are not in the program),
/Brepro for MSVC (no link timestamp), and SOURCE_DATE_EPOCH set from the
commit time. Build caches are not used for the release builds. Two builds of
the same commit on the same machine give identical bytes (the three extensions
were built twice with different target folders on Windows and compared), and
sevak-ext pack is deterministic. Different runner images or toolchains may
still differ, so to verify a package compare it with the one the workflow built
for that commit, not with a local build.
| Platform | Built on | How |
|---|---|---|
windows-x86_64 |
windows-latest |
native |
windows-aarch64 |
windows-latest |
cross (aarch64-pc-windows-msvc); not run |
macos-aarch64 |
macos-latest (Apple silicon) |
native |
macos-x86_64 |
macos-latest |
cross (x86_64-apple-darwin); not run |
linux-x86_64 |
ubuntu-22.04 |
native (needs glibc 2.35 or newer) |
linux-aarch64 |
ubuntu-22.04 |
cross with gcc-aarch64-linux-gnu; not run |
Publishing a release of an extension (maintainers):
- Change the code, bump
versionin itsCargo.tomlandplugin.toml(the package name carries the version), updateCargo.lockif a dependency changed, and merge the pull request after the build and test jobs are green. - Actions → Native extensions → Run workflow on
mainwith the extension id and publish ticked. - Review the pull request it opens: the diff should be the packages for that extension and its entry (and nothing else), the hashes should match the job summary, and the build's commit is named in the description. Close and reopen it to run CI, then merge. Users get it with the next Sevak release, because builds read the gallery at their own tag.
To add a new native extension to the set, copy an existing folder in
examples/native, change the id, keyword and code, run cargo test in it
(which creates Cargo.lock; commit it), and open a pull request: the build and
test jobs run on it. Choose a keyword that no built-in or gallery entry uses;
cargo test -p sevak-plugins --test gallery_content checks that once the entry
is in the index.
Versioning and updates¶
- Your extension's
extension.versionis semver. Bump it for every release, including a rebuild with a different program: Settings compares it with the installed version to offer Update. - An update replaces the folder as one step (the new version is checked and written aside first; if anything fails the old version stays). It is a new program, so Sevak asks for permission again, with the dialog saying the contents changed.
protocol = 1is the wire protocol; within a version only additive changes happen, so a program built today keeps working.min_sevakis how you say you need a newer Sevak.- The SDK has its own version (
sevak-extension-sdk0.x). A breaking SDK change does not break programs already built: the protocol is the contract.
Publishing the SDK (maintainers)¶
sevak-extension-sdk is written to be published (Apache-2.0, a README, only
serde and serde_json as dependencies, publish is not disabled) but nothing
publishes it automatically. When the maintainers decide to:
- Check the crate on its own:
cargo publish -p sevak-extension-sdk --dry-run, andcargo package -p sevak-extension-sdk --listfor the file list. - Publish it from the repository's release workflow or by hand with a token that is not stored in the repository.
- In
templates/rust-extension/Cargo.tomlreplace thegitdependency withsevak-extension-sdk = "0.1", and do the same indocs/.
Until then the template depends on the Sevak repository (branch = "main"); pin
a rev for builds you want to reproduce.
Troubleshooting¶
| Symptom | Likely cause |
|---|---|
| Settings > Plugins lists it as "Not loaded: ... no build for linux-x86_64" | plugin.toml has no [extension.binaries] entry for this computer's platform |
| "Not loaded: needs Sevak 0.3.0 or newer" | min_sevak is newer than the running Sevak |
| The Allow dialog appears again after every rebuild | by design: the approval is bound to the program's bytes |
| "refuses to start: changed since you allowed it" in the log | the program or manifest changed after the dialog; choose Reload index |
No rows, error in the log |
your function returned Err or panicked; stderr lines are tagged with your id |
| Rows show but Enter does nothing | the action was outside the closed set (the log says which); open_url accepts only http, https and mailto |
sevak-ext validate says "needs an id" |
add id = "script:<name>" to plugin.toml |
| macOS: "killed" when started | an unsigned arm64 binary: build with the default Rust toolchain for aarch64-apple-darwin (it signs ad hoc) |