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
| Module | Type | Description |
|---|---|---|
host | PluginHost | Manages plugin lifecycle and inter-plugin communication |
resolver | DependencyResolver | Topological dependency resolution with cycle detection |
sandbox | PluginSandbox | Isolated execution environment with resource limits |
registry | PluginRegistry | Plugin 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 == 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.
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
| Aspect | Design Choice | Rationale |
|---|---|---|
| Lifecycle | FSM with 7 states | Clear state transitions, auditable |
| Dependencies | Topological sort (Kahn's) | Correct activation order, cycle detection |
| Isolation | Sandbox with resource limits | Prevent runaway plugins |
| Security | Capability-based | Least privilege, declarative |
| Communication | Broadcast message bus | Decoupled inter-plugin messaging |
| Discovery | File system scan + manifest | Simple, no registry server needed |