Files
Toju/agents-docs/features/plugin-system.md
brogeby b19c39208c docs: populate initial cross-context feature docs
Add area-level documentation for the five most significant cross-context
feature areas under agents-docs/features/:

- websocket-envelopes: full envelope catalogue, lifecycle, dispatcher
- ipc-bridge: window.electronAPI surface, IPC channels, CQRS dispatch
- plugin-system: manifest contract, runtime, capabilities, plugin-support API
- server-directory: REST endpoints, CQRS, entities, business rules
- voice-signaling: mesh signaling, RNNoise pipeline, domain split

Update agents-docs/FEATURES.md index alphabetically and remove the
"no cross-context feature docs" placeholder.

Each doc records honest TODOs for verified gaps (stale signaling-contracts.ts,
window.api vs window.electronAPI mismatch, IPC error envelope drift from
CONTEXT.md, missing OpenAPI coverage for server-directory routes, no
envelope round-trip test).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 15:36:36 +02:00

13 KiB

Plugin System

Area: plugin-system Status: Active Last updated: 2026-05-25

Overview

Plugins extend Toju's renderer with bundled or local ES modules that can publish events into a server, register UI contributions (pages, panels, actions, channel sections, embed renderers), store per-user or per-server data, and exchange messages over P2P. They are described by a typed manifest, gated by an explicit capability grant model, and coordinated across three subdomains: the Electron plugin loader (electron/plugin-library.ts), the renderer plugin runtime (toju-app/src/app/domains/plugins/), and the server's plugin-support surface (server/src/routes/plugin-support.ts). This area documents the contract those three sides share.

Responsibilities

  • Define the canonical plugin manifest shape (TojuPluginManifest) and its semantic versioning, dependency, and capability requirements.
  • Discover local plugin manifests from disk in Electron and surface them to the renderer.
  • Load plugin modules — local file://, http(s)://, or bundled — into the renderer and run their lifecycle hooks.
  • Gate every host API call by an explicit capability grant.
  • Persist server-scoped plugin requirements and event definitions on the signaling server, broadcasting changes to connected clients.

This area does not own:

  • The wire format of plugin_event envelopes themselves — those live with websocket-envelopes; this area defines the validation rules applied to them.
  • IPC plumbing for plugin manifest discovery — that's the ipc-bridge surface.
  • Per-plugin business logic — that lives in the plugin's own code.

Key concepts

  • ManifestTojuPluginManifest (toju-app/src/app/shared-kernel/plugin-system.contracts.ts). Required: id, title, description, version, apiVersion, kind, schemaVersion (fixed 1), compatibility.minimumTojuVersion. Optional: entrypoint, bundle, capabilities[], events[], data[], ui, settings, relationships, authors, pluginUser, scope, homepage, changelog, license, readme, load.priority.
  • Capability — a string ID (e.g. messages.send, events.server.publish, ui.pages, storage.local). Plugins declare what they need in capabilities[]; the host enforces grants per plugin.
  • Plugin event — a declared { eventName, direction, scope, schema?, maxPayloadBytes? } tuple. direction is clientToServer | serverRelay | p2pHint; scope is server | channel | user | plugin.
  • Capability grant — an entry in metoyou_plugin_capability_grants (localStorage + desktop file) recording user consent for a (pluginId, capability) pair.
  • Activation contextTojuPluginActivationContext — what a plugin module receives in its activate(context) hook: pluginId, manifest, api (the capability-gated TojuClientPluginApi), and a subscriptions[] cleanup list.
  • Local plugin — a folder under ${app.getPath('userData')}/plugins/<id>/ containing toju-plugin.json (preferred) or plugin.json and resolved relative assets.

Manifest contract

Declared in toju-app/src/app/shared-kernel/plugin-system.contracts.ts (the source of truth on the renderer side; the Electron loader treats the manifest as unknown until the renderer validates it).

Highlights:

  • schemaVersion: 1 — fixed; bump only with a coordinated migration.
  • apiVersion — declares which host API surface the plugin expects.
  • kind — distinguishes plugin types (currently a string union; see file for exact members).
  • bundle.url + optional bundle.entrypointUrl — for remote / store-installed plugins.
  • entrypoint — relative path within the local plugin folder; rejected if it escapes pluginRoot.
  • relationships.{requires,optional,conflicts,before,after}pluginId + optional versionRange. Resolved at activation; missing required dependencies block activation.
  • events[] — registered with the server via plugin-support when the plugin is required by a server. The server uses these definitions to validate inbound plugin_event envelopes.
  • data[]{ key, scope: server|channel|user|plugin, storage: local|serverData, schema? }.
  • load.prioritybootstrap | high | default | low; controls ordering when several plugins are activated together.
  • pluginUser — synthetic user identity for messages a plugin posts on its own behalf.

Discovery & loading

Local discovery (Electron main)

electron/plugin-library.ts:

  • Scans ${app.getPath('userData')}/plugins/ one level deep for plugin folders.
  • For each folder, reads toju-plugin.json (preferred) or plugin.json as raw JSON. No schema validation at this layer.
  • Resolves relative paths (entrypoint, readme) against the plugin root, rejecting .., absolute paths, or anything that escapes the root via isPathInside() realpath check.
  • Returns LocalPluginDiscoveryResult { plugins, errors, pluginsPath } where each plugins[i] is a LocalPluginManifestDescriptor carrying the raw manifest, manifest path, plugin root, plugin-root file:// URL, optional entrypoint and readme paths, and a discoveredAt timestamp.

Exposed to the renderer through the ipc-bridge as listLocalPluginManifests and getLocalPluginsPath.

Renderer runtime

toju-app/src/app/domains/plugins/:

  • PluginHostService — orchestrates lifecycle: discoverLocalPlugins, registerLocalManifest, activate, deactivate, reload, loadPluginModule.
  • PluginClientApiService — constructs the capability-gated facade TojuClientPluginApi per plugin. Subsystems: channels, events, messages, messageBus, p2p, profile, users, roles, server, attachments, media, storage, serverData, clientData, ui, logger, context.
  • PluginCapabilityServicegrant(pluginId, capability), revoke(), grantAll(manifest), assert(pluginId, capability). Storage in metoyou_plugin_capability_grants (localStorage + desktop file).
  • PluginMessageBusService — plugin-scoped pub/sub with topic, optional channel/peer targeting, optional message replay.
  • PluginStorageService — split storage paths for local and serverData scopes.
  • PluginUiRegistryService — central registry of UI contributions consumed by plugin-render-host, plugin-page-host, plugin-action-menu.
  • PluginRequirementStateService, PluginDesktopStateService — state slices.

Module loading

  • Entrypoint URL is file:// for local plugins, http(s):// for remote, or the bundle.url for bundled.
  • Bytes are fetched, wrapped in a Blob-backed object URL, then imported via dynamic import() so devtools and stack traces resolve.
  • GuardedPluginMutationObserver wraps observer callbacks to catch plugin errors and break infinite redispatch loops.

Lifecycle states

From plugin-runtime.models.ts: discovered → validated → blocked | loading → ready → loaded → failed | unloading → unloaded → disabled.

Module contract

export interface TojuClientPluginModule {
  activate?(context: TojuPluginActivationContext): void | Promise<void>;
  deactivate?(context: TojuPluginActivationContext): void | Promise<void>;
  ready?(context: TojuPluginActivationContext): void | Promise<void>;
  onServerRequirementsChanged?(context, snapshot): void | Promise<void>;
  onPluginDataChanged?(context, event): void | Promise<void>;
}

Server surface (server/src/routes/plugin-support.ts)

Each endpoint scoped to :serverId. Permission manageServer enforced for mutations via server-permissions.service.ts.

  • GET /:serverId/pluginsPluginRequirementsSnapshot — full set of required/optional plugins + event definitions for the server.
  • PUT /:serverId/plugins/:pluginId/requirement — upsert a requirement: status: required | optional | recommended | blocked | incompatible, installUrl?, sourceUrl?, manifest?, versionRange?.
  • DELETE /:serverId/plugins/:pluginId/requirement — remove.
  • PUT /:serverId/plugins/:pluginId/events/:eventName — register or update an event definition (direction, scope, schema?, maxPayloadBytes?).
  • DELETE /:serverId/plugins/:pluginId/events/:eventName — delete.
  • GET|PUT|DELETE /:serverId/plugins/:pluginId/data/:keydisabled on the signaling server (returns HTTP 410). Plugin data on the server is intentionally out of scope.

Identifier patterns:

  • pluginId: /^[a-z0-9][a-z0-9.-]{1,126}[a-z0-9]$/
  • eventName: /^[a-z][a-z0-9.:-]{1,126}[a-z0-9]$/

Changes broadcast to connected clients via the websocket-envelopes — the matching PluginRequirementsSnapshot is also delivered as part of the join_server and view_server responses.


Plugin event validation

server/src/services/plugin-support.service.ts exposes validatePluginEventEnvelope(). The plugin_event envelope handler (server/src/websocket/handler.ts) calls it before broadcasting. Validation checks:

  • eventName is registered for pluginId on serverId.
  • direction permits the source (clientToServer vs p2pHint vs serverRelay).
  • payload size ≤ maxPayloadBytes (default 64 KB).
  • If schema was declared in the manifest, the payload conforms — TODO: confirm the schema dialect (looks like JSON Schema subset).

Security considerations

  • Plugins run in the renderer's JS context. There is no true sandbox: plugins have DOM access, can read/write localStorage, and can issue fetch requests subject to the renderer's CSP.
  • Capability model is the primary security boundary. Every method on TojuClientPluginApi calls PluginCapabilityService.assert(pluginId, capability). Missing grant → PluginCapabilityError.
  • Path traversal in the local plugin loader is blocked by isPathInside() realpath checks (electron/plugin-library.ts).
  • Payload bounds on plugin events: default 64 KB, configurable per event definition.
  • No code signing or integrity verification. Plugins are trusted to the extent the user granted capabilities. The plugin store flow is documented in docs-site/docs/plugin-development/.
  • TODO: review whether bundle.url fetches go through a CSP / allow-list or are unbounded.

Configuration

  • Local plugins path: ${app.getPath('userData')}/plugins/ (Electron). Exposed as getLocalPluginsPath.
  • Capability grants: metoyou_plugin_capability_grants in localStorage; mirrored to a desktop file via PluginDesktopStateService.
  • Server-side persistence: plugin requirements and event definitions are stored in the server's database; entities and migrations live alongside other server entities (TODO: enumerate the specific entities).

Testing

  • Electron: electron/plugin-library.spec.ts — covers valid/invalid JSON, path traversal rejection, escaping entrypoints. Uses fixture TEST_PLUGIN_FIXTURE_DIR.
  • Renderer: plugin-host.service.spec.ts, plugin-store.service.spec.ts, local-plugin-discovery.service.spec.ts.
  • Server: server/src/websocket/handler-plugin.spec.tsplugin_event validation flow.
  • E2E: e2e/tests/.../plugin-support-api.spec.ts, plugin-manager-ui.spec.ts, plugin-api-two-users.spec.ts.

Known issues and limitations

  • Server-side plugin data is intentionally disabled (410 Gone on */data/:key routes). Plugins must currently treat serverData storage as not yet implemented on the signaling server. TODO: clarify whether this is a permanent boundary or scheduled work.
  • No true sandbox for plugin execution. The capability model is the only restraint between a plugin and the renderer's globals.
  • Manifest validation is renderer-side. The Electron loader treats manifests as raw JSON; malformed or hostile manifests are caught when the renderer registers them.
  • No host-side plugin allow-list beyond per-server requirements — a user can install any local plugin.
  • ipc-bridge — surfaces listLocalPluginManifests, getLocalPluginsPath, and the plugin CQRS commands (SavePluginData, DeletePluginData, GetPluginData).
  • websocket-envelopes — defines the plugin_event envelope this area validates.
  • server-directoryjoin_server / view_server responses include PluginRequirementsSnapshot for the joined server.

Documentation for plugin authors

Author-facing docs ship with the desktop app via Docusaurus:

  • docs-site/docs/plugin-development/create-a-plugin.md
  • docs-site/docs/user-guide/plugins.md
  • docs-site/docs/developer/llm-plugin-builder-guide.md

Changelog

Date Change
2026-05-25 Initial documentation