1use std::collections::HashMap;
4use std::path::PathBuf;
5use std::sync::Arc;
6
7use super::config::{diagnose_extension_config, ExtensionConfigDiagnostics};
8use super::info::PluginInfo;
9use super::hooks::HookBus;
10use super::manifest::{ExtensionConfigEntry, ExtensionManifest};
11use super::providers::{ProviderRegistry, RegisteredProvider, RegisteredProviderSummary};
12use super::runtime::{ExtensionHandler, ExtensionHealth};
13use super::runtime::process::ProcessExtension;
14use super::capability::{ExtensionCapabilitySnapshot, FutureCapabilityEntry, HookCapabilityEntry, ToolCapabilityEntry};
15use serde_json::{Map, Value};
16
17fn project_plugins_disabled() -> bool {
18 std::env::var("SYNAPS_DISABLE_PROJECT_PLUGINS")
19 .map(|value| {
20 let normalized = value.trim().to_ascii_lowercase();
21 matches!(normalized.as_str(), "1" | "true" | "yes" | "on")
22 })
23 .unwrap_or(false)
24}
25
26
27fn installed_plugin_setup_failure(plugin_name: &str) -> Option<String> {
28 let state_path = crate::skills::state::PluginsState::default_path();
29 let state = crate::skills::state::PluginsState::load_from(&state_path).ok()?;
30 let plugin = state.installed.iter().find(|p| p.name == plugin_name)?;
31 match &plugin.setup_status {
32 crate::skills::state::SetupStatus::Failed { message, .. } => Some(message.clone()),
33 _ => None,
34 }
35}
36
37fn sanitize_hint_fragment(input: &str) -> String {
38 input
39 .chars()
40 .map(|ch| if ch.is_control() { '?' } else { ch })
41 .collect::<String>()
42}
43
44#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct ExtensionLoadFailure {
47 pub plugin: String,
48 pub manifest_path: Option<PathBuf>,
49 pub reason: String,
50 pub hint: String,
51}
52
53impl ExtensionLoadFailure {
54 fn new(
55 plugin: impl Into<String>,
56 manifest_path: Option<PathBuf>,
57 reason: impl Into<String>,
58 hint: impl Into<String>,
59 ) -> Self {
60 Self {
61 plugin: plugin.into(),
62 manifest_path,
63 reason: reason.into(),
64 hint: hint.into(),
65 }
66 }
67
68 pub fn concise_message(&self) -> String {
69 match &self.manifest_path {
70 Some(path) => format!(
71 "{} (manifest: {}; hint: {})",
72 self.reason,
73 path.display(),
74 self.hint
75 ),
76 None => format!("{} (hint: {})", self.reason, self.hint),
77 }
78 }
79}
80
81#[derive(Debug, Clone, PartialEq, Eq)]
83pub struct ExtensionStatus {
84 pub id: String,
85 pub health: ExtensionHealth,
86 pub restart_count: usize,
87}
88
89pub fn compute_extension_load_hint(
104 error: &str,
105 plugin_dir: &std::path::Path,
106 declared_setup: Option<&str>,
107) -> String {
108 let missing_binary =
109 error.contains("No such file or directory") || error.contains("os error 2");
110 match (missing_binary, declared_setup) {
111 (true, Some(setup)) => format!(
112 "Extension binary missing — this plugin ships source only. Run the setup script from the plugin directory, then reload. plugin_dir={}, setup={}",
113 sanitize_hint_fragment(&plugin_dir.display().to_string()),
114 sanitize_hint_fragment(setup),
115 ),
116 _ => "Run `plugin validate <plugin-dir>` and confirm the extension command is installed"
117 .to_string(),
118 }
119}
120
121pub struct ExtensionManager {
123 hook_bus: Arc<HookBus>,
125 tools: Option<Arc<tokio::sync::RwLock<crate::ToolRegistry>>>,
127 providers: ProviderRegistry,
129 extensions: HashMap<String, Arc<dyn ExtensionHandler>>,
131 manifest_configs: HashMap<String, Vec<ExtensionConfigEntry>>,
134 capabilities: HashMap<String, Vec<crate::extensions::runtime::process::CapabilityDeclaration>>,
138 plugin_info: HashMap<String, PluginInfo>,
140}
141
142impl ExtensionManager {
143 pub fn new(hook_bus: Arc<HookBus>) -> Self {
145 Self {
146 hook_bus,
147 tools: None,
148 providers: ProviderRegistry::new(),
149 extensions: HashMap::new(),
150 manifest_configs: HashMap::new(),
151 capabilities: HashMap::new(),
152 plugin_info: HashMap::new(),
153 }
154 }
155
156 pub fn new_with_tools(
158 hook_bus: Arc<HookBus>,
159 tools: Arc<tokio::sync::RwLock<crate::ToolRegistry>>,
160 ) -> Self {
161 Self {
162 hook_bus,
163 tools: Some(tools),
164 providers: ProviderRegistry::new(),
165 extensions: HashMap::new(),
166 manifest_configs: HashMap::new(),
167 capabilities: HashMap::new(),
168 plugin_info: HashMap::new(),
169 }
170 }
171
172 pub async fn load(
174 &mut self,
175 id: &str,
176 manifest: &ExtensionManifest,
177 ) -> Result<(), String> {
178 self.load_with_cwd(id, manifest, None).await
179 }
180
181 pub async fn load_with_cwd(
183 &mut self,
184 id: &str,
185 manifest: &ExtensionManifest,
186 cwd: Option<std::path::PathBuf>,
187 ) -> Result<(), String> {
188 let config = Self::resolve_config(id, &manifest.config)?;
189 self.load_with_cwd_and_config(id, manifest, cwd, config).await
190 }
191
192 async fn load_with_cwd_and_config(
193 &mut self,
194 id: &str,
195 manifest: &ExtensionManifest,
196 cwd: Option<std::path::PathBuf>,
197 config: Value,
198 ) -> Result<(), String> {
199 if self.extensions.contains_key(id) {
201 return Err(format!("Extension '{}' is already loaded", id));
202 }
203
204 let validated = manifest.validate(id)?;
208 let permissions = validated.permissions;
209 let subscriptions = validated.subscriptions;
210
211 let process = ProcessExtension::spawn_with_cwd(id, &manifest.command, &manifest.args, cwd.clone()).await?;
213 process.set_permissions(permissions.clone()).await;
216 let capabilities = match process.initialize(cwd.clone(), config.clone()).await {
217 Ok(capabilities) => capabilities,
218 Err(error) => {
219 process.shutdown().await;
220 return Err(error);
221 }
222 };
223 let registered_tools = capabilities.tools;
224 let registered_providers = capabilities.providers;
225 let capability_declarations = capabilities.capabilities;
226 let should_probe_info = !registered_tools.is_empty()
227 || !registered_providers.is_empty()
228 || !capability_declarations.is_empty();
229 let handler: Arc<dyn ExtensionHandler> = Arc::new(process);
230 if !registered_tools.is_empty() && !permissions.has(crate::extensions::permissions::Permission::ToolsRegister) {
231 handler.shutdown().await;
232 return Err(format!(
233 "Extension '{}' registered tools but lacks permission 'tools.register'",
234 id
235 ));
236 }
237 if !registered_providers.is_empty() && !permissions.has(crate::extensions::permissions::Permission::ProvidersRegister) {
238 handler.shutdown().await;
239 return Err(format!(
240 "Extension '{}' registered providers but lacks permission 'providers.register'",
241 id
242 ));
243 }
244 for decl in &capability_declarations {
245 if let Err(err) = crate::extensions::runtime::process::validate_capability(decl, &permissions) {
246 handler.shutdown().await;
247 return Err(format!(
248 "Extension '{}' capability '{}' invalid: {}",
249 id, decl.kind, err
250 ));
251 }
252 }
253 if !registered_providers.is_empty() {
254 let mut registered_ids = Vec::new();
255 for provider in registered_providers {
256 if let Err(error) = Self::validate_provider_config_requirements(id, &provider, &config) {
257 self.providers.unregister_plugin(id);
258 handler.shutdown().await;
259 return Err(error);
260 }
261 match self.providers.register_with_handler(id, provider, Some(handler.clone())) {
262 Ok(runtime_id) => registered_ids.push(runtime_id),
263 Err(error) => {
264 self.providers.unregister_plugin(id);
265 handler.shutdown().await;
266 return Err(error);
267 }
268 }
269 }
270 tracing::info!(extension = %id, providers = ?registered_ids, "Extension provider metadata registered");
271 for runtime_id in ®istered_ids {
273 if let Some(provider) = self.providers.get(runtime_id) {
274 let tool_use = provider.spec.models.iter().any(|m| {
275 m.capabilities
276 .get("tool_use")
277 .and_then(|v| v.as_bool())
278 .unwrap_or(false)
279 });
280 if tool_use {
281 tracing::warn!(
282 "Provider '{}' is tool-use capable: it can request Synaps tools through provider mediation. Use `/extensions trust disable {}` to block routing.",
283 runtime_id,
284 runtime_id,
285 );
286 }
287 }
288 }
289 }
290 if !registered_tools.is_empty() {
291 let Some(tools) = &self.tools else {
292 handler.shutdown().await;
293 return Err(format!(
294 "Extension '{}' registered tools but no tool registry is available",
295 id
296 ));
297 };
298 let mut registry = tools.write().await;
299 for spec in registered_tools {
300 registry.register(Arc::new(crate::tools::ExtensionTool::new(id, spec, handler.clone())));
301 }
302 }
303
304 let info = if should_probe_info {
309 match handler.get_info().await {
310 Ok(info) => Some(info),
311 Err(error) => {
312 if error.contains("method not found") || error.contains("unknown method") {
313 tracing::debug!(
314 extension = %id,
315 error = %error,
316 "Extension did not provide optional info.get metadata",
317 );
318 None
319 } else {
320 tracing::warn!(
321 extension = %id,
322 error = %error,
323 "Ignoring invalid optional info.get metadata",
324 );
325 None
326 }
327 }
328 }
329 } else {
330 None
331 };
332
333 for (kind, tool_filter, matcher) in subscriptions {
335 self.hook_bus
336 .subscribe(kind, handler.clone(), tool_filter, matcher, permissions.clone())
337 .await?;
338 }
339
340 self.extensions.insert(id.to_string(), handler);
341 self.manifest_configs
342 .insert(id.to_string(), manifest.config.clone());
343 if !capability_declarations.is_empty() {
344 self.capabilities
345 .insert(id.to_string(), capability_declarations);
346 }
347 if let Some(info) = info {
348 self.plugin_info.insert(id.to_string(), info);
349 }
350 tracing::info!(extension = %id, hooks = manifest.hooks.len(), "Extension loaded");
351 Ok(())
352 }
353
354 fn validate_provider_config_requirements(
355 id: &str,
356 provider: &crate::extensions::runtime::process::RegisteredProviderSpec,
357 config: &Value,
358 ) -> Result<(), String> {
359 let Some(required) = provider
360 .config_schema
361 .as_ref()
362 .and_then(|schema| schema.get("required"))
363 .and_then(Value::as_array) else {
364 return Ok(());
365 };
366 for key in required {
367 let Some(key) = key.as_str() else {
368 return Err(format!(
369 "Extension '{}' provider '{}' config_schema.required must contain only strings",
370 id, provider.id,
371 ));
372 };
373 let present = config
374 .as_object()
375 .map(|map| map.contains_key(key))
376 .unwrap_or(false);
377 if !present {
378 return Err(format!(
379 "Extension '{}' provider '{}' missing required provider config '{}'",
380 id, provider.id, key,
381 ));
382 }
383 }
384 Ok(())
385 }
386
387 fn resolve_config(id: &str, entries: &[ExtensionConfigEntry]) -> Result<Value, String> {
388 let mut out = Map::new();
389 for entry in entries {
390 let key = entry.key.trim();
391 if key.is_empty() {
392 return Err(format!("Extension '{}' declares config with empty key", id));
393 }
394 if key.contains('.') || key.contains('/') || key.contains(' ') {
395 return Err(format!(
396 "Extension '{}' config key '{}' must not contain dots, slashes, or spaces",
397 id, key,
398 ));
399 }
400 let config_key = format!("extension.{}.{}", id, key);
401 if let Ok(value) = std::env::var(format!("SYNAPS_EXTENSION_{}_{}", id.replace('-', "_").to_ascii_uppercase(), key.replace('-', "_").to_ascii_uppercase())) {
402 out.insert(key.to_string(), Value::String(value));
403 continue;
404 }
405 if let Some(secret_env) = &entry.secret_env {
406 if let Ok(value) = std::env::var(secret_env) {
407 out.insert(key.to_string(), Value::String(value));
408 continue;
409 }
410 }
411 if let Some(value) = crate::extensions::config_store::read_plugin_config(id, key) {
412 out.insert(key.to_string(), Value::String(value));
413 continue;
414 }
415 if let Some(value) = crate::config::read_config_value(&config_key) {
416 out.insert(key.to_string(), Value::String(value));
417 continue;
418 }
419 if let Some(default) = &entry.default {
420 out.insert(key.to_string(), default.clone());
421 continue;
422 }
423 if entry.required {
424 let hint = if let Some(secret_env) = &entry.secret_env {
425 format!("set environment variable '{}' or config key '{}'", secret_env, config_key)
426 } else {
427 format!("set config key '{}'", config_key)
428 };
429 return Err(format!("Extension '{}' missing required config '{}': {}", id, key, hint));
430 }
431 }
432 Ok(Value::Object(out))
433 }
434
435 #[cfg(test)]
439 pub(crate) fn test_seed_capabilities(
440 &mut self,
441 id: &str,
442 decls: Vec<crate::extensions::runtime::process::CapabilityDeclaration>,
443 ) {
444 self.capabilities.insert(id.to_string(), decls);
445 }
446
447 pub async fn unload(&mut self, id: &str) -> Result<(), String> {
449 let handler = self
450 .extensions
451 .remove(id)
452 .ok_or_else(|| format!("Extension '{}' not found", id))?;
453
454 self.hook_bus.unsubscribe_all(id).await;
455 self.providers.unregister_plugin(id);
456 self.manifest_configs.remove(id);
457 self.capabilities.remove(id);
458 self.plugin_info.remove(id);
459 handler.shutdown().await;
460
461 tracing::info!(extension = %id, "Extension unloaded");
462 Ok(())
463 }
464
465 pub async fn reload(
469 &mut self,
470 id: &str,
471 manifest: &ExtensionManifest,
472 cwd: Option<std::path::PathBuf>,
473 ) -> Result<(), String> {
474 if self.extensions.contains_key(id) {
475 self.unload(id).await?;
476 }
477 self.load_with_cwd(id, manifest, cwd).await
478 }
479
480 pub async fn shutdown_all(&mut self) {
482 let ids: Vec<String> = self.extensions.keys().cloned().collect();
483 for id in ids {
484 let _ = self.unload(&id).await;
485 }
486 }
487
488 pub fn shutdown_all_detached(manager: Arc<tokio::sync::RwLock<Self>>) -> tokio::task::JoinHandle<()> {
494 tokio::spawn(async move {
495 manager.write().await.shutdown_all().await;
496 })
497 }
498
499 pub fn list(&self) -> Vec<&str> {
501 self.extensions.keys().map(|s| s.as_str()).collect()
502 }
503
504 pub fn handlers(&self) -> Vec<(String, Arc<dyn super::runtime::ExtensionHandler>)> {
508 let mut out: Vec<_> = self
509 .extensions
510 .iter()
511 .map(|(id, h)| (id.clone(), Arc::clone(h)))
512 .collect();
513 out.sort_by(|a, b| a.0.cmp(&b.0));
514 out
515 }
516
517 pub fn count(&self) -> usize {
519 self.extensions.len()
520 }
521
522 pub async fn statuses(&self) -> Vec<ExtensionStatus> {
524 let mut handlers: Vec<(String, Arc<dyn ExtensionHandler>)> = self
525 .extensions
526 .iter()
527 .map(|(id, handler)| (id.clone(), handler.clone()))
528 .collect();
529 handlers.sort_by(|a, b| a.0.cmp(&b.0));
530
531 let mut statuses = Vec::with_capacity(handlers.len());
532 for (id, handler) in handlers {
533 statuses.push(ExtensionStatus {
534 id,
535 health: handler.health().await,
536 restart_count: handler.restart_count().await,
537 });
538 }
539 statuses
540 }
541
542 pub fn providers(&self) -> Vec<&RegisteredProvider> {
544 self.providers.list()
545 }
546
547 pub fn provider(&self, runtime_id: &str) -> Option<&RegisteredProvider> {
549 self.providers.get(runtime_id)
550 }
551
552 pub fn plugin_info(&self, id: &str) -> Option<&PluginInfo> {
554 self.plugin_info.get(id)
555 }
556
557 pub async fn sidecar_spawn_args(
562 &self,
563 id: &str,
564 ) -> Result<crate::sidecar::spawn::SidecarSpawnArgs, String> {
565 let handler = self
566 .extensions
567 .get(id)
568 .ok_or_else(|| format!("unknown extension '{}'", id))?
569 .clone();
570 handler.sidecar_spawn_args().await
571 }
572
573 pub async fn invoke_command(
577 &self,
578 id: &str,
579 command: &str,
580 args: Vec<String>,
581 request_id: &str,
582 sink: tokio::sync::mpsc::UnboundedSender<crate::extensions::runtime::InvokeCommandEvent>,
583 ) -> Result<serde_json::Value, String> {
584 let handler = self
585 .extensions
586 .get(id)
587 .ok_or_else(|| format!("unknown extension '{}'", id))?
588 .clone();
589 handler.invoke_command(command, args, request_id, sink).await
590 }
591
592 pub async fn settings_editor_open(
593 &self,
594 id: &str,
595 category: &str,
596 field: &str,
597 ) -> Result<serde_json::Value, String> {
598 let handler = self
599 .extensions
600 .get(id)
601 .ok_or_else(|| format!("unknown extension '{}'", id))?
602 .clone();
603 handler.settings_editor_open(category, field).await
604 }
605
606 pub async fn settings_editor_key(
607 &self,
608 id: &str,
609 category: &str,
610 field: &str,
611 key: &str,
612 ) -> Result<serde_json::Value, String> {
613 let handler = self
614 .extensions
615 .get(id)
616 .ok_or_else(|| format!("unknown extension '{}'", id))?
617 .clone();
618 handler.settings_editor_key(category, field, key).await
619 }
620
621 pub async fn settings_editor_commit(
622 &self,
623 id: &str,
624 category: &str,
625 field: &str,
626 value: serde_json::Value,
627 ) -> Result<serde_json::Value, String> {
628 let handler = self
629 .extensions
630 .get(id)
631 .ok_or_else(|| format!("unknown extension '{}'", id))?
632 .clone();
633 handler.settings_editor_commit(category, field, value).await
634 }
635
636 pub fn plugin_infos(&self) -> Vec<(&str, &PluginInfo)> {
638 let mut entries: Vec<_> = self
639 .plugin_info
640 .iter()
641 .map(|(id, info)| (id.as_str(), info))
642 .collect();
643 entries.sort_by(|a, b| a.0.cmp(b.0));
644 entries
645 }
646
647 pub fn provider_summaries(&self) -> Vec<RegisteredProviderSummary> {
649 self.providers.summaries()
650 }
651
652 pub async fn capability_snapshots(&self) -> Vec<ExtensionCapabilitySnapshot> {
658 let mut handlers: Vec<(String, Arc<dyn ExtensionHandler>)> = self
659 .extensions
660 .iter()
661 .map(|(id, handler)| (id.clone(), handler.clone()))
662 .collect();
663 handlers.sort_by(|a, b| a.0.cmp(&b.0));
664
665 let provider_summaries = self.providers.summaries();
666 let plugin_id_lookup: std::collections::HashMap<String, String> = self
667 .providers
668 .list()
669 .into_iter()
670 .map(|p| (p.runtime_id.clone(), p.plugin_id.clone()))
671 .collect();
672
673 let mut out = Vec::with_capacity(handlers.len());
674 for (id, handler) in handlers {
675 let health = handler.health().await;
676 let restart_count = handler.restart_count().await;
677
678 let hook_pairs = self.hook_bus.subscriptions_for(&id).await;
679 let hooks: Vec<HookCapabilityEntry> = hook_pairs
680 .into_iter()
681 .map(|(kind, tool_filter)| HookCapabilityEntry {
682 kind: kind.as_str().to_string(),
683 tool_filter,
684 })
685 .collect();
686
687 let tools: Vec<ToolCapabilityEntry> = if let Some(tools) = &self.tools {
688 let registry = tools.read().await;
689 registry
690 .tool_names_for_extension(&id)
691 .into_iter()
692 .map(|name| ToolCapabilityEntry { name })
693 .collect()
694 } else {
695 Vec::new()
696 };
697
698 let providers: Vec<RegisteredProviderSummary> = provider_summaries
699 .iter()
700 .filter(|summary| {
701 plugin_id_lookup
702 .get(&summary.runtime_id)
703 .map(|p| p == &id)
704 .unwrap_or(false)
705 })
706 .cloned()
707 .collect();
708
709 let future: Vec<FutureCapabilityEntry> = self
710 .capabilities
711 .get(&id)
712 .map(|decls| {
713 decls
714 .iter()
715 .map(|d| FutureCapabilityEntry {
716 kind: d.kind.clone(),
717 name: d.name.clone(),
718 })
719 .collect()
720 })
721 .unwrap_or_default();
722
723 out.push(ExtensionCapabilitySnapshot {
724 id,
725 health,
726 restart_count,
727 hooks,
728 tools,
729 providers,
730 future,
731 });
732 }
733 out
734 }
735
736 pub fn provider_tool_use_runtime_ids(&self) -> Vec<String> {
739 let mut ids: Vec<String> = self
740 .providers
741 .list()
742 .into_iter()
743 .filter(|p| {
744 p.spec.models.iter().any(|m| {
745 m.capabilities
746 .get("tool_use")
747 .and_then(|v| v.as_bool())
748 .unwrap_or(false)
749 })
750 })
751 .map(|p| p.runtime_id.clone())
752 .collect();
753 ids.sort();
754 ids
755 }
756
757 pub fn provider_trust_view(&self) -> std::collections::BTreeMap<String, bool> {
763 let trust = match crate::extensions::trust::load_trust_state() {
764 Ok(t) => t,
765 Err(e) => {
766 tracing::warn!("trust.json corrupt or unreadable, failing closed (all providers disabled): {e}");
767 return self.providers
769 .list()
770 .into_iter()
771 .map(|p| (p.runtime_id.clone(), false))
772 .collect();
773 }
774 };
775 self.providers
776 .list()
777 .into_iter()
778 .map(|p| {
779 let enabled =
780 crate::extensions::trust::is_provider_enabled(&trust, &p.runtime_id);
781 (p.runtime_id.clone(), enabled)
782 })
783 .collect()
784 }
785
786 pub fn config_diagnostics(&self, id: &str) -> Option<ExtensionConfigDiagnostics> {
789 let manifest_config = self.manifest_configs.get(id)?;
790
791 let mut provider_required: Vec<(String, Vec<String>)> = Vec::new();
793 for provider in self.providers.list() {
794 if provider.plugin_id != id {
795 continue;
796 }
797 let required: Vec<String> = provider
798 .spec
799 .config_schema
800 .as_ref()
801 .and_then(|schema| schema.get("required"))
802 .and_then(Value::as_array)
803 .map(|arr| {
804 arr.iter()
805 .filter_map(|v| v.as_str().map(|s| s.to_string()))
806 .collect()
807 })
808 .unwrap_or_default();
809 provider_required.push((provider.provider_id.clone(), required));
810 }
811 provider_required.sort_by(|a, b| a.0.cmp(&b.0));
812
813 let env_lookup = |name: &str| std::env::var(name).ok();
814 let plugin_config_lookup = |key: &str| crate::extensions::config_store::read_plugin_config(id, key);
815 let legacy_config_lookup = |key: &str| crate::config::read_config_value(key);
816
817 Some(diagnose_extension_config(
818 id,
819 manifest_config,
820 &provider_required,
821 &env_lookup,
822 &plugin_config_lookup,
823 &legacy_config_lookup,
824 ))
825 }
826
827 pub fn all_config_diagnostics(&self) -> Vec<ExtensionConfigDiagnostics> {
829 let mut ids: Vec<&String> = self.manifest_configs.keys().collect();
830 ids.sort();
831 ids.into_iter()
832 .filter_map(|id| self.config_diagnostics(id))
833 .collect()
834 }
835
836 pub fn hook_bus(&self) -> &Arc<HookBus> {
838 &self.hook_bus
839 }
840
841 pub fn tools_shared(&self) -> Option<Arc<tokio::sync::RwLock<crate::ToolRegistry>>> {
843 self.tools.clone()
844 }
845
846 pub async fn discover_and_load(&mut self) -> (Vec<String>, Vec<ExtensionLoadFailure>) {
853 self.discover_and_load_with_progress(|_| {}).await
854 }
855
856 pub async fn discover_and_load_with_progress<F>(&mut self, mut progress: F) -> (Vec<String>, Vec<ExtensionLoadFailure>)
860 where
861 F: FnMut(crate::extensions::loader::ExtensionLoaderEvent),
862 {
863 let mut plugin_roots = vec![crate::config::base_dir().join("plugins")];
864 if !project_plugins_disabled() {
865 if let Ok(cwd) = std::env::current_dir() {
866 let project_plugins = cwd.join(".synaps").join("plugins");
867 if project_plugins != plugin_roots[0] {
868 plugin_roots.push(project_plugins);
869 }
870 }
871 }
872
873 let mut plugin_dirs: HashMap<String, PathBuf> = HashMap::new();
874 let mut failed: Vec<ExtensionLoadFailure> = Vec::new();
875
876 for plugins_dir in plugin_roots {
877 if !plugins_dir.exists() {
878 continue;
879 }
880
881 let entries = match std::fs::read_dir(&plugins_dir) {
882 Ok(e) => e,
883 Err(e) => {
884 tracing::warn!(path = %plugins_dir.display(), error = %e, "Failed to read plugins directory");
885 failed.push(ExtensionLoadFailure::new(
886 "plugins",
887 Some(plugins_dir.clone()),
888 format!("Failed to read plugins directory: {e}"),
889 "Check directory permissions and retry",
890 ));
891 continue;
892 }
893 };
894
895 for entry in entries.flatten() {
896 let plugin_name = entry.file_name().to_string_lossy().to_string();
897 plugin_dirs.insert(plugin_name, entry.path());
898 }
899 }
900
901 let mut plugin_dirs: Vec<(String, PathBuf)> = plugin_dirs.into_iter().collect();
902 plugin_dirs.sort_by(|a, b| a.0.cmp(&b.0));
903
904 let mut loaded = Vec::new();
905 let disabled_plugins = crate::config::load_config().disabled_plugins;
906 for (plugin_name, plugin_dir) in plugin_dirs {
907 if disabled_plugins.iter().any(|d| d == &plugin_name) {
908 tracing::debug!(plugin = %plugin_name, "Extension disabled via disabled_plugins config");
909 continue;
910 }
911 if let Some(message) = installed_plugin_setup_failure(&plugin_name) {
912 tracing::warn!(plugin = %plugin_name, error = %message, "Skipping extension with failed post-install setup");
913 failed.push(ExtensionLoadFailure::new(
914 plugin_name,
915 None,
916 format!("Post-install setup failed: {message}"),
917 "Open /plugins, reinstall or update the plugin after fixing setup; extension load is disabled until setup succeeds",
918 ));
919 continue;
920 }
921 let manifest_path = plugin_dir.join(".synaps-plugin").join("plugin.json");
922 if !manifest_path.exists() {
923 continue;
924 }
925
926 let content = match std::fs::read_to_string(&manifest_path) {
927 Ok(c) => c,
928 Err(e) => {
929 let reason = format!("Failed to read plugin manifest: {e}");
930 tracing::warn!(plugin = %plugin_name, manifest = %manifest_path.display(), error = %e, "Failed to read plugin manifest");
931 failed.push(ExtensionLoadFailure::new(
932 plugin_name,
933 Some(manifest_path),
934 reason,
935 "Check manifest file permissions, then run `plugin validate <plugin-dir>`",
936 ));
937 continue;
938 }
939 };
940
941 let json: serde_json::Value = match serde_json::from_str(&content) {
942 Ok(v) => v,
943 Err(e) => {
944 let reason = format!("Invalid plugin manifest JSON: {e}");
945 tracing::warn!(plugin = %plugin_name, manifest = %manifest_path.display(), error = %e, "Invalid plugin manifest JSON");
946 failed.push(ExtensionLoadFailure::new(
947 plugin_name,
948 Some(manifest_path),
949 reason,
950 "Fix JSON syntax, then run `plugin validate <plugin-dir>`",
951 ));
952 continue;
953 }
954 };
955
956 let ext_value = match json.get("extension") {
957 Some(v) => v.clone(),
958 None => continue,
959 };
960
961 let ext_manifest: ExtensionManifest = match serde_json::from_value(ext_value) {
962 Ok(m) => m,
963 Err(e) => {
964 let reason = format!("Failed to parse extension manifest: {e}");
965 tracing::warn!(plugin = %plugin_name, manifest = %manifest_path.display(), error = %e, "Failed to parse extension manifest");
966 failed.push(ExtensionLoadFailure::new(
967 plugin_name,
968 Some(manifest_path),
969 reason,
970 "Check the `extension` block shape against docs/extensions/contract.json, then run `plugin validate <plugin-dir>`",
971 ));
972 continue;
973 }
974 };
975
976 #[allow(clippy::if_same_then_else)]
977 let command = if std::path::Path::new(&ext_manifest.command).is_absolute() {
978 ext_manifest.command.clone()
979 } else if !ext_manifest.command.contains(std::path::MAIN_SEPARATOR) && !ext_manifest.command.contains('/') {
980 ext_manifest.command.clone()
981 } else {
982 plugin_dir.join(&ext_manifest.command)
983 .to_string_lossy().to_string()
984 };
985
986 let args: Vec<String> = ext_manifest.args.iter().map(|arg| {
987 let arg_path = plugin_dir.join(arg);
988 if arg_path.exists() {
989 if let (Ok(canonical), Ok(plugin_canonical)) = (
990 arg_path.canonicalize(),
991 plugin_dir.canonicalize(),
992 ) {
993 if canonical.starts_with(&plugin_canonical) {
994 return canonical.to_string_lossy().to_string();
995 }
996 }
997 }
998 arg.clone()
999 }).collect();
1000
1001 let resolved = ExtensionManifest {
1002 command,
1003 args,
1004 ..ext_manifest
1005 };
1006
1007 match self.load_with_cwd(&plugin_name, &resolved, Some(plugin_dir.clone())).await {
1008 Ok(()) => {
1009 tracing::info!(plugin = %plugin_name, path = %plugin_dir.display(), "Extension loaded from plugins/");
1010 loaded.push(plugin_name.clone());
1011 progress(crate::extensions::loader::ExtensionLoaderEvent::Loaded {
1012 plugin: plugin_name,
1013 loaded: loaded.len(),
1014 failed: failed.len(),
1015 });
1016 }
1017 Err(e) => {
1018 tracing::warn!(plugin = %plugin_name, manifest = %manifest_path.display(), error = %e, "Failed to load extension");
1019 let setup_script = json
1020 .pointer("/extension/setup")
1021 .and_then(|v| v.as_str())
1022 .or_else(|| json.pointer("/provides/sidecar/setup").and_then(|v| v.as_str()));
1023 let hint = compute_extension_load_hint(&e, &plugin_dir, setup_script);
1024 let failure = ExtensionLoadFailure::new(
1025 plugin_name,
1026 Some(manifest_path),
1027 e,
1028 hint,
1029 );
1030 failed.push(failure.clone());
1031 progress(crate::extensions::loader::ExtensionLoaderEvent::Failed {
1032 failure,
1033 loaded: loaded.len(),
1034 failed: failed.len(),
1035 });
1036 }
1037 }
1038 }
1039
1040 (loaded, failed)
1041 }
1042}
1043
1044#[cfg(test)]
1045mod tests {
1046 use super::*;
1047
1048 #[tokio::test]
1049 async fn capability_snapshots_empty_when_no_extensions() {
1050 let bus = Arc::new(HookBus::new());
1051 let mgr = ExtensionManager::new(bus);
1052 assert!(mgr.capability_snapshots().await.is_empty());
1053 }
1054
1055 #[tokio::test]
1056 async fn capability_snapshot_lists_hooks_for_loaded_extension() {
1057 let bus = Arc::new(HookBus::new());
1058 let mut mgr = ExtensionManager::new(bus.clone());
1059 let manifest = ExtensionManifest {
1060 protocol_version: 1,
1061 runtime: crate::extensions::manifest::ExtensionRuntime::Process,
1062 command: "python3".to_string(),
1063 setup: None,
1064 prebuilt: ::std::collections::HashMap::new(),
1065 args: vec![
1066 "tests/fixtures/process_extension.py".to_string(),
1067 "normal".to_string(),
1068 "/tmp/synaps-capability-test.log".to_string(),
1069 ],
1070 permissions: vec!["tools.intercept".to_string()],
1071 hooks: vec![crate::extensions::manifest::HookSubscription {
1072 hook: "before_tool_call".to_string(),
1073 tool: Some("bash".to_string()),
1074 matcher: None,
1075 }],
1076 config: vec![],
1077 };
1078
1079 mgr.load("cap-snap", &manifest).await.unwrap();
1080
1081 let snaps = mgr.capability_snapshots().await;
1082 assert_eq!(snaps.len(), 1);
1083 let snap = &snaps[0];
1084 assert_eq!(snap.id, "cap-snap");
1085 assert_eq!(snap.hooks.len(), 1);
1086 assert_eq!(snap.hooks[0].kind, "before_tool_call");
1087 assert_eq!(snap.hooks[0].tool_filter.as_deref(), Some("bash"));
1088 assert!(snap.tools.is_empty());
1089 assert!(snap.providers.is_empty());
1090 assert!(snap.future.is_empty());
1091
1092 mgr.shutdown_all().await;
1093 }
1094
1095 #[tokio::test]
1096 async fn capability_snapshot_surfaces_seeded_capabilities() {
1097 let bus = Arc::new(HookBus::new());
1098 let mut mgr = ExtensionManager::new(bus.clone());
1099 let manifest = ExtensionManifest {
1100 protocol_version: 1,
1101 runtime: crate::extensions::manifest::ExtensionRuntime::Process,
1102 command: "python3".to_string(),
1103 setup: None,
1104 prebuilt: ::std::collections::HashMap::new(),
1105 args: vec![
1106 "tests/fixtures/process_extension.py".to_string(),
1107 "normal".to_string(),
1108 "/tmp/synaps-capability-snapshot-test.log".to_string(),
1109 ],
1110 permissions: vec!["tools.intercept".to_string()],
1111 hooks: vec![crate::extensions::manifest::HookSubscription {
1112 hook: "before_tool_call".to_string(),
1113 tool: Some("bash".to_string()),
1114 matcher: None,
1115 }],
1116 config: vec![],
1117 };
1118
1119 mgr.load("multi-cap", &manifest).await.unwrap();
1120
1121 mgr.test_seed_capabilities(
1125 "multi-cap",
1126 vec![
1127 crate::extensions::runtime::process::CapabilityDeclaration {
1128 kind: "capture".to_string(),
1129 name: "Local Sample STT".to_string(),
1130 permissions: vec!["audio.input".to_string()],
1131 params: serde_json::Value::Null,
1132 },
1133 crate::extensions::runtime::process::CapabilityDeclaration {
1134 kind: "ocr".to_string(),
1135 name: "Tesseract".to_string(),
1136 permissions: vec![],
1137 params: serde_json::Value::Null,
1138 },
1139 ],
1140 );
1141
1142 let snaps = mgr.capability_snapshots().await;
1143 let snap = snaps
1144 .iter()
1145 .find(|s| s.id == "multi-cap")
1146 .expect("multi-cap snapshot");
1147 assert_eq!(snap.future.len(), 2);
1148 let kinds: Vec<&str> = snap.future.iter().map(|e| e.kind.as_str()).collect();
1149 assert!(kinds.contains(&"capture"), "got kinds {:?}", kinds);
1150 assert!(kinds.contains(&"ocr"), "got kinds {:?}", kinds);
1151 let names: Vec<&str> = snap.future.iter().map(|e| e.name.as_str()).collect();
1152 assert!(names.contains(&"Local Sample STT"), "got {:?}", names);
1153 assert!(names.contains(&"Tesseract"), "got {:?}", names);
1154
1155 mgr.unload("multi-cap").await.unwrap();
1156 let snaps = mgr.capability_snapshots().await;
1157 assert!(snaps.iter().all(|s| s.id != "multi-cap"));
1158
1159 mgr.shutdown_all().await;
1160 }
1161
1162 #[tokio::test]
1163 async fn new_manager_has_no_extensions() {
1164 let bus = Arc::new(HookBus::new());
1165 let mgr = ExtensionManager::new(bus);
1166 assert_eq!(mgr.count(), 0);
1167 assert!(mgr.list().is_empty());
1168 }
1169
1170 #[tokio::test]
1171 async fn unload_nonexistent_returns_error() {
1172 let bus = Arc::new(HookBus::new());
1173 let mut mgr = ExtensionManager::new(bus);
1174 let result = mgr.unload("nope").await;
1175 assert!(result.is_err());
1176 }
1177
1178 #[tokio::test]
1179 async fn reload_unsubscribes_old_handler_before_loading_new_one() {
1180 let bus = Arc::new(HookBus::new());
1181 let mut mgr = ExtensionManager::new(bus.clone());
1182 let manifest = ExtensionManifest {
1183 protocol_version: 1,
1184 runtime: crate::extensions::manifest::ExtensionRuntime::Process,
1185 command: "python3".to_string(),
1186 setup: None,
1187 prebuilt: ::std::collections::HashMap::new(),
1188 args: vec!["tests/fixtures/process_extension.py".to_string(), "normal".to_string(), "/tmp/synaps-reload-test.log".to_string()],
1189 permissions: vec!["tools.intercept".to_string()],
1190 hooks: vec![crate::extensions::manifest::HookSubscription {
1191 hook: "before_tool_call".to_string(),
1192 tool: Some("bash".to_string()),
1193 matcher: None,
1194 }],
1195 config: vec![],
1196 };
1197
1198 mgr.load("reload-test", &manifest).await.unwrap();
1199 assert_eq!(bus.handler_count().await, 1);
1200
1201 mgr.reload("reload-test", &manifest, None).await.unwrap();
1202
1203 assert_eq!(mgr.count(), 1);
1204 assert_eq!(bus.handler_count().await, 1);
1205 mgr.shutdown_all().await;
1206 }
1207
1208 #[tokio::test]
1209 async fn reload_failure_leaves_previous_instance_unloaded() {
1210 let bus = Arc::new(HookBus::new());
1211 let mut mgr = ExtensionManager::new(bus.clone());
1212 let good = ExtensionManifest {
1213 protocol_version: 1,
1214 runtime: crate::extensions::manifest::ExtensionRuntime::Process,
1215 command: "python3".to_string(),
1216 setup: None,
1217 prebuilt: ::std::collections::HashMap::new(),
1218 args: vec!["tests/fixtures/process_extension.py".to_string(), "normal".to_string(), "/tmp/synaps-reload-failure-test.log".to_string()],
1219 permissions: vec!["tools.intercept".to_string()],
1220 hooks: vec![crate::extensions::manifest::HookSubscription {
1221 hook: "before_tool_call".to_string(),
1222 tool: Some("bash".to_string()),
1223 matcher: None,
1224 }],
1225 config: vec![],
1226 };
1227 let bad = ExtensionManifest {
1228 command: "/definitely/not/a/real/extension-binary".to_string(),
1229 setup: None,
1230 prebuilt: ::std::collections::HashMap::new(),
1231 ..good.clone()
1232 };
1233
1234 mgr.load("reload-failure-test", &good).await.unwrap();
1235 let err = mgr.reload("reload-failure-test", &bad, None).await.unwrap_err();
1236
1237 assert!(err.contains("Failed to spawn extension"), "{err}");
1238 assert_eq!(mgr.count(), 0);
1239 assert_eq!(bus.handler_count().await, 0);
1240 }
1241
1242 #[test]
1243 fn project_plugins_disable_env_parser_accepts_truthy_values() {
1244 for value in ["1", "true", "TRUE", "yes", "on"] {
1245 std::env::set_var("SYNAPS_DISABLE_PROJECT_PLUGINS", value);
1246 assert!(project_plugins_disabled());
1247 }
1248 for value in ["", "0", "false", "off", "no"] {
1249 std::env::set_var("SYNAPS_DISABLE_PROJECT_PLUGINS", value);
1250 assert!(!project_plugins_disabled());
1251 }
1252 std::env::remove_var("SYNAPS_DISABLE_PROJECT_PLUGINS");
1253 }
1254
1255 fn with_temp_base_dir<T>(path: &std::path::Path, f: impl FnOnce() -> T) -> T {
1256 let old_base_dir = std::env::var("SYNAPS_BASE_DIR").ok();
1257 crate::config::set_base_dir_for_tests(path.to_path_buf());
1258 let out = f();
1259 match old_base_dir {
1260 Some(old) => std::env::set_var("SYNAPS_BASE_DIR", old),
1261 None => std::env::remove_var("SYNAPS_BASE_DIR"),
1262 }
1263 out
1264 }
1265
1266 #[test]
1267 fn resolve_config_prefers_plugin_namespaced_config_before_legacy_global_key() {
1268 let dir = tempfile::tempdir().unwrap();
1269 with_temp_base_dir(dir.path(), || {
1270 crate::extensions::config_store::write_plugin_config("sample-sidecar", "backend", "cpu")
1271 .unwrap();
1272 crate::config::write_config_value("extension.sample-sidecar.backend", "auto").unwrap();
1273
1274 let resolved = ExtensionManager::resolve_config(
1275 "sample-sidecar",
1276 &[ExtensionConfigEntry {
1277 key: "backend".to_string(),
1278 value_type: None,
1279 description: None,
1280 required: true,
1281 default: None,
1282 secret_env: None,
1283 }],
1284 )
1285 .unwrap();
1286
1287 assert_eq!(resolved["backend"], serde_json::Value::String("cpu".to_string()));
1288 });
1289 }
1290
1291 #[test]
1292 fn resolve_config_keeps_legacy_global_extension_key_as_fallback() {
1293 let dir = tempfile::tempdir().unwrap();
1294 with_temp_base_dir(dir.path(), || {
1295 crate::config::write_config_value("extension.sample-sidecar.backend", "auto").unwrap();
1296
1297 let resolved = ExtensionManager::resolve_config(
1298 "sample-sidecar",
1299 &[ExtensionConfigEntry {
1300 key: "backend".to_string(),
1301 value_type: None,
1302 description: None,
1303 required: true,
1304 default: None,
1305 secret_env: None,
1306 }],
1307 )
1308 .unwrap();
1309
1310 assert_eq!(resolved["backend"], serde_json::Value::String("auto".to_string()));
1311 });
1312 }
1313
1314 #[tokio::test]
1315 async fn config_diagnostics_returns_none_for_unknown_extension() {
1316 let bus = Arc::new(HookBus::new());
1317 let mgr = ExtensionManager::new(bus);
1318 assert!(mgr.config_diagnostics("nope").is_none());
1319 assert!(mgr.all_config_diagnostics().is_empty());
1320 }
1321
1322 #[tokio::test]
1323 async fn config_diagnostics_reports_loaded_manifest_entries() {
1324 let bus = Arc::new(HookBus::new());
1325 let mut mgr = ExtensionManager::new(bus);
1326 let manifest = ExtensionManifest {
1327 protocol_version: 1,
1328 runtime: crate::extensions::manifest::ExtensionRuntime::Process,
1329 command: "python3".to_string(),
1330 setup: None,
1331 prebuilt: ::std::collections::HashMap::new(),
1332 args: vec![
1333 "tests/fixtures/process_extension.py".to_string(),
1334 "normal".to_string(),
1335 "/tmp/synaps-config-diag-test.log".to_string(),
1336 ],
1337 permissions: vec!["tools.intercept".to_string()],
1338 hooks: vec![crate::extensions::manifest::HookSubscription {
1339 hook: "before_tool_call".to_string(),
1340 tool: Some("bash".to_string()),
1341 matcher: None,
1342 }],
1343 config: vec![crate::extensions::manifest::ExtensionConfigEntry {
1344 key: "region".to_string(),
1345 value_type: None,
1346 description: Some("AWS region".to_string()),
1347 required: false,
1348 default: Some(serde_json::Value::String("us-east-1".to_string())),
1349 secret_env: None,
1350 }],
1351 };
1352
1353 mgr.load("config-diag-test", &manifest).await.unwrap();
1354
1355 let diag = mgr
1356 .config_diagnostics("config-diag-test")
1357 .expect("diagnostics should be available for loaded extension");
1358 assert_eq!(diag.extension_id, "config-diag-test");
1359 assert_eq!(diag.entries.len(), 1);
1360 assert_eq!(diag.entries[0].key, "region");
1361 assert!(diag.entries[0].has_value);
1362 assert!(diag.provider_missing.is_empty());
1363
1364 let all = mgr.all_config_diagnostics();
1365 assert_eq!(all.len(), 1);
1366 assert_eq!(all[0].extension_id, "config-diag-test");
1367
1368 mgr.shutdown_all().await;
1369 assert!(mgr.config_diagnostics("config-diag-test").is_none());
1371 }
1372
1373 #[tokio::test]
1374 async fn provider_trust_view_is_empty_for_no_providers() {
1375 let bus = Arc::new(HookBus::new());
1376 let mgr = ExtensionManager::new(bus);
1377 let view = mgr.provider_trust_view();
1378 assert!(view.is_empty());
1379 }
1380
1381 #[tokio::test]
1382 async fn provider_tool_use_runtime_ids_lists_only_tool_use_capable() {
1383 use crate::extensions::runtime::process::{RegisteredProviderModelSpec, RegisteredProviderSpec};
1384 let bus = Arc::new(HookBus::new());
1385 let mut mgr = ExtensionManager::new(bus);
1386 let tool_spec = RegisteredProviderSpec {
1388 id: "alpha".into(),
1389 display_name: "Alpha".into(),
1390 description: "tool-use".into(),
1391 models: vec![RegisteredProviderModelSpec {
1392 id: "m1".into(),
1393 display_name: None,
1394 capabilities: serde_json::json!({"tool_use": true}),
1395 context_window: None,
1396 }],
1397 config_schema: None,
1398 };
1399 let plain_spec = RegisteredProviderSpec {
1401 id: "beta".into(),
1402 display_name: "Beta".into(),
1403 description: "plain".into(),
1404 models: vec![RegisteredProviderModelSpec {
1405 id: "m1".into(),
1406 display_name: None,
1407 capabilities: serde_json::json!({"streaming": true}),
1408 context_window: None,
1409 }],
1410 config_schema: None,
1411 };
1412 mgr.providers.register("plug", tool_spec).unwrap();
1413 mgr.providers.register("plug", plain_spec).unwrap();
1414 let ids = mgr.provider_tool_use_runtime_ids();
1415 assert_eq!(ids, vec!["plug:alpha".to_string()]);
1416 }
1417
1418 #[test]
1421 fn hint_missing_binary_with_declared_setup_points_at_script() {
1422 let hint = compute_extension_load_hint(
1423 "Failed to spawn extension 'sample-sidecar': No such file or directory (os error 2)",
1424 std::path::Path::new("/home/u/.synaps-cli/plugins/sample-sidecar"),
1425 Some("scripts/setup.sh"),
1426 );
1427 assert!(
1428 hint.contains("Extension binary missing"),
1429 "missing-binary case should be flagged: {hint}"
1430 );
1431 assert!(
1432 hint.contains("/home/u/.synaps-cli/plugins/sample-sidecar"),
1433 "hint should include the plugin dir: {hint}"
1434 );
1435 assert!(
1436 hint.contains("setup=scripts/setup.sh"),
1437 "hint should show sanitized setup path without copy-paste shell command: {hint}"
1438 );
1439 }
1440
1441 #[test]
1442 fn hint_missing_binary_without_declared_setup_falls_back_to_generic() {
1443 let hint = compute_extension_load_hint(
1444 "Failed to spawn extension 'foo': No such file or directory (os error 2)",
1445 std::path::Path::new("/x/y"),
1446 None,
1447 );
1448 assert!(
1449 hint.contains("plugin validate"),
1450 "no setup declared → generic hint: {hint}"
1451 );
1452 assert!(
1453 !hint.contains("Extension binary missing"),
1454 "should not falsely promise a setup script: {hint}"
1455 );
1456 }
1457
1458 #[test]
1459 fn hint_other_error_with_declared_setup_falls_back_to_generic() {
1460 let hint = compute_extension_load_hint(
1461 "Extension 'foo' must subscribe to at least one hook or request a registration permission",
1462 std::path::Path::new("/x/y"),
1463 Some("scripts/setup.sh"),
1464 );
1465 assert!(hint.contains("plugin validate"), "got {hint}");
1469 assert!(!hint.contains("Extension binary missing"), "got {hint}");
1470 }
1471
1472 #[test]
1473 fn hint_recognises_os_error_2_format() {
1474 let hint = compute_extension_load_hint(
1477 "spawn failed (os error 2)",
1478 std::path::Path::new("/p"),
1479 Some("setup.sh"),
1480 );
1481 assert!(hint.contains("Extension binary missing"), "got {hint}");
1482 }
1483}