Skip to main content

Plugin System

ClawDesk's plugin system enables third-party extensibility through a sandboxed, capability-based architecture. The clawdesk-plugin crate manages the full plugin lifecycle from discovery to deactivation.

Module Structure

ModuleTypeDescription
hostPluginHostManages plugin lifecycle and inter-plugin communication
resolverDependencyResolverTopological dependency resolution with cycle detection
sandboxPluginSandboxIsolated execution environment with resource limits
registryPluginRegistryPlugin catalog with version management

Plugin Manifest

Every plugin declares its metadata, dependencies, and required capabilities in a manifest:

/// Plugin manifest — deserialized from plugin.toml or plugin.json
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginManifest {
/// Unique plugin identifier
pub id: PluginId,

/// Semantic version
pub version: Version,

/// Human-readable name
pub name: String,

/// Plugin author
pub author: String,

/// Description
pub description: String,

/// Required capabilities (permissions)
pub capabilities: Vec<Capability>,

/// Plugin dependencies
pub dependencies: Vec<PluginDependency>,

/// Entry point
pub entry: EntryPoint,

/// Resource limits
pub limits: ResourceLimits,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginDependency {
pub id: PluginId,
pub version_req: VersionReq,
pub optional: bool,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum Capability {
/// Read conversation messages
MessagesRead,

/// Send messages on behalf of the bot
MessagesSend,

/// Access session data
SessionAccess,

/// Make HTTP requests to external services
NetworkAccess { allowed_hosts: Vec<String> },

/// Read/write plugin-scoped storage
Storage { max_bytes: usize },

/// Register custom tools
ToolRegistration,

/// Register custom commands
CommandRegistration,

/// Access file system (within sandbox)
FileSystem { paths: Vec<String>, read_only: bool },

/// Execute scheduled tasks
CronAccess,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResourceLimits {
/// Maximum memory usage (bytes)
pub max_memory: usize, // default: 64 MB

/// Maximum CPU time per invocation (ms)
pub max_cpu_time_ms: u64, // default: 5000

/// Maximum concurrent tasks
pub max_tasks: usize, // default: 4

/// Maximum storage (bytes)
pub max_storage: usize, // default: 16 MB

/// Maximum network requests per minute
pub max_network_rpm: u32, // default: 60
}

Plugin Lifecycle FSM

The plugin lifecycle is governed by a finite state machine with 7 states:

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PluginState {
Discovered,
Loading,
Resolving,
Resolved,
Activating,
Active,
Deactivating,
Inactive,
Failed,
}

impl PluginHost {
/// Transition a plugin through its lifecycle.
fn transition(
&mut self,
plugin_id: &PluginId,
event: PluginEvent,
) -> Result<PluginState, PluginError> {
let current = self.get_state(plugin_id)?;

let next = match (current, event) {
(PluginState::Discovered, PluginEvent::Load) => PluginState::Loading,
(PluginState::Loading, PluginEvent::DepsFound) => PluginState::Resolving,
(PluginState::Loading, PluginEvent::Error(_)) => PluginState::Failed,
(PluginState::Resolving, PluginEvent::DepsResolved) => PluginState::Resolved,
(PluginState::Resolving, PluginEvent::Error(_)) => PluginState::Failed,
(PluginState::Resolved, PluginEvent::Activate) => PluginState::Activating,
(PluginState::Activating, PluginEvent::InitSuccess) => PluginState::Active,
(PluginState::Activating, PluginEvent::Error(_)) => PluginState::Failed,
(PluginState::Active, PluginEvent::Deactivate) => PluginState::Deactivating,
(PluginState::Active, PluginEvent::Error(_)) => PluginState::Failed,
(PluginState::Deactivating, PluginEvent::CleanupDone) => PluginState::Inactive,
(PluginState::Inactive, PluginEvent::Load) => PluginState::Loading,
(PluginState::Failed, PluginEvent::Load) => PluginState::Loading,
(state, event) => {
return Err(PluginError::InvalidTransition { state, event });
}
};

self.set_state(plugin_id, next);
self.audit_transition(plugin_id, current, next);

Ok(next)
}
}

Dependency Resolution

The dependency resolver uses topological sorting to determine the correct activation order:

// crates/clawdesk-plugin/src/resolver.rs

pub struct DependencyResolver {
plugins: HashMap<PluginId, PluginManifest>,
}

impl DependencyResolver {
/// Resolve all dependencies and return activation order.
/// Returns an error if there are cycles or unresolvable dependencies.
pub fn resolve(&self) -> Result<Vec<PluginId>, ResolverError> {
let mut in_degree: HashMap<&PluginId, usize> = HashMap::new();
let mut adjacency: HashMap<&PluginId, Vec<&PluginId>> = HashMap::new();

// Build the dependency graph
for (id, manifest) in &self.plugins {
in_degree.entry(id).or_insert(0);

for dep in &manifest.dependencies {
// Verify the dependency exists
if !self.plugins.contains_key(&dep.id) {
if dep.optional {
continue; // Skip optional missing deps
}
return Err(ResolverError::MissingDependency {
plugin: id.clone(),
dependency: dep.id.clone(),
required_version: dep.version_req.clone(),
});
}

// Verify version compatibility
let dep_manifest = &self.plugins[&dep.id];
if !dep.version_req.matches(&dep_manifest.version) {
return Err(ResolverError::VersionMismatch {
plugin: id.clone(),
dependency: dep.id.clone(),
required: dep.version_req.clone(),
available: dep_manifest.version.clone(),
});
}

adjacency.entry(&dep.id).or_default().push(id);
*in_degree.entry(id).or_insert(0) += 1;
}
}

// Kahn's algorithm for topological sort
let mut queue: VecDeque<&PluginId> = in_degree.iter()
.filter(|(_, &deg)| deg == 0)
.map(|(id, _)| *id)
.collect();

let mut order = Vec::new();

while let Some(node) = queue.pop_front() {
order.push(node.clone());

if let Some(dependents) = adjacency.get(node) {
for dependent in dependents {
let deg = in_degree.get_mut(dependent).unwrap();
*deg -= 1;
if *deg == 0 {
queue.push_back(dependent);
}
}
}
}

// Check for cycles
if order.len() != self.plugins.len() {
let cycle_members: Vec<_> = self.plugins.keys()
.filter(|id| !order.contains(id))
.cloned()
.collect();
return Err(ResolverError::CyclicDependency { plugins: cycle_members });
}

Ok(order)
}
}

Resolution Complexity

$$ T_{\text{resolve}} = O(V + E) $$

Where $V$ is the number of plugins and $E$ is the total number of dependency edges. Kahn's algorithm processes each node and edge exactly once.

Cycle Detection

The resolver rejects any plugin set that contains a dependency cycle. This is detected by Kahn's algorithm — if the topological sort doesn't include all nodes, the remaining nodes form a cycle.

Sandbox Environment

Each plugin runs in an isolated sandbox with enforced resource limits:

// crates/clawdesk-plugin/src/sandbox.rs

pub struct PluginSandbox {
/// Plugin identity
plugin_id: PluginId,

/// Granted capabilities
capabilities: Vec<Capability>,

/// Resource usage tracking
resource_tracker: ResourceTracker,

/// Scoped storage (isolated from other plugins)
storage: ScopedStorage,

/// Network filter (if NetworkAccess capability granted)
network_filter: Option<NetworkFilter>,
}

impl PluginSandbox {
/// Check if the plugin has a specific capability.
pub fn has_capability(&self, cap: &Capability) -> bool {
self.capabilities.iter().any(|c| c.covers(cap))
}

/// Execute a plugin function within the sandbox.
pub async fn execute<F, T>(
&self,
name: &str,
f: F,
) -> Result<T, PluginError>
where
F: Future<Output = Result<T, PluginError>> + Send + 'static,
T: Send + 'static,
{
// Check resource limits
if self.resource_tracker.memory_used() > self.limits().max_memory {
return Err(PluginError::ResourceExceeded {
plugin: self.plugin_id.clone(),
resource: "memory".into(),
limit: self.limits().max_memory,
used: self.resource_tracker.memory_used(),
});
}

// Execute with timeout
let timeout = Duration::from_millis(self.limits().max_cpu_time_ms);
match tokio::time::timeout(timeout, f).await {
Ok(result) => result,
Err(_) => Err(PluginError::Timeout {
plugin: self.plugin_id.clone(),
operation: name.into(),
timeout,
}),
}
}
}

Resource Tracking

pub struct ResourceTracker {
memory_used: AtomicUsize,
cpu_time_used: AtomicU64,
tasks_active: AtomicUsize,
storage_used: AtomicUsize,
network_requests: AtomicU32,
last_reset: Instant,
}

impl ResourceTracker {
pub fn check_limits(&self, limits: &ResourceLimits) -> Result<(), ResourceViolation> {
if self.memory_used.load(Ordering::Relaxed) > limits.max_memory {
return Err(ResourceViolation::Memory);
}
if self.tasks_active.load(Ordering::Relaxed) > limits.max_tasks {
return Err(ResourceViolation::Tasks);
}
if self.storage_used.load(Ordering::Relaxed) > limits.max_storage {
return Err(ResourceViolation::Storage);
}
if self.network_requests.load(Ordering::Relaxed) > limits.max_network_rpm {
return Err(ResourceViolation::Network);
}
Ok(())
}
}

Plugin Registry

The registry manages plugin discovery, versioning, and metadata:

// crates/clawdesk-plugin/src/registry.rs

pub struct PluginRegistry {
/// Installed plugins indexed by ID
plugins: HashMap<PluginId, InstalledPlugin>,

/// Plugin search paths
search_paths: Vec<PathBuf>,

/// Plugin state machine states
states: HashMap<PluginId, PluginState>,
}

#[derive(Debug)]
pub struct InstalledPlugin {
pub manifest: PluginManifest,
pub install_path: PathBuf,
pub installed_at: DateTime<Utc>,
pub checksum: [u8; 32],
}

impl PluginRegistry {
/// Discover plugins from configured search paths.
pub async fn discover(&mut self) -> Result<Vec<PluginId>, PluginError> {
let mut discovered = Vec::new();

for path in &self.search_paths {
let entries = tokio::fs::read_dir(path).await?;
let mut entries = entries;

while let Some(entry) = entries.next_entry().await? {
if entry.file_type().await?.is_dir() {
let manifest_path = entry.path().join("plugin.toml");
if manifest_path.exists() {
match self.load_manifest(&manifest_path).await {
Ok(manifest) => {
let id = manifest.id.clone();
self.plugins.insert(id.clone(), InstalledPlugin {
manifest,
install_path: entry.path(),
installed_at: Utc::now(),
checksum: self.compute_checksum(&entry.path()).await?,
});
self.states.insert(id.clone(), PluginState::Discovered);
discovered.push(id);
}
Err(e) => {
tracing::warn!(
path = %manifest_path.display(),
error = %e,
"skipping invalid plugin manifest"
);
}
}
}
}
}
}

tracing::info!(count = discovered.len(), "plugins discovered");
Ok(discovered)
}
}

Plugin Host Lifecycle

The PluginHost orchestrates the full lifecycle for all plugins:

// crates/clawdesk-plugin/src/host.rs

pub struct PluginHost {
registry: PluginRegistry,
resolver: DependencyResolver,
sandboxes: HashMap<PluginId, PluginSandbox>,
token: CancellationToken,
}

impl PluginHost {
/// Full lifecycle: discover → load → resolve → activate
pub async fn start_all(&mut self) -> Result<PluginStartReport, PluginError> {
let mut report = PluginStartReport::new();

// Phase 1: Discover
let discovered = self.registry.discover().await?;
report.discovered = discovered.len();

// Phase 2: Load manifests and validate
for id in &discovered {
match self.load_plugin(id).await {
Ok(_) => report.loaded += 1,
Err(e) => {
report.failures.push((id.clone(), e));
}
}
}

// Phase 3: Resolve dependencies
let activation_order = self.resolver.resolve()?;

// Phase 4: Activate in dependency order
for id in &activation_order {
match self.activate_plugin(id).await {
Ok(_) => report.activated += 1,
Err(e) => {
tracing::error!(plugin = %id, error = %e, "plugin activation failed");
report.failures.push((id.clone(), e));
}
}
}

Ok(report)
}

async fn activate_plugin(&mut self, id: &PluginId) -> Result<(), PluginError> {
let manifest = self.registry.get_manifest(id)?;

// Create sandbox with granted capabilities
let sandbox = PluginSandbox::new(
id.clone(),
manifest.capabilities.clone(),
manifest.limits.clone(),
);

// Initialize the plugin within the sandbox
sandbox.execute("init", async {
// Plugin initialization code runs here
Ok(())
}).await?;

self.sandboxes.insert(id.clone(), sandbox);
self.transition(id, PluginEvent::InitSuccess)?;

tracing::info!(plugin = %id, "plugin activated");
Ok(())
}

/// Gracefully deactivate all plugins in reverse dependency order.
pub async fn stop_all(&mut self) -> Result<(), PluginError> {
let order = self.resolver.resolve()?;

// Deactivate in reverse order (dependents first)
for id in order.iter().rev() {
if let Some(sandbox) = self.sandboxes.remove(id) {
sandbox.execute("shutdown", async {
// Plugin cleanup code runs here
Ok(())
}).await.ok(); // Best-effort cleanup

self.transition(id, PluginEvent::CleanupDone)?;
tracing::info!(plugin = %id, "plugin deactivated");
}
}

Ok(())
}
}

Capability-Based Security

Plugins interact with ClawDesk only through granted capabilities. Each capability is a specific permission that must be declared in the manifest and approved during installation:

/// Capability check at every API boundary.
impl PluginSandbox {
pub async fn send_message(
&self,
channel: &ChannelId,
content: &str,
) -> Result<(), PluginError> {
// Gate: requires MessagesSend capability
if !self.has_capability(&Capability::MessagesSend) {
return Err(PluginError::CapabilityDenied {
plugin: self.plugin_id.clone(),
capability: "MessagesSend".into(),
});
}

// Proceed with the actual send
self.host_api.send_message(channel, content).await
}

pub async fn http_request(
&self,
url: &str,
) -> Result<HttpResponse, PluginError> {
// Gate: requires NetworkAccess capability with matching host
match self.network_filter.as_ref() {
Some(filter) if filter.is_allowed(url) => {
self.host_api.http_request(url).await
}
Some(_) => Err(PluginError::NetworkDenied {
plugin: self.plugin_id.clone(),
url: url.to_string(),
}),
None => Err(PluginError::CapabilityDenied {
plugin: self.plugin_id.clone(),
capability: "NetworkAccess".into(),
}),
}
}
}

Plugin Communication

Plugins can communicate with each other through a message bus scoped by the plugin host:

pub struct PluginBus {
subscribers: HashMap<String, Vec<PluginId>>,
sender: broadcast::Sender<PluginMessage>,
}

#[derive(Debug, Clone)]
pub struct PluginMessage {
pub topic: String,
pub source: PluginId,
pub payload: serde_json::Value,
pub timestamp: DateTime<Utc>,
}

impl PluginBus {
pub fn publish(&self, msg: PluginMessage) -> Result<(), PluginError> {
self.sender.send(msg)
.map_err(|_| PluginError::BusDisconnected)?;
Ok(())
}

pub fn subscribe(&mut self, topic: &str, plugin: PluginId) -> broadcast::Receiver<PluginMessage> {
self.subscribers.entry(topic.to_string())
.or_default()
.push(plugin);
self.sender.subscribe()
}
}

Summary

AspectDesign ChoiceRationale
LifecycleFSM with 7 statesClear state transitions, auditable
DependenciesTopological sort (Kahn's)Correct activation order, cycle detection
IsolationSandbox with resource limitsPrevent runaway plugins
SecurityCapability-basedLeast privilege, declarative
CommunicationBroadcast message busDecoupled inter-plugin messaging
DiscoveryFile system scan + manifestSimple, no registry server needed