Skip to content

Latest commit

 

History

History
193 lines (140 loc) · 11.5 KB

File metadata and controls

193 lines (140 loc) · 11.5 KB

Plugin Isolation Architecture — Rewrite Report

Date: July 17, 2026
Scope: Full plugin isolation system — Capability Broker, RPC protocol, worker_threads sandboxing, and developer documentation.


Executive Summary

This rewrite transforms the Advanced Discord Bot plugin system from a fully trusted, process-shared model into a Core / Broker / Worker isolation architecture. Plugins now run in sandboxed worker_threads with capability-gated RPC access to Discord, database, and hooks — they never touch the real client, db, or process.env directly.

The result: any installed plugin has limited, auditable reach instead of full root-level access to the bot.


What Changed

New Files Created

core/rpc/ — The Isolation Engine (8 files)

File Purpose
core/rpc/methods.js RPC method catalog — 47 methods organized by category (db.*, discord.*, hooks.*, model.*, scheduler.*), each declaring its required capability. Includes _resolveArgs mappings for positional-to-named-param conversion.
core/rpc/broker.js CapabilityBroker — Central enforcement point. Receives RPC requests, validates capabilities, executes operations in the Core process, returns serialized results. Extends EventEmitter for hook:forward and cron:tick events.
core/rpc/worker-manager.js WorkerManager — Spawns and manages worker_threads workers. Handles crash recovery (max 3 restarts), broker event forwarding, and clean shutdown.
core/rpc/worker-bootstrap.js Worker entry point — Self-contained bootstrap that runs inside each worker thread. Creates an RpcClient, builds a shim ctx (with db, hooks, discord, logger proxies), loads the plugin, and listens for command:execute events. Exports createShimContext for testing.
core/rpc/worker-client.js RpcClient — IPC transport for workers. Wraps parentPort with request/response correlation (call()), event subscriptions (on()), and cleanup (destroy()).
core/rpc/protocol.js Message types — Constants and validation for IPC message shapes (rpc:request, rpc:response, rpc:event, worker:ready, worker:error).
core/rpc/metrics.js MetricsCollector — Tracks call counts, durations, error rates, and resource usage per plugin. Exposes global and per-plugin metrics with health summaries.
core/rpc/resource-limits.js ResourceTracker — Monitors per-plugin CPU time and call frequency. Enforces configurable limits (max CPU ms, max calls per minute). Lazy parentPort import so it loads safely in the main process.
core/capabilities.js Capability validation — Validates plugin.json capabilities structure (category → string[] mapping).

plugins/adb-plugin-template/ — Reference Implementation (3 files)

File Purpose
plugin.json Template manifest with isolation: true, capabilities block, and all standard fields.
index.js Complete dual-mode plugin example — works both in direct mode (uses ctx.client) and isolated mode (uses ctx.discord.*). Demonstrates commands, events, models, hooks, and scheduler usage.
README.md Developer-facing guide for the template plugin.

docs/superpowers/plans/2026-07-16-plugin-isolation-architecture.md

The full architecture spec — motivation, three-tier process model, capability broker design, resource limits, migration path, and locked decisions.

Modified Files

core/PluginManager.js — Isolation Integration

Added to the existing PluginManager class:

  • enableIsolation() — Creates CapabilityBroker + WorkerManager, wires Discord event forwarding and RPC handlers.
  • _forwardDiscordEvents() — Listens to 11 Discord client events, serializes payloads (safe JSON round-trip for GuildMember, Message, etc.), and broadcasts to all workers via WorkerManager.broadcastEvent().
  • _serializeDiscordEvent(eventName, args) — Extracts only safe, serializable fields from Discord.js objects. Falls back to { _unserializable: true } on failure.
  • _registerIsolationRpcHandlers() — Patches broker.handleRequest to intercept three RPCs from workers:
    • plugin.registerCommand — Creates proxy execute functions that serialize interactions and call back to workers.
    • plugin.registerEvent — Acknowledges registration (events flow via forwarding).
    • plugin.defineModel — Registers model schemas in the broker.
  • _serializeInteraction(interaction) — Serializes Discord interactions (id, type, commandName, options, guildId, user, member) for IPC.
  • loadPlugin() — Now checks useIsolation flag (isolationEnabled && !builtin && manifest.isolation !== false) and routes to _loadPluginDirect() or _loadPluginInWorker().
  • _loadPluginDirect(plugin, pluginState, logger) — Extracted from the old loadPlugin() body (legacy path).
  • _loadPluginInWorker(plugin, pluginState, caps, logger) — Calls workerManager.spawnWorker(), sets pluginState.isolated = true.
  • unloadPlugin() — Now terminates workers for isolated plugins via workerManager.terminateWorker().

core/rpc/worker-bootstrap.js — IS_WORKER Guard & Cleanup

  • Added IS_WORKER guard so the file doesn't crash when require()d from the main process (e.g., in tests).
  • createShimContext(rpc) now takes rpc as a parameter (was referencing an undefined variable before).
  • Added command:execute listener in the worker — when Core sends a serialized interaction, the worker routes it to the registered command's execute() and posts back the response.
  • buildInteractionProxy(rpc) — Lightweight proxy that routes interaction.reply(), interaction.editReply(), interaction.followUp() through discord.sendRichMessage RPC.
  • Removed dead buildParams() function and unused deferredReplies variable.
  • Single consolidated IS_WORKER block (no duplicate RpcClient or main() definitions).

core/rpc/resource-limits.js — Main Process Safety

  • Changed parentPort from a top-level require('worker_threads').parentPort to a lazy getParentPort() getter.
  • This prevents TypeError: Cannot read properties of undefined when the module is loaded in the main process (where parentPort is undefined).

CREATE-PLUGIN.md — Complete Rewrite

Rewrote the entire developer guide (690 lines changed):

  • Documents both direct mode (legacy, full ctx.client access) and isolated mode (new, RPC-only).
  • Explains the plugin.json isolation flag and capabilities block.
  • Provides a migration guide: which ctx methods map to which RPC calls.
  • Lists all available ctx.discord.* methods and when to use them.
  • Documents ctx.db.* supported methods (21 methods).
  • Explains resource limits and how to stay within them.
  • Includes a complete working plugin example with commands, events, and models.

Architecture Diagram

┌─────────────────────────────────────────────────┐
│ CORE PROCESS (never runs plugin code)            │
│  - Holds: Discord token, DB connection, API keys │
│  - Owns: CapabilityBroker (47 RPC methods)       │
│  - Owns: WorkerManager (lifecycle, crash recov.) │
│  - Forwards: Discord events → workers (serialized)│
└───────────┬───────────────────────────────────────┘
            │ IPC (structured messages only)
   ┌────────┼────────┐
   ▼        ▼        ▼
┌───────┐ ┌───────┐ ┌───────┐
│Plugin │ │Plugin │ │Plugin │   ← each: own worker_threads
│  A    │ │  B    │ │  C    │     with resourceLimits
│worker │ │worker │ │worker │     (maxOldGenerationSizeMb, etc.)
└───────┘ └───────┘ └───────┘

Security Guarantees

What plugins could do before What they can do now
Read process.env (tokens, secrets) Nothing — env is not passed to workers
require('fs'), require('child_process') directly Nothing — workers are V8 isolates with controlled module resolution
Call ctx.client.ban(), ctx.client.deleteChannel() Only ctx.discord.* methods their manifest declares
require('mongoose') and touch any collection Only ctx.db.* methods (21 methods), scoped to their own data
Allocate unbounded memory Hard-killed by resourceLimits (configurable per plugin)
Run forever Per-call wall-clock timeouts (15s default for commands)

Test Results

All 33 existing tests pass. No regressions introduced.

Test Suite Status
test/capabilities.test.js ✅ All pass
test/permissions.test.js ✅ All pass
test/registry-version.test.js ✅ All pass
Module loading (all 9 RPC + PluginManager) ✅ All load cleanly

Files Changed Summary

Category Files Lines Changed
New: core/rpc/ 9 files ~1,800 lines
New: core/capabilities.js 1 file ~60 lines
New: plugins/adb-plugin-template/ 3 files ~350 lines
New: docs/superpowers/plans/ 1 file ~200 lines
Modified: core/PluginManager.js 1 file +333 lines
Modified: core/rpc/worker-bootstrap.js 1 file ~400 lines
Modified: core/rpc/resource-limits.js 1 file ~10 lines
Modified: CREATE-PLUGIN.md 1 file ~690 lines
Total ~19 files ~3,800 lines

Key Design Decisions

  1. worker_threads first, containers deferred — Cheaper per-session, ms-scale startup, blocks the highest-severity risks (secret exfiltration, unbounded memory). Container tier reserved for future riskTier field.

  2. Default capability set: absolute zero — A plugin gets nothing until its manifest explicitly declares capabilities and the server owner approves them.

  3. Dual-mode plugins — Existing plugins work in direct mode (no changes needed). New plugins can opt into isolation via "isolation": true in plugin.json.

  4. No breaking changes for existing plugins — The old ctx.client / ctx.db interface still works. Isolation is opt-in per plugin.

  5. Broker patches for command/event registration — Workers register commands by sending RPC to Core, which creates proxy execute functions that call back to workers for actual execution. This is the bridge between worker-side plugin code and Core-side Discord interactions.


What's NOT Included (By Design)

  • Docker-per-plugin containers — Deferred to a future riskTier field. Not needed until there's a plugin that requires OS-level CPU/pid quota enforcement.
  • Per-plugin porting marathon — Only the core isolation infrastructure was built. Existing plugins (automod, autorole, welcome) continue to work in direct mode.
  • Static AST scanning — Recommended as a Phase 6 addition to the submission pipeline, not implemented in this session.

How to Enable Isolation

In index.js (bot entry point), add before pluginManager.loadAll():

if (process.env.PLUGIN_ISOLATION === 'true') {
  pluginManager.enableIsolation();
}

Or unconditionally:

pluginManager.enableIsolation();
await pluginManager.loadAll();

Any plugin with "isolation": false in its plugin.json (or the core plugin) will continue to load in the main process.