The extension runner
sheru-extension-runner is the host's single signed executable for loading
extensions. An extension in SHERU ships no binary of its own — it is data:
a manifest.json plus a JS/TS entry module. The host spawns one runner per live
node instance, hands it the extension's directory, and speaks a small stdio
sub-protocol with it. Because there is exactly one runner, there is exactly one
thing the release pipeline code-signs, regardless of how many extensions are
installed.
This page documents the runner boundary, the stdio sub-protocol (version 2),
the @sheru-app/extension-sdk author surface, and the single-node manifest.json.
The broader extension model these live inside — the multi-contribution
contributes bag, the declarative menus/openers/launchers/sidebarItems
registry, and dynamic discovery of arbitrary extensions — is described in
extensions — concepts.
One signed binary, extensions as data#
Conceptually, an extension is a directory under ~/.sheru/connectors/<id>/
containing a manifest.json and an entry module. There is no per-extension
executable and therefore no per-extension code signing. The host spawns
sheru-extension-runner with a scrubbed environment and a single argument:
sheru-extension-runner --extension-dir <manifest dir>The runner reads the manifest (the single source of truth for the id and the entry path), imports the entry as a module, validates its default export, and hands it to the SDK's stdio loop. The id always comes from the manifest, never from the extension code.
The load is literally await import(entry) — the extension is treated as data,
not invoked as a process. A bad --extension-dir, an unreadable manifest, a
missing entry, or a default export that carries neither a node nor the legacy
connector all fail with exit code 2 and a message on stderr.
Release vs dev#
The runtime.entry field selects the load path:
- Release —
entryis a relative path ("extension.js"). At provision time the extension is bundled into one self-contained file that inlines the SDK, so the packaged app needs nonode_modules. The bundle is plain JS, not an executable, so it needs no signing. - Dev —
entryis an absolute path to the TypeScript source (index.ts), transpiled on import.
The runner itself is the single standalone binary the release pipeline signs.
The manifest the runner reads#
The runner only needs id and runtime.entry, but the host validates the full
manifest at registration time before any runner is spawned. A malformed manifest
is logged and skipped — never fatal.
A service-projection manifest has this shape (release form, with
entry: "extension.js"):
{
"id": "github",
"name": "GitHub",
"class": "service",
"icon": "github",
"runtime": { "type": "sheru-ts", "entry": "extension.js" }, // loaded by the runner
"execTools": ["gh"]
}Key fields, as validated today:
id— pattern^[a-z0-9-]+$, 1–64 chars, and must equal the directory name under the extensions dir.class—"service"(a read-only projection; the legacy spelling"connector"is still accepted) or"fs-provider"(a faithful filesystem). Anything else is rejected.runtime—{ type: "sheru-ts", entry }.entryis what the runner imports.services— an fs-provider declares its per-service block here (itsfswrite/delete/paste bits, aterminalcommand template, aprocblock, devicenodes.enumerate); the core clamps it to the class ceiling. A service projection omits it — its profile is the fixed read-only projection, and a manifest asserting a write bit on a service class is rejected at registration.execTools— the allow-list of binarieshost.execmay run.secretsis the allow-list of keyshost.secretmay fetch.allowHttp(defaultfalse) gateshost.http. These are the host-SPI allow-lists enforced by the host, before any callback reaches the OS.schemaVersion— defaults to the current schema version (2); the core requires an exact match, so a stale version is rejected at registration.
The single-node manifest declares one node. The broader contributes-bag
manifest (one extension declaring nodes plus commands plus menus) is
described in extensions — concepts.
The stdio sub-protocol#
Once loaded, the extension speaks a sub-protocol over the runner's stdio, driven
entirely by the SDK's runExtension(id, def). Authors never call runExtension
themselves — the runner does. The wire format is line-delimited JSON frames,
multiplexed by request id, over a single shared pipe. A request always carries
a method; a reply always carries an ok; an upward notification carries
kind: "event"; the three are mutually exclusive. The sub-protocol major is
exact-match:
/** Sub-protocol major this SDK speaks. The v1→v2 verb rename was a clean break,
* so both sides require an EXACT match — a version skew fails at the
* node.describe handshake, never at a stray unknown verb. */
export const EXTENSION_PROTOCOL = 2 as const;The frame envelopes are a fixed contract shared by both sides; any change to the frame shapes, method table, or callback set must change both sides in the same commit.
export interface RequestFrame {
v?: number;
id: number;
method: string;
params: Record<string, unknown>;
}
export type ReplyFrame =
| { id: number; ok: true; result: unknown }
| { id: number; ok: false; error: string };
/** An UPWARD fire-and-forget notification (no id, no reply): the extension's
* device set changed, re-enumerate. Written by ctx.emit. */
export interface EventFrame {
kind: "event";
name: "nodes.changed";
}The runner's stderr is not part of the protocol — it is a free-form log
drain the host forwards to diagnostics. console.error(...) is fine.
Downward: requests the host sends the extension#
The host sends service-namespaced downward methods on the runner's stdin. The
SDK dispatches each to the author's handler groups; only fs.list and fs.read
are near-universal, and the rest are declared per manifest and sent only when the
served bits allow them:
| method | handler group | required? |
|---|---|---|
node.describe |
synthesized (optional init hook runs first) |
no |
fs.list |
fs.list |
yes |
fs.read |
fs.read |
yes |
fs.mkdir / createFile / rename / duplicate / move / copy / delete / upload / download |
fs.* |
fs-provider (declared) |
commands.list |
commands.list (defaults to no commands) |
no |
commands.invoke |
commands.invoke |
no |
terminal.describe |
terminal.describe |
fs-provider (dynamic terminal) |
proc.list / proc.signal |
proc.* |
fs-provider (declared) |
clipboard.read / clipboard.write |
clipboard.* |
fs-provider (declared) |
nodes.enumerate |
nodes.enumerate |
fs-provider (device enumeration) |
node.describe is fully synthesized by runExtension — never authored. The
id comes from the manifest and the protocol is this SDK's version; what the node
serves comes from the validated manifest, so there is no capability block on the
wire. A successful describe reply is also the readiness signal; there is no
separate "ready" frame. The optional init handler runs at describe time to
capture the node's backend params (e.g. a repo allow-list). The node definition:
export interface NodeDefinition {
init?(params: InitParams, ctx: ExtensionContext): Promise<void> | void;
fs?: FsHandlers; // list + read required; mutation/transfer verbs optional
commands?: CommandsHandlers;
terminal?: TerminalHandlers;
proc?: ProcHandlers;
clipboard?: ClipboardHandlers;
nodes?: NodesHandlers; // device enumeration
}fs.read returns a tagged union so binary content is safe across the JSON wire
boundary:
export type ReadResult =
| { encoding: "utf8"; data: string; truncated: boolean }
| { encoding: "base64"; data: string; truncated: boolean };If a handler is omitted, the SDK supplies a default — commands.list falls back
to an empty list, commands.invoke to an error, and any verb whose group is not
served answers with a typed error. Errors thrown by a handler become an
{ ok:false, error } reply for that request id — they never crash the loop. EOF
on stdin is the teardown contract: the host closes the runner's stdin, the loop
resolves, and a well-behaved runner exits.
For an fs-provider, the core issues concurrent in-flight downward calls (a
large fs.upload/fs.download must not head-of-line block a fs.list); replies
are demuxed by id, so out-of-order completion is expected.
Upward: the host-gated SPI#
When a handler needs to reach a host resource, it issues an upward callback — the only sanctioned door out of the extension. The runner sends an upward frame on stdout and awaits the reply by id. There are three upward methods, each gated by the manifest — and they are deliberately the same strings as the core's own host-SPI methods, because the callback is a host call the core forwards after the allow-list check:
| upward method | gated by | purpose |
|---|---|---|
host.exec |
execTools |
run a host-resolved binary (no shell, no PATH lookup by the child) |
host.http |
allowHttp |
a request from the host's network stack |
host.secret |
secrets |
fetch a manifest-declared secret |
The host enforces each allow-list before the callback reaches the OS — e.g.
it rejects any bin not in execTools. Handlers see these as the
ExtensionContext (ctx), which also provides byte-ergonomic wrappers that
handle the base64 on the wire for you, plus emit for the device-set
notification:
export interface ExtensionContext {
exec(params: ExecParams): Promise<ExecResult>;
execBytes(
bin: string,
args: string[],
opts?: { stdin?: Uint8Array | string },
): Promise<{ code: number; stdout: Uint8Array; stderr: Uint8Array }>;
http(params: HttpParams): Promise<HttpResult>;
httpBytes(
method: string,
url: string,
opts?: { headers?: Record<string, string>; body?: Uint8Array | string },
): Promise<{ status: number; body: Uint8Array }>;
secret(key: string): Promise<string>;
/** Fire-and-forget: the device set changed, re-enumerate (fs-provider only). */
emit(name: "nodes.changed"): void;
}The reference GitHub extension reaches GitHub only through ctx.execBytes("gh", …)
(which is why its manifest declares execTools: ["gh"]):
export function makeGh(ctx: ExtensionContext): Gh {
return async (args: string[]) => {
const r = await ctx.execBytes("gh", args);
const stdout = new TextDecoder().decode(r.stdout);
if (r.code !== 0) { /* throw with decoded stderr */ }
return JSON.parse(stdout);
};
}A complete extension entry#
The author writes a manifest (above) and one entry module whose default export is
defineExtension(...). Note the id is not in the definition — it lives once
in the manifest, and the runner supplies it. Here is the GitHub reference
extension:
import { defineExtension } from "@sheru-app/extension-sdk";
import { commands, invokeCommand, listPage, read } from "./github";
// Per-node repo allow-list, captured from the describe handshake params.
let repos: string[] = [];
export default defineExtension({
node: {
init(params) {
const list = (params.params as { repos?: unknown }).repos;
repos = Array.isArray(list) ? list.filter((x): x is string => typeof x === "string") : [];
},
fs: {
list: (params, ctx) => listPage(params, repos, ctx),
read: (params, ctx) => read(params.path, params.maxBytes, ctx),
},
commands: {
list: (params) => ({ commands: commands(params.path) }),
invoke: (params, ctx) => invokeCommand(params.command, params.path, params.args, ctx),
},
},
});defineExtension is pure identity — its only job is to type-check the
definition:
export function defineExtension(def: ExtensionDefinition): ExtensionDefinition {
return def;
}Trust tiers#
Privilege follows the contribution point, not the extension. SHERU defines three trust tiers (the L2 isolation layer in the architecture map):
- 2a — trusted-builtin, in-core. The rclone
fs-engineprovider, the built-in local node, and the federation client. First-party, no process boundary at all. - 2b — out-of-process sidecar. The extension-shipped classes — service
projection (GitHub, Notion, Linear) and fs-provider (adb) — and any
future credentialed logic extension. The runner is spawned with a scrubbed
env; the manifest
execTools/secrets/allowHttpallow-lists are enforced host-side; a service projection is forced read-only regardless of what the child claims, and an fs-provider's declared services are clamped to its class ceiling; dropping the node kills the child. This is the tier the extension runner serves. - 2c — in-process / declarative. Themes (pure-data style
sinks) and view/command handlers with no standalone credentials. They reach the
OS only through the typed protocol and receive a capability-narrowed
ExtensionContext.
The runner is not a sandbox#
This is the load-bearing caveat. The runner loads arbitrary code via
await import(entry), so a local extension keeps full Bun/Node access — like
any CLI you install. The exec/http/secret callbacks are the sanctioned
interface and review surface, and on the cloud server they are the only
serviced channel — but they are not an enforced boundary on the local host.
The consequence: installed extensions are trusted — like any CLI tool you install, an installed extension runs with host access, so you install only first-party or vetted extensions. Running genuinely untrusted third-party logic would require an actual sandbox, which the runner is not.
The class ceiling: read-only projections, clamped fs-providers#
Every provider class has a fixed capability ceiling the core enforces, so no single bug can let a manifest widen its class.
A service projection is read-only forever, enforced independently at three layers:
- JSON schema — for a
service(or legacyconnector) class, the manifest schema pins thefswrite bits (canWrite/canPaste/canDelete) tofalse. - Host validation — the host rejects a service manifest that asserts any write bit (logged and skipped at registration).
- Routing — the core refuses to route any mutation verb to a projection-flavored fs, regardless of what the child answers.
A service projection mutates only through host-mediated commands.invoke
callbacks (which return { ok, message, changedPath } so views can re-list),
never as a filesystem write, and is never an upload target.
An fs-provider may serve real writes, but only up to the fs-provider ceiling:
its declared services are clamped by the core (a declaration can lower a
bit, never raise it), it can never declare nativePaths, and every mutation
still runs through a host-gated tool (e.g. adb) — no privilege beyond its
granted execTools.
See also#
- Extensions — concepts — the full extension model (nodes,
provider classes, the
contributesbag, contribution points). - Themes — the orthogonal presentation axis (pure data, never code).