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 count(&self) -> usize {
506 self.extensions.len()
507 }
508
509 pub async fn statuses(&self) -> Vec<ExtensionStatus> {
511 let mut handlers: Vec<(String, Arc<dyn ExtensionHandler>)> = self
512 .extensions
513 .iter()
514 .map(|(id, handler)| (id.clone(), handler.clone()))
515 .collect();
516 handlers.sort_by(|a, b| a.0.cmp(&b.0));
517
518 let mut statuses = Vec::with_capacity(handlers.len());
519 for (id, handler) in handlers {
520 statuses.push(ExtensionStatus {
521 id,
522 health: handler.health().await,
523 restart_count: handler.restart_count().await,
524 });
525 }
526 statuses
527 }
528
529 pub fn providers(&self) -> Vec<&RegisteredProvider> {
531 self.providers.list()
532 }
533
534 pub fn provider(&self, runtime_id: &str) -> Option<&RegisteredProvider> {
536 self.providers.get(runtime_id)
537 }
538
539 pub fn plugin_info(&self, id: &str) -> Option<&PluginInfo> {
541 self.plugin_info.get(id)
542 }
543
544 pub async fn sidecar_spawn_args(
549 &self,
550 id: &str,
551 ) -> Result<crate::sidecar::spawn::SidecarSpawnArgs, String> {
552 let handler = self
553 .extensions
554 .get(id)
555 .ok_or_else(|| format!("unknown extension '{}'", id))?
556 .clone();
557 handler.sidecar_spawn_args().await
558 }
559
560 pub async fn invoke_command(
564 &self,
565 id: &str,
566 command: &str,
567 args: Vec<String>,
568 request_id: &str,
569 sink: tokio::sync::mpsc::UnboundedSender<crate::extensions::runtime::InvokeCommandEvent>,
570 ) -> Result<serde_json::Value, String> {
571 let handler = self
572 .extensions
573 .get(id)
574 .ok_or_else(|| format!("unknown extension '{}'", id))?
575 .clone();
576 handler.invoke_command(command, args, request_id, sink).await
577 }
578
579 pub async fn settings_editor_open(
580 &self,
581 id: &str,
582 category: &str,
583 field: &str,
584 ) -> Result<serde_json::Value, String> {
585 let handler = self
586 .extensions
587 .get(id)
588 .ok_or_else(|| format!("unknown extension '{}'", id))?
589 .clone();
590 handler.settings_editor_open(category, field).await
591 }
592
593 pub async fn settings_editor_key(
594 &self,
595 id: &str,
596 category: &str,
597 field: &str,
598 key: &str,
599 ) -> Result<serde_json::Value, String> {
600 let handler = self
601 .extensions
602 .get(id)
603 .ok_or_else(|| format!("unknown extension '{}'", id))?
604 .clone();
605 handler.settings_editor_key(category, field, key).await
606 }
607
608 pub async fn settings_editor_commit(
609 &self,
610 id: &str,
611 category: &str,
612 field: &str,
613 value: serde_json::Value,
614 ) -> Result<serde_json::Value, String> {
615 let handler = self
616 .extensions
617 .get(id)
618 .ok_or_else(|| format!("unknown extension '{}'", id))?
619 .clone();
620 handler.settings_editor_commit(category, field, value).await
621 }
622
623 pub fn plugin_infos(&self) -> Vec<(&str, &PluginInfo)> {
625 let mut entries: Vec<_> = self
626 .plugin_info
627 .iter()
628 .map(|(id, info)| (id.as_str(), info))
629 .collect();
630 entries.sort_by(|a, b| a.0.cmp(b.0));
631 entries
632 }
633
634 pub fn provider_summaries(&self) -> Vec<RegisteredProviderSummary> {
636 self.providers.summaries()
637 }
638
639 pub async fn capability_snapshots(&self) -> Vec<ExtensionCapabilitySnapshot> {
645 let mut handlers: Vec<(String, Arc<dyn ExtensionHandler>)> = self
646 .extensions
647 .iter()
648 .map(|(id, handler)| (id.clone(), handler.clone()))
649 .collect();
650 handlers.sort_by(|a, b| a.0.cmp(&b.0));
651
652 let provider_summaries = self.providers.summaries();
653 let plugin_id_lookup: std::collections::HashMap<String, String> = self
654 .providers
655 .list()
656 .into_iter()
657 .map(|p| (p.runtime_id.clone(), p.plugin_id.clone()))
658 .collect();
659
660 let mut out = Vec::with_capacity(handlers.len());
661 for (id, handler) in handlers {
662 let health = handler.health().await;
663 let restart_count = handler.restart_count().await;
664
665 let hook_pairs = self.hook_bus.subscriptions_for(&id).await;
666 let hooks: Vec<HookCapabilityEntry> = hook_pairs
667 .into_iter()
668 .map(|(kind, tool_filter)| HookCapabilityEntry {
669 kind: kind.as_str().to_string(),
670 tool_filter,
671 })
672 .collect();
673
674 let tools: Vec<ToolCapabilityEntry> = if let Some(tools) = &self.tools {
675 let registry = tools.read().await;
676 registry
677 .tool_names_for_extension(&id)
678 .into_iter()
679 .map(|name| ToolCapabilityEntry { name })
680 .collect()
681 } else {
682 Vec::new()
683 };
684
685 let providers: Vec<RegisteredProviderSummary> = provider_summaries
686 .iter()
687 .filter(|summary| {
688 plugin_id_lookup
689 .get(&summary.runtime_id)
690 .map(|p| p == &id)
691 .unwrap_or(false)
692 })
693 .cloned()
694 .collect();
695
696 let future: Vec<FutureCapabilityEntry> = self
697 .capabilities
698 .get(&id)
699 .map(|decls| {
700 decls
701 .iter()
702 .map(|d| FutureCapabilityEntry {
703 kind: d.kind.clone(),
704 name: d.name.clone(),
705 })
706 .collect()
707 })
708 .unwrap_or_default();
709
710 out.push(ExtensionCapabilitySnapshot {
711 id,
712 health,
713 restart_count,
714 hooks,
715 tools,
716 providers,
717 future,
718 });
719 }
720 out
721 }
722
723 pub fn provider_tool_use_runtime_ids(&self) -> Vec<String> {
726 let mut ids: Vec<String> = self
727 .providers
728 .list()
729 .into_iter()
730 .filter(|p| {
731 p.spec.models.iter().any(|m| {
732 m.capabilities
733 .get("tool_use")
734 .and_then(|v| v.as_bool())
735 .unwrap_or(false)
736 })
737 })
738 .map(|p| p.runtime_id.clone())
739 .collect();
740 ids.sort();
741 ids
742 }
743
744 pub fn provider_trust_view(&self) -> std::collections::BTreeMap<String, bool> {
750 let trust = match crate::extensions::trust::load_trust_state() {
751 Ok(t) => t,
752 Err(e) => {
753 tracing::warn!("trust.json corrupt or unreadable, failing closed (all providers disabled): {e}");
754 return self.providers
756 .list()
757 .into_iter()
758 .map(|p| (p.runtime_id.clone(), false))
759 .collect();
760 }
761 };
762 self.providers
763 .list()
764 .into_iter()
765 .map(|p| {
766 let enabled =
767 crate::extensions::trust::is_provider_enabled(&trust, &p.runtime_id);
768 (p.runtime_id.clone(), enabled)
769 })
770 .collect()
771 }
772
773 pub fn config_diagnostics(&self, id: &str) -> Option<ExtensionConfigDiagnostics> {
776 let manifest_config = self.manifest_configs.get(id)?;
777
778 let mut provider_required: Vec<(String, Vec<String>)> = Vec::new();
780 for provider in self.providers.list() {
781 if provider.plugin_id != id {
782 continue;
783 }
784 let required: Vec<String> = provider
785 .spec
786 .config_schema
787 .as_ref()
788 .and_then(|schema| schema.get("required"))
789 .and_then(Value::as_array)
790 .map(|arr| {
791 arr.iter()
792 .filter_map(|v| v.as_str().map(|s| s.to_string()))
793 .collect()
794 })
795 .unwrap_or_default();
796 provider_required.push((provider.provider_id.clone(), required));
797 }
798 provider_required.sort_by(|a, b| a.0.cmp(&b.0));
799
800 let env_lookup = |name: &str| std::env::var(name).ok();
801 let plugin_config_lookup = |key: &str| crate::extensions::config_store::read_plugin_config(id, key);
802 let legacy_config_lookup = |key: &str| crate::config::read_config_value(key);
803
804 Some(diagnose_extension_config(
805 id,
806 manifest_config,
807 &provider_required,
808 &env_lookup,
809 &plugin_config_lookup,
810 &legacy_config_lookup,
811 ))
812 }
813
814 pub fn all_config_diagnostics(&self) -> Vec<ExtensionConfigDiagnostics> {
816 let mut ids: Vec<&String> = self.manifest_configs.keys().collect();
817 ids.sort();
818 ids.into_iter()
819 .filter_map(|id| self.config_diagnostics(id))
820 .collect()
821 }
822
823 pub fn hook_bus(&self) -> &Arc<HookBus> {
825 &self.hook_bus
826 }
827
828 pub fn tools_shared(&self) -> Option<Arc<tokio::sync::RwLock<crate::ToolRegistry>>> {
830 self.tools.clone()
831 }
832
833 pub async fn discover_and_load(&mut self) -> (Vec<String>, Vec<ExtensionLoadFailure>) {
840 self.discover_and_load_with_progress(|_| {}).await
841 }
842
843 pub async fn discover_and_load_with_progress<F>(&mut self, mut progress: F) -> (Vec<String>, Vec<ExtensionLoadFailure>)
847 where
848 F: FnMut(crate::extensions::loader::ExtensionLoaderEvent),
849 {
850 let mut plugin_roots = vec![crate::config::base_dir().join("plugins")];
851 if !project_plugins_disabled() {
852 if let Ok(cwd) = std::env::current_dir() {
853 let project_plugins = cwd.join(".synaps").join("plugins");
854 if project_plugins != plugin_roots[0] {
855 plugin_roots.push(project_plugins);
856 }
857 }
858 }
859
860 let mut plugin_dirs: HashMap<String, PathBuf> = HashMap::new();
861 let mut failed: Vec<ExtensionLoadFailure> = Vec::new();
862
863 for plugins_dir in plugin_roots {
864 if !plugins_dir.exists() {
865 continue;
866 }
867
868 let entries = match std::fs::read_dir(&plugins_dir) {
869 Ok(e) => e,
870 Err(e) => {
871 tracing::warn!(path = %plugins_dir.display(), error = %e, "Failed to read plugins directory");
872 failed.push(ExtensionLoadFailure::new(
873 "plugins",
874 Some(plugins_dir.clone()),
875 format!("Failed to read plugins directory: {e}"),
876 "Check directory permissions and retry",
877 ));
878 continue;
879 }
880 };
881
882 for entry in entries.flatten() {
883 let plugin_name = entry.file_name().to_string_lossy().to_string();
884 plugin_dirs.insert(plugin_name, entry.path());
885 }
886 }
887
888 let mut plugin_dirs: Vec<(String, PathBuf)> = plugin_dirs.into_iter().collect();
889 plugin_dirs.sort_by(|a, b| a.0.cmp(&b.0));
890
891 let mut loaded = Vec::new();
892 let disabled_plugins = crate::config::load_config().disabled_plugins;
893 for (plugin_name, plugin_dir) in plugin_dirs {
894 if disabled_plugins.iter().any(|d| d == &plugin_name) {
895 tracing::debug!(plugin = %plugin_name, "Extension disabled via disabled_plugins config");
896 continue;
897 }
898 if let Some(message) = installed_plugin_setup_failure(&plugin_name) {
899 tracing::warn!(plugin = %plugin_name, error = %message, "Skipping extension with failed post-install setup");
900 failed.push(ExtensionLoadFailure::new(
901 plugin_name,
902 None,
903 format!("Post-install setup failed: {message}"),
904 "Open /plugins, reinstall or update the plugin after fixing setup; extension load is disabled until setup succeeds",
905 ));
906 continue;
907 }
908 let manifest_path = plugin_dir.join(".synaps-plugin").join("plugin.json");
909 if !manifest_path.exists() {
910 continue;
911 }
912
913 let content = match std::fs::read_to_string(&manifest_path) {
914 Ok(c) => c,
915 Err(e) => {
916 let reason = format!("Failed to read plugin manifest: {e}");
917 tracing::warn!(plugin = %plugin_name, manifest = %manifest_path.display(), error = %e, "Failed to read plugin manifest");
918 failed.push(ExtensionLoadFailure::new(
919 plugin_name,
920 Some(manifest_path),
921 reason,
922 "Check manifest file permissions, then run `plugin validate <plugin-dir>`",
923 ));
924 continue;
925 }
926 };
927
928 let json: serde_json::Value = match serde_json::from_str(&content) {
929 Ok(v) => v,
930 Err(e) => {
931 let reason = format!("Invalid plugin manifest JSON: {e}");
932 tracing::warn!(plugin = %plugin_name, manifest = %manifest_path.display(), error = %e, "Invalid plugin manifest JSON");
933 failed.push(ExtensionLoadFailure::new(
934 plugin_name,
935 Some(manifest_path),
936 reason,
937 "Fix JSON syntax, then run `plugin validate <plugin-dir>`",
938 ));
939 continue;
940 }
941 };
942
943 let ext_value = match json.get("extension") {
944 Some(v) => v.clone(),
945 None => continue,
946 };
947
948 let ext_manifest: ExtensionManifest = match serde_json::from_value(ext_value) {
949 Ok(m) => m,
950 Err(e) => {
951 let reason = format!("Failed to parse extension manifest: {e}");
952 tracing::warn!(plugin = %plugin_name, manifest = %manifest_path.display(), error = %e, "Failed to parse extension manifest");
953 failed.push(ExtensionLoadFailure::new(
954 plugin_name,
955 Some(manifest_path),
956 reason,
957 "Check the `extension` block shape against docs/extensions/contract.json, then run `plugin validate <plugin-dir>`",
958 ));
959 continue;
960 }
961 };
962
963 let command = if std::path::Path::new(&ext_manifest.command).is_absolute() {
964 ext_manifest.command.clone()
965 } else if !ext_manifest.command.contains(std::path::MAIN_SEPARATOR) && !ext_manifest.command.contains('/') {
966 ext_manifest.command.clone()
967 } else {
968 plugin_dir.join(&ext_manifest.command)
969 .to_string_lossy().to_string()
970 };
971
972 let args: Vec<String> = ext_manifest.args.iter().map(|arg| {
973 let arg_path = plugin_dir.join(arg);
974 if arg_path.exists() {
975 if let (Ok(canonical), Ok(plugin_canonical)) = (
976 arg_path.canonicalize(),
977 plugin_dir.canonicalize(),
978 ) {
979 if canonical.starts_with(&plugin_canonical) {
980 return canonical.to_string_lossy().to_string();
981 }
982 }
983 }
984 arg.clone()
985 }).collect();
986
987 let resolved = ExtensionManifest {
988 command,
989 args,
990 ..ext_manifest
991 };
992
993 match self.load_with_cwd(&plugin_name, &resolved, Some(plugin_dir.clone())).await {
994 Ok(()) => {
995 tracing::info!(plugin = %plugin_name, path = %plugin_dir.display(), "Extension loaded from plugins/");
996 loaded.push(plugin_name.clone());
997 progress(crate::extensions::loader::ExtensionLoaderEvent::Loaded {
998 plugin: plugin_name,
999 loaded: loaded.len(),
1000 failed: failed.len(),
1001 });
1002 }
1003 Err(e) => {
1004 tracing::warn!(plugin = %plugin_name, manifest = %manifest_path.display(), error = %e, "Failed to load extension");
1005 let setup_script = json
1006 .pointer("/extension/setup")
1007 .and_then(|v| v.as_str())
1008 .or_else(|| json.pointer("/provides/sidecar/setup").and_then(|v| v.as_str()));
1009 let hint = compute_extension_load_hint(&e, &plugin_dir, setup_script);
1010 let failure = ExtensionLoadFailure::new(
1011 plugin_name,
1012 Some(manifest_path),
1013 e,
1014 hint,
1015 );
1016 failed.push(failure.clone());
1017 progress(crate::extensions::loader::ExtensionLoaderEvent::Failed {
1018 failure,
1019 loaded: loaded.len(),
1020 failed: failed.len(),
1021 });
1022 }
1023 }
1024 }
1025
1026 (loaded, failed)
1027 }
1028}
1029
1030#[cfg(test)]
1031mod tests {
1032 use super::*;
1033
1034 #[tokio::test]
1035 async fn capability_snapshots_empty_when_no_extensions() {
1036 let bus = Arc::new(HookBus::new());
1037 let mgr = ExtensionManager::new(bus);
1038 assert!(mgr.capability_snapshots().await.is_empty());
1039 }
1040
1041 #[tokio::test]
1042 async fn capability_snapshot_lists_hooks_for_loaded_extension() {
1043 let bus = Arc::new(HookBus::new());
1044 let mut mgr = ExtensionManager::new(bus.clone());
1045 let manifest = ExtensionManifest {
1046 protocol_version: 1,
1047 runtime: crate::extensions::manifest::ExtensionRuntime::Process,
1048 command: "python3".to_string(),
1049 setup: None,
1050 prebuilt: ::std::collections::HashMap::new(),
1051 args: vec![
1052 "tests/fixtures/process_extension.py".to_string(),
1053 "normal".to_string(),
1054 "/tmp/synaps-capability-test.log".to_string(),
1055 ],
1056 permissions: vec!["tools.intercept".to_string()],
1057 hooks: vec![crate::extensions::manifest::HookSubscription {
1058 hook: "before_tool_call".to_string(),
1059 tool: Some("bash".to_string()),
1060 matcher: None,
1061 }],
1062 config: vec![],
1063 };
1064
1065 mgr.load("cap-snap", &manifest).await.unwrap();
1066
1067 let snaps = mgr.capability_snapshots().await;
1068 assert_eq!(snaps.len(), 1);
1069 let snap = &snaps[0];
1070 assert_eq!(snap.id, "cap-snap");
1071 assert_eq!(snap.hooks.len(), 1);
1072 assert_eq!(snap.hooks[0].kind, "before_tool_call");
1073 assert_eq!(snap.hooks[0].tool_filter.as_deref(), Some("bash"));
1074 assert!(snap.tools.is_empty());
1075 assert!(snap.providers.is_empty());
1076 assert!(snap.future.is_empty());
1077
1078 mgr.shutdown_all().await;
1079 }
1080
1081 #[tokio::test]
1082 async fn capability_snapshot_surfaces_seeded_capabilities() {
1083 let bus = Arc::new(HookBus::new());
1084 let mut mgr = ExtensionManager::new(bus.clone());
1085 let manifest = ExtensionManifest {
1086 protocol_version: 1,
1087 runtime: crate::extensions::manifest::ExtensionRuntime::Process,
1088 command: "python3".to_string(),
1089 setup: None,
1090 prebuilt: ::std::collections::HashMap::new(),
1091 args: vec![
1092 "tests/fixtures/process_extension.py".to_string(),
1093 "normal".to_string(),
1094 "/tmp/synaps-capability-snapshot-test.log".to_string(),
1095 ],
1096 permissions: vec!["tools.intercept".to_string()],
1097 hooks: vec![crate::extensions::manifest::HookSubscription {
1098 hook: "before_tool_call".to_string(),
1099 tool: Some("bash".to_string()),
1100 matcher: None,
1101 }],
1102 config: vec![],
1103 };
1104
1105 mgr.load("multi-cap", &manifest).await.unwrap();
1106
1107 mgr.test_seed_capabilities(
1111 "multi-cap",
1112 vec![
1113 crate::extensions::runtime::process::CapabilityDeclaration {
1114 kind: "capture".to_string(),
1115 name: "Local Sample STT".to_string(),
1116 permissions: vec!["audio.input".to_string()],
1117 params: serde_json::Value::Null,
1118 },
1119 crate::extensions::runtime::process::CapabilityDeclaration {
1120 kind: "ocr".to_string(),
1121 name: "Tesseract".to_string(),
1122 permissions: vec![],
1123 params: serde_json::Value::Null,
1124 },
1125 ],
1126 );
1127
1128 let snaps = mgr.capability_snapshots().await;
1129 let snap = snaps
1130 .iter()
1131 .find(|s| s.id == "multi-cap")
1132 .expect("multi-cap snapshot");
1133 assert_eq!(snap.future.len(), 2);
1134 let kinds: Vec<&str> = snap.future.iter().map(|e| e.kind.as_str()).collect();
1135 assert!(kinds.contains(&"capture"), "got kinds {:?}", kinds);
1136 assert!(kinds.contains(&"ocr"), "got kinds {:?}", kinds);
1137 let names: Vec<&str> = snap.future.iter().map(|e| e.name.as_str()).collect();
1138 assert!(names.contains(&"Local Sample STT"), "got {:?}", names);
1139 assert!(names.contains(&"Tesseract"), "got {:?}", names);
1140
1141 mgr.unload("multi-cap").await.unwrap();
1142 let snaps = mgr.capability_snapshots().await;
1143 assert!(snaps.iter().all(|s| s.id != "multi-cap"));
1144
1145 mgr.shutdown_all().await;
1146 }
1147
1148 #[tokio::test]
1149 async fn new_manager_has_no_extensions() {
1150 let bus = Arc::new(HookBus::new());
1151 let mgr = ExtensionManager::new(bus);
1152 assert_eq!(mgr.count(), 0);
1153 assert!(mgr.list().is_empty());
1154 }
1155
1156 #[tokio::test]
1157 async fn unload_nonexistent_returns_error() {
1158 let bus = Arc::new(HookBus::new());
1159 let mut mgr = ExtensionManager::new(bus);
1160 let result = mgr.unload("nope").await;
1161 assert!(result.is_err());
1162 }
1163
1164 #[tokio::test]
1165 async fn reload_unsubscribes_old_handler_before_loading_new_one() {
1166 let bus = Arc::new(HookBus::new());
1167 let mut mgr = ExtensionManager::new(bus.clone());
1168 let manifest = ExtensionManifest {
1169 protocol_version: 1,
1170 runtime: crate::extensions::manifest::ExtensionRuntime::Process,
1171 command: "python3".to_string(),
1172 setup: None,
1173 prebuilt: ::std::collections::HashMap::new(),
1174 args: vec!["tests/fixtures/process_extension.py".to_string(), "normal".to_string(), "/tmp/synaps-reload-test.log".to_string()],
1175 permissions: vec!["tools.intercept".to_string()],
1176 hooks: vec![crate::extensions::manifest::HookSubscription {
1177 hook: "before_tool_call".to_string(),
1178 tool: Some("bash".to_string()),
1179 matcher: None,
1180 }],
1181 config: vec![],
1182 };
1183
1184 mgr.load("reload-test", &manifest).await.unwrap();
1185 assert_eq!(bus.handler_count().await, 1);
1186
1187 mgr.reload("reload-test", &manifest, None).await.unwrap();
1188
1189 assert_eq!(mgr.count(), 1);
1190 assert_eq!(bus.handler_count().await, 1);
1191 mgr.shutdown_all().await;
1192 }
1193
1194 #[tokio::test]
1195 async fn reload_failure_leaves_previous_instance_unloaded() {
1196 let bus = Arc::new(HookBus::new());
1197 let mut mgr = ExtensionManager::new(bus.clone());
1198 let good = ExtensionManifest {
1199 protocol_version: 1,
1200 runtime: crate::extensions::manifest::ExtensionRuntime::Process,
1201 command: "python3".to_string(),
1202 setup: None,
1203 prebuilt: ::std::collections::HashMap::new(),
1204 args: vec!["tests/fixtures/process_extension.py".to_string(), "normal".to_string(), "/tmp/synaps-reload-failure-test.log".to_string()],
1205 permissions: vec!["tools.intercept".to_string()],
1206 hooks: vec![crate::extensions::manifest::HookSubscription {
1207 hook: "before_tool_call".to_string(),
1208 tool: Some("bash".to_string()),
1209 matcher: None,
1210 }],
1211 config: vec![],
1212 };
1213 let bad = ExtensionManifest {
1214 command: "/definitely/not/a/real/extension-binary".to_string(),
1215 setup: None,
1216 prebuilt: ::std::collections::HashMap::new(),
1217 ..good.clone()
1218 };
1219
1220 mgr.load("reload-failure-test", &good).await.unwrap();
1221 let err = mgr.reload("reload-failure-test", &bad, None).await.unwrap_err();
1222
1223 assert!(err.contains("Failed to spawn extension"), "{err}");
1224 assert_eq!(mgr.count(), 0);
1225 assert_eq!(bus.handler_count().await, 0);
1226 }
1227
1228 #[test]
1229 fn project_plugins_disable_env_parser_accepts_truthy_values() {
1230 for value in ["1", "true", "TRUE", "yes", "on"] {
1231 std::env::set_var("SYNAPS_DISABLE_PROJECT_PLUGINS", value);
1232 assert!(project_plugins_disabled());
1233 }
1234 for value in ["", "0", "false", "off", "no"] {
1235 std::env::set_var("SYNAPS_DISABLE_PROJECT_PLUGINS", value);
1236 assert!(!project_plugins_disabled());
1237 }
1238 std::env::remove_var("SYNAPS_DISABLE_PROJECT_PLUGINS");
1239 }
1240
1241 fn with_temp_base_dir<T>(path: &std::path::Path, f: impl FnOnce() -> T) -> T {
1242 let old_base_dir = std::env::var("SYNAPS_BASE_DIR").ok();
1243 crate::config::set_base_dir_for_tests(path.to_path_buf());
1244 let out = f();
1245 match old_base_dir {
1246 Some(old) => std::env::set_var("SYNAPS_BASE_DIR", old),
1247 None => std::env::remove_var("SYNAPS_BASE_DIR"),
1248 }
1249 out
1250 }
1251
1252 #[test]
1253 fn resolve_config_prefers_plugin_namespaced_config_before_legacy_global_key() {
1254 let dir = tempfile::tempdir().unwrap();
1255 with_temp_base_dir(dir.path(), || {
1256 crate::extensions::config_store::write_plugin_config("sample-sidecar", "backend", "cpu")
1257 .unwrap();
1258 crate::config::write_config_value("extension.sample-sidecar.backend", "auto").unwrap();
1259
1260 let resolved = ExtensionManager::resolve_config(
1261 "sample-sidecar",
1262 &[ExtensionConfigEntry {
1263 key: "backend".to_string(),
1264 value_type: None,
1265 description: None,
1266 required: true,
1267 default: None,
1268 secret_env: None,
1269 }],
1270 )
1271 .unwrap();
1272
1273 assert_eq!(resolved["backend"], serde_json::Value::String("cpu".to_string()));
1274 });
1275 }
1276
1277 #[test]
1278 fn resolve_config_keeps_legacy_global_extension_key_as_fallback() {
1279 let dir = tempfile::tempdir().unwrap();
1280 with_temp_base_dir(dir.path(), || {
1281 crate::config::write_config_value("extension.sample-sidecar.backend", "auto").unwrap();
1282
1283 let resolved = ExtensionManager::resolve_config(
1284 "sample-sidecar",
1285 &[ExtensionConfigEntry {
1286 key: "backend".to_string(),
1287 value_type: None,
1288 description: None,
1289 required: true,
1290 default: None,
1291 secret_env: None,
1292 }],
1293 )
1294 .unwrap();
1295
1296 assert_eq!(resolved["backend"], serde_json::Value::String("auto".to_string()));
1297 });
1298 }
1299
1300 #[tokio::test]
1301 async fn config_diagnostics_returns_none_for_unknown_extension() {
1302 let bus = Arc::new(HookBus::new());
1303 let mgr = ExtensionManager::new(bus);
1304 assert!(mgr.config_diagnostics("nope").is_none());
1305 assert!(mgr.all_config_diagnostics().is_empty());
1306 }
1307
1308 #[tokio::test]
1309 async fn config_diagnostics_reports_loaded_manifest_entries() {
1310 let bus = Arc::new(HookBus::new());
1311 let mut mgr = ExtensionManager::new(bus);
1312 let manifest = ExtensionManifest {
1313 protocol_version: 1,
1314 runtime: crate::extensions::manifest::ExtensionRuntime::Process,
1315 command: "python3".to_string(),
1316 setup: None,
1317 prebuilt: ::std::collections::HashMap::new(),
1318 args: vec![
1319 "tests/fixtures/process_extension.py".to_string(),
1320 "normal".to_string(),
1321 "/tmp/synaps-config-diag-test.log".to_string(),
1322 ],
1323 permissions: vec!["tools.intercept".to_string()],
1324 hooks: vec![crate::extensions::manifest::HookSubscription {
1325 hook: "before_tool_call".to_string(),
1326 tool: Some("bash".to_string()),
1327 matcher: None,
1328 }],
1329 config: vec![crate::extensions::manifest::ExtensionConfigEntry {
1330 key: "region".to_string(),
1331 value_type: None,
1332 description: Some("AWS region".to_string()),
1333 required: false,
1334 default: Some(serde_json::Value::String("us-east-1".to_string())),
1335 secret_env: None,
1336 }],
1337 };
1338
1339 mgr.load("config-diag-test", &manifest).await.unwrap();
1340
1341 let diag = mgr
1342 .config_diagnostics("config-diag-test")
1343 .expect("diagnostics should be available for loaded extension");
1344 assert_eq!(diag.extension_id, "config-diag-test");
1345 assert_eq!(diag.entries.len(), 1);
1346 assert_eq!(diag.entries[0].key, "region");
1347 assert!(diag.entries[0].has_value);
1348 assert!(diag.provider_missing.is_empty());
1349
1350 let all = mgr.all_config_diagnostics();
1351 assert_eq!(all.len(), 1);
1352 assert_eq!(all[0].extension_id, "config-diag-test");
1353
1354 mgr.shutdown_all().await;
1355 assert!(mgr.config_diagnostics("config-diag-test").is_none());
1357 }
1358
1359 #[tokio::test]
1360 async fn provider_trust_view_is_empty_for_no_providers() {
1361 let bus = Arc::new(HookBus::new());
1362 let mgr = ExtensionManager::new(bus);
1363 let view = mgr.provider_trust_view();
1364 assert!(view.is_empty());
1365 }
1366
1367 #[tokio::test]
1368 async fn provider_tool_use_runtime_ids_lists_only_tool_use_capable() {
1369 use crate::extensions::runtime::process::{RegisteredProviderModelSpec, RegisteredProviderSpec};
1370 let bus = Arc::new(HookBus::new());
1371 let mut mgr = ExtensionManager::new(bus);
1372 let tool_spec = RegisteredProviderSpec {
1374 id: "alpha".into(),
1375 display_name: "Alpha".into(),
1376 description: "tool-use".into(),
1377 models: vec![RegisteredProviderModelSpec {
1378 id: "m1".into(),
1379 display_name: None,
1380 capabilities: serde_json::json!({"tool_use": true}),
1381 context_window: None,
1382 }],
1383 config_schema: None,
1384 };
1385 let plain_spec = RegisteredProviderSpec {
1387 id: "beta".into(),
1388 display_name: "Beta".into(),
1389 description: "plain".into(),
1390 models: vec![RegisteredProviderModelSpec {
1391 id: "m1".into(),
1392 display_name: None,
1393 capabilities: serde_json::json!({"streaming": true}),
1394 context_window: None,
1395 }],
1396 config_schema: None,
1397 };
1398 mgr.providers.register("plug", tool_spec).unwrap();
1399 mgr.providers.register("plug", plain_spec).unwrap();
1400 let ids = mgr.provider_tool_use_runtime_ids();
1401 assert_eq!(ids, vec!["plug:alpha".to_string()]);
1402 }
1403
1404 #[test]
1407 fn hint_missing_binary_with_declared_setup_points_at_script() {
1408 let hint = compute_extension_load_hint(
1409 "Failed to spawn extension 'sample-sidecar': No such file or directory (os error 2)",
1410 std::path::Path::new("/home/u/.synaps-cli/plugins/sample-sidecar"),
1411 Some("scripts/setup.sh"),
1412 );
1413 assert!(
1414 hint.contains("Extension binary missing"),
1415 "missing-binary case should be flagged: {hint}"
1416 );
1417 assert!(
1418 hint.contains("/home/u/.synaps-cli/plugins/sample-sidecar"),
1419 "hint should include the plugin dir: {hint}"
1420 );
1421 assert!(
1422 hint.contains("setup=scripts/setup.sh"),
1423 "hint should show sanitized setup path without copy-paste shell command: {hint}"
1424 );
1425 }
1426
1427 #[test]
1428 fn hint_missing_binary_without_declared_setup_falls_back_to_generic() {
1429 let hint = compute_extension_load_hint(
1430 "Failed to spawn extension 'foo': No such file or directory (os error 2)",
1431 std::path::Path::new("/x/y"),
1432 None,
1433 );
1434 assert!(
1435 hint.contains("plugin validate"),
1436 "no setup declared → generic hint: {hint}"
1437 );
1438 assert!(
1439 !hint.contains("Extension binary missing"),
1440 "should not falsely promise a setup script: {hint}"
1441 );
1442 }
1443
1444 #[test]
1445 fn hint_other_error_with_declared_setup_falls_back_to_generic() {
1446 let hint = compute_extension_load_hint(
1447 "Extension 'foo' must subscribe to at least one hook or request a registration permission",
1448 std::path::Path::new("/x/y"),
1449 Some("scripts/setup.sh"),
1450 );
1451 assert!(hint.contains("plugin validate"), "got {hint}");
1455 assert!(!hint.contains("Extension binary missing"), "got {hint}");
1456 }
1457
1458 #[test]
1459 fn hint_recognises_os_error_2_format() {
1460 let hint = compute_extension_load_hint(
1463 "spawn failed (os error 2)",
1464 std::path::Path::new("/p"),
1465 Some("setup.sh"),
1466 );
1467 assert!(hint.contains("Extension binary missing"), "got {hint}");
1468 }
1469}