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:

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:

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):

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:

  1. JSON schema — for a service (or legacy connector) class, the manifest schema pins the fs write bits (canWrite / canPaste / canDelete) to false.
  2. Host validation — the host rejects a service manifest that asserts any write bit (logged and skipped at registration).
  3. 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