1use std::collections::HashMap;
4use std::path::PathBuf;
5use std::sync::Arc;
6
7use super::capability::{
8 ExtensionCapabilitySnapshot, FutureCapabilityEntry, HookCapabilityEntry, ToolCapabilityEntry,
9};
10use super::config::{diagnose_extension_config, ExtensionConfigDiagnostics};
11use super::context_provider::{
12 dormant_context_provider_descriptors, resolve_context_provider, ContextProviderId,
13 RegisteredContextProviderDescriptor, SharedContextProviderCatalog,
14};
15use super::hooks::HookBus;
16use super::info::PluginInfo;
17use super::manifest::{ExtensionConfigEntry, ExtensionManifest};
18use super::providers::{ProviderRegistry, RegisteredProvider, RegisteredProviderSummary};
19use super::runtime::process::ProcessExtension;
20use super::runtime::{ExtensionHandler, ExtensionHealth};
21use serde_json::{Map, Value};
22
23fn project_plugins_disabled() -> bool {
24 std::env::var("SYNAPS_DISABLE_PROJECT_PLUGINS")
25 .map(|value| {
26 let normalized = value.trim().to_ascii_lowercase();
27 matches!(normalized.as_str(), "1" | "true" | "yes" | "on")
28 })
29 .unwrap_or(false)
30}
31
32fn is_inert_plugin_dir_name(name: &str) -> bool {
44 let lower = name.to_ascii_lowercase();
45 if lower.contains(".backup.") {
47 return true;
48 }
49 const INERT_SUFFIXES: &[&str] = &[
50 ".backup",
51 ".update-backup", ".bak",
53 ".disabled",
54 ".old",
55 ".orig",
56 ".tmp",
57 ".temp",
58 ];
59 INERT_SUFFIXES.iter().any(|s| lower.ends_with(s))
60}
61
62fn installed_plugin_setup_failure_in(
63 state: &crate::skills::state::PluginsState,
64 plugin_name: &str,
65) -> Option<String> {
66 let plugin = state.installed.iter().find(|p| p.name == plugin_name)?;
67 match &plugin.setup_status {
68 crate::skills::state::SetupStatus::Failed { message, .. } => Some(message.clone()),
69 _ => None,
70 }
71}
72
73fn sanitize_hint_fragment(input: &str) -> String {
74 input
75 .chars()
76 .map(|ch| if ch.is_control() { '?' } else { ch })
77 .collect::<String>()
78}
79
80#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct ExtensionLoadFailure {
83 pub plugin: String,
84 pub manifest_path: Option<PathBuf>,
85 pub reason: String,
86 pub hint: String,
87}
88
89impl ExtensionLoadFailure {
90 fn new(
91 plugin: impl Into<String>,
92 manifest_path: Option<PathBuf>,
93 reason: impl Into<String>,
94 hint: impl Into<String>,
95 ) -> Self {
96 Self {
97 plugin: plugin.into(),
98 manifest_path,
99 reason: reason.into(),
100 hint: hint.into(),
101 }
102 }
103
104 pub fn concise_message(&self) -> String {
105 match &self.manifest_path {
106 Some(path) => format!(
107 "{} (manifest: {}; hint: {})",
108 self.reason,
109 path.display(),
110 self.hint
111 ),
112 None => format!("{} (hint: {})", self.reason, self.hint),
113 }
114 }
115}
116
117#[derive(Debug, Clone, PartialEq, Eq)]
119pub struct ExtensionStatus {
120 pub id: String,
121 pub health: ExtensionHealth,
122 pub restart_count: usize,
123}
124
125pub fn compute_extension_load_hint(
140 error: &str,
141 plugin_dir: &std::path::Path,
142 declared_setup: Option<&str>,
143) -> String {
144 let missing_binary =
145 error.contains("No such file or directory") || error.contains("os error 2");
146 match (missing_binary, declared_setup) {
147 (true, Some(setup)) => format!(
148 "Extension binary missing — this plugin ships source only. Run the setup script from the plugin directory, then reload. plugin_dir={}, setup={}",
149 sanitize_hint_fragment(&plugin_dir.display().to_string()),
150 sanitize_hint_fragment(setup),
151 ),
152 _ => "Run `plugin validate <plugin-dir>` and confirm the extension command is installed"
153 .to_string(),
154 }
155}
156
157pub struct ExtensionManager {
159 hook_bus: Arc<HookBus>,
161 tools: Option<Arc<tokio::sync::RwLock<crate::ToolRegistry>>>,
163 providers: ProviderRegistry,
165 extensions: HashMap<String, Arc<dyn ExtensionHandler>>,
167 manifest_configs: HashMap<String, Vec<ExtensionConfigEntry>>,
170 capabilities: HashMap<String, Vec<crate::extensions::runtime::process::CapabilityDeclaration>>,
174 plugin_info: HashMap<String, PluginInfo>,
176 manifest_theme_tokens: HashMap<String, std::collections::BTreeMap<String, String>>,
181 progressive_deferral: bool,
186 deferred_tool_only: SharedDeferredRecords,
192 extension_runtime: std::sync::OnceLock<Arc<crate::extensions::lease::ExtensionRuntimeManager>>,
196 context_providers: Vec<RegisteredContextProviderDescriptor>,
204 context_provider_catalog: SharedContextProviderCatalog,
211 host_project_root: std::sync::OnceLock<Option<String>>,
218}
219
220pub(crate) type SharedDeferredRecords =
223 Arc<std::sync::Mutex<HashMap<String, DeferredExtensionRecord>>>;
224
225#[derive(Clone)]
234pub(crate) struct DeferredExtensionRecord {
235 pub(crate) manifest: ExtensionManifest,
236 pub(crate) cwd: Option<std::path::PathBuf>,
237 pub(crate) config: Value,
238}
239
240impl ExtensionManager {
241 pub fn new(hook_bus: Arc<HookBus>) -> Self {
243 Self {
244 hook_bus,
245 tools: None,
246 providers: ProviderRegistry::new(),
247 extensions: HashMap::new(),
248 manifest_configs: HashMap::new(),
249 capabilities: HashMap::new(),
250 plugin_info: HashMap::new(),
251 manifest_theme_tokens: HashMap::new(),
252 progressive_deferral: false,
253 deferred_tool_only: Arc::new(std::sync::Mutex::new(HashMap::new())),
254 extension_runtime: std::sync::OnceLock::new(),
255 context_providers: Vec::new(),
256 context_provider_catalog: Arc::new(std::sync::Mutex::new(Vec::new())),
257 host_project_root: std::sync::OnceLock::new(),
258 }
259 }
260
261 pub fn new_with_tools(
263 hook_bus: Arc<HookBus>,
264 tools: Arc<tokio::sync::RwLock<crate::ToolRegistry>>,
265 ) -> Self {
266 Self {
267 hook_bus,
268 tools: Some(tools),
269 providers: ProviderRegistry::new(),
270 extensions: HashMap::new(),
271 manifest_configs: HashMap::new(),
272 capabilities: HashMap::new(),
273 plugin_info: HashMap::new(),
274 manifest_theme_tokens: HashMap::new(),
275 progressive_deferral: false,
276 deferred_tool_only: Arc::new(std::sync::Mutex::new(HashMap::new())),
277 extension_runtime: std::sync::OnceLock::new(),
278 context_providers: Vec::new(),
279 context_provider_catalog: Arc::new(std::sync::Mutex::new(Vec::new())),
280 host_project_root: std::sync::OnceLock::new(),
281 }
282 }
283
284 pub async fn load(&mut self, id: &str, manifest: &ExtensionManifest) -> Result<(), String> {
286 self.load_with_cwd(id, manifest, None).await
287 }
288
289 pub async fn load_with_cwd(
291 &mut self,
292 id: &str,
293 manifest: &ExtensionManifest,
294 cwd: Option<std::path::PathBuf>,
295 ) -> Result<(), String> {
296 let config = Self::resolve_config(id, &manifest.config, self.host_project_root())?;
297 self.load_with_cwd_and_config(id, manifest, cwd, config)
298 .await
299 }
300
301 pub fn set_progressive_deferral(&mut self, enabled: bool) {
305 self.progressive_deferral = enabled;
306 }
307
308 pub fn host_project_root(&self) -> Option<&str> {
315 self.host_project_root
316 .get_or_init(|| {
317 let cwd = std::env::current_dir().ok()?;
318 agent_core::memory::store::ProjectScope::discover(&cwd)
319 .ok()
320 .map(|scope| scope.root().display().to_string())
321 })
322 .as_deref()
323 }
324
325 pub fn is_deferred(&self, id: &str) -> bool {
331 self.deferred_records()
332 .lock()
333 .unwrap_or_else(std::sync::PoisonError::into_inner)
334 .contains_key(id)
335 }
336
337 pub fn is_deferred_tool_only(&self, id: &str) -> bool {
339 self.is_deferred(id)
340 }
341
342 pub fn deferred_tool_only_count(&self) -> usize {
345 self.deferred_records()
346 .lock()
347 .unwrap_or_else(std::sync::PoisonError::into_inner)
348 .len()
349 }
350
351 fn deferred_records(&self) -> &SharedDeferredRecords {
352 &self.deferred_tool_only
353 }
354
355 fn publish_context_providers(&self) {
361 let mut published = self
362 .context_provider_catalog
363 .lock()
364 .unwrap_or_else(std::sync::PoisonError::into_inner);
365 *published = self.context_providers.clone();
366 }
367
368 fn catalog_context_providers(&mut self, id: &str, manifest: &ExtensionManifest) {
374 let descriptors = dormant_context_provider_descriptors(id, manifest);
375 if descriptors.is_empty() {
376 return;
377 }
378 self.context_providers.extend(descriptors);
379 self.publish_context_providers();
380 }
381
382 fn uncatalog_context_providers(&mut self, id: &str) {
385 let before = self.context_providers.len();
386 self.context_providers
387 .retain(|descriptor| descriptor.extension_id() != id);
388 if self.context_providers.len() != before {
389 self.publish_context_providers();
390 }
391 }
392
393 pub fn extension_runtime(&self) -> Arc<crate::extensions::lease::ExtensionRuntimeManager> {
401 self.extension_runtime_with_idle(crate::extensions::lease::DEFAULT_IDLE_MAX)
402 }
403
404 pub fn extension_runtime_with_idle(
407 &self,
408 idle_max: std::time::Duration,
409 ) -> Arc<crate::extensions::lease::ExtensionRuntimeManager> {
410 Arc::clone(self.extension_runtime.get_or_init(|| {
411 Arc::new(crate::extensions::lease::ExtensionRuntimeManager::new(
412 Arc::clone(&self.deferred_tool_only),
413 Arc::clone(&self.context_provider_catalog),
414 idle_max,
415 ))
416 }))
417 }
418
419 #[cfg(test)]
422 pub(crate) fn deferred_launch_record(&self, id: &str) -> Option<DeferredExtensionRecord> {
423 self.deferred_records()
424 .lock()
425 .unwrap_or_else(std::sync::PoisonError::into_inner)
426 .get(id)
427 .cloned()
428 }
429
430 #[doc(hidden)]
435 pub fn tamper_deferred_manifest_permissions_for_tests(
436 &self,
437 id: &str,
438 permissions: Vec<String>,
439 ) -> bool {
440 let mut map = self
441 .deferred_tool_only
442 .lock()
443 .unwrap_or_else(std::sync::PoisonError::into_inner);
444 match map.get_mut(id) {
445 Some(record) => {
446 record.manifest.permissions = permissions;
447 true
448 }
449 None => false,
450 }
451 }
452
453 #[doc(hidden)]
457 pub fn tamper_deferred_config_for_tests(&self, id: &str, config: Value) -> bool {
458 let mut map = self
459 .deferred_tool_only
460 .lock()
461 .unwrap_or_else(std::sync::PoisonError::into_inner);
462 match map.get_mut(id) {
463 Some(record) => {
464 record.config = config;
465 true
466 }
467 None => false,
468 }
469 }
470
471 async fn load_with_cwd_and_config(
472 &mut self,
473 id: &str,
474 manifest: &ExtensionManifest,
475 cwd: Option<std::path::PathBuf>,
476 config: Value,
477 ) -> Result<(), String> {
478 if self.extensions.contains_key(id) || self.is_deferred_tool_only(id) {
480 return Err(format!("Extension '{}' is already loaded", id));
481 }
482
483 let validated = manifest.validate(id)?;
487 let permissions = validated.permissions;
488 let subscriptions = validated.subscriptions;
489
490 let class = crate::extensions::lifecycle::classify(manifest);
506 if self.progressive_deferral
507 && class != crate::extensions::lifecycle::ExtensionClass::LegacyEager
508 {
509 let declared_tool_names: Vec<String> = manifest
510 .deferred
511 .as_ref()
512 .map(|d| d.tools.iter().map(|t| format!("{id}:{}", t.name)).collect())
513 .unwrap_or_default();
514 let dormant = crate::extensions::lifecycle::dormant_extension_tools(id, manifest);
515 let tool_count = dormant.len();
516 if !dormant.is_empty() {
517 let Some(tools) = &self.tools else {
518 return Err(format!(
519 "Extension '{}' declares deferred tools but no tool registry is available",
520 id
521 ));
522 };
523 tools
524 .write()
525 .await
526 .try_register_batch(dormant)
527 .map_err(|e| {
528 format!("Extension '{}' deferred tool catalog rejected: {}", id, e)
529 })?;
530 }
531 self.deferred_tool_only
534 .lock()
535 .unwrap_or_else(std::sync::PoisonError::into_inner)
536 .insert(
537 id.to_string(),
538 DeferredExtensionRecord {
539 manifest: manifest.clone(),
540 cwd,
541 config: config.clone(),
542 },
543 );
544 macro_rules! unwind_deferred {
547 () => {{
548 self.deferred_tool_only
549 .lock()
550 .unwrap_or_else(std::sync::PoisonError::into_inner)
551 .remove(id);
552 self.providers.unregister_plugin(id);
553 if let Some(tools) = &self.tools {
554 let _ = tools.write().await.try_disable(&declared_tool_names);
555 }
556 self.hook_bus.unsubscribe_all(id).await;
557 }};
558 }
559 let lazy: Option<Arc<dyn ExtensionHandler>> = {
560 let needs_lazy = manifest
564 .deferred
565 .as_ref()
566 .map(|d| !d.providers.is_empty())
567 .unwrap_or(false)
568 || !manifest.hooks.is_empty();
569 needs_lazy.then(|| {
570 Arc::new(crate::extensions::lease::LazyExtensionHandler::new(
571 id,
572 self.extension_runtime(),
573 )) as Arc<dyn ExtensionHandler>
574 })
575 };
576 let declared_providers: Vec<_> = manifest
580 .deferred
581 .as_ref()
582 .map(|d| d.providers.clone())
583 .unwrap_or_default();
584 let mut registered_provider_ids = Vec::new();
585 for declared in &declared_providers {
586 let spec = declared.to_registered_spec();
587 if let Err(error) = Self::validate_provider_config_requirements(id, &spec, &config)
588 {
589 unwind_deferred!();
590 return Err(error);
591 }
592 let handler = lazy.clone().expect("providers imply a lazy handler");
593 match self
594 .providers
595 .register_with_handler(id, spec, Some(handler))
596 {
597 Ok(runtime_id) => registered_provider_ids.push(runtime_id),
598 Err(error) => {
599 unwind_deferred!();
600 return Err(error);
601 }
602 }
603 }
604 for runtime_id in ®istered_provider_ids {
606 if let Some(provider) = self.providers.get(runtime_id) {
607 let tool_use = provider.spec.models.iter().any(|m| {
608 m.capabilities
609 .get("tool_use")
610 .and_then(|v| v.as_bool())
611 .unwrap_or(false)
612 });
613 if tool_use {
614 tracing::warn!(
615 "Provider '{}' is tool-use capable: it can request Synaps tools through provider mediation. Use `/extensions trust disable {}` to block routing.",
616 runtime_id,
617 runtime_id,
618 );
619 }
620 }
621 }
622 for (kind, tool_filter, matcher) in subscriptions {
625 let handler = lazy.clone().expect("subscriptions imply a lazy handler");
626 if let Err(error) = self
627 .hook_bus
628 .subscribe(kind, handler, tool_filter, matcher, permissions.clone())
629 .await
630 {
631 unwind_deferred!();
632 return Err(error);
633 }
634 }
635 self.manifest_configs
636 .insert(id.to_string(), manifest.config.clone());
637 if !manifest.theme_tokens.is_empty() {
638 self.manifest_theme_tokens
639 .insert(id.to_string(), manifest.theme_tokens.clone());
640 }
641 self.catalog_context_providers(id, manifest);
646 tracing::info!(extension = %id, class = ?class, tools = tool_count,
647 providers = registered_provider_ids.len(), hooks = manifest.hooks.len(),
648 context_providers = self.context_providers.iter().filter(|d| d.extension_id() == id).count(),
649 "Extension deferred: passive surfaces registered, no process started");
650 return Ok(());
651 }
652
653 let process =
655 ProcessExtension::spawn_with_cwd(id, &manifest.command, &manifest.args, cwd.clone())
656 .await?;
657 process.set_permissions(permissions.clone()).await;
660 let capabilities = match process.initialize(cwd.clone(), config.clone()).await {
661 Ok(capabilities) => capabilities,
662 Err(error) => {
663 process.shutdown().await;
664 return Err(error);
665 }
666 };
667 let registered_tools = capabilities.tools;
668 let registered_providers = capabilities.providers;
669 let capability_declarations = capabilities.capabilities;
670 let should_probe_info = !registered_tools.is_empty()
671 || !registered_providers.is_empty()
672 || !capability_declarations.is_empty();
673 let handler: Arc<dyn ExtensionHandler> = Arc::new(process);
674 if !registered_tools.is_empty()
675 && !permissions.has(crate::extensions::permissions::Permission::ToolsRegister)
676 {
677 handler.shutdown().await;
678 return Err(format!(
679 "Extension '{}' registered tools but lacks permission 'tools.register'",
680 id
681 ));
682 }
683 if !registered_providers.is_empty()
684 && !permissions.has(crate::extensions::permissions::Permission::ProvidersRegister)
685 {
686 handler.shutdown().await;
687 return Err(format!(
688 "Extension '{}' registered providers but lacks permission 'providers.register'",
689 id
690 ));
691 }
692 for decl in &capability_declarations {
693 if let Err(err) =
694 crate::extensions::runtime::process::validate_capability(decl, &permissions)
695 {
696 handler.shutdown().await;
697 return Err(format!(
698 "Extension '{}' capability '{}' invalid: {}",
699 id, decl.kind, err
700 ));
701 }
702 }
703 if !registered_providers.is_empty() {
704 let mut registered_ids = Vec::new();
705 for provider in registered_providers {
706 if let Err(error) =
707 Self::validate_provider_config_requirements(id, &provider, &config)
708 {
709 self.providers.unregister_plugin(id);
710 handler.shutdown().await;
711 return Err(error);
712 }
713 match self
714 .providers
715 .register_with_handler(id, provider, Some(handler.clone()))
716 {
717 Ok(runtime_id) => registered_ids.push(runtime_id),
718 Err(error) => {
719 self.providers.unregister_plugin(id);
720 handler.shutdown().await;
721 return Err(error);
722 }
723 }
724 }
725 tracing::info!(extension = %id, providers = ?registered_ids, "Extension provider metadata registered");
726 for runtime_id in ®istered_ids {
728 if let Some(provider) = self.providers.get(runtime_id) {
729 let tool_use = provider.spec.models.iter().any(|m| {
730 m.capabilities
731 .get("tool_use")
732 .and_then(|v| v.as_bool())
733 .unwrap_or(false)
734 });
735 if tool_use {
736 tracing::warn!(
737 "Provider '{}' is tool-use capable: it can request Synaps tools through provider mediation. Use `/extensions trust disable {}` to block routing.",
738 runtime_id,
739 runtime_id,
740 );
741 }
742 }
743 }
744 }
745 if !registered_tools.is_empty() {
746 let Some(tools) = &self.tools else {
747 handler.shutdown().await;
748 return Err(format!(
749 "Extension '{}' registered tools but no tool registry is available",
750 id
751 ));
752 };
753 let mut registry = tools.write().await;
754 for spec in registered_tools {
755 let tool_name = spec.name.clone();
756 if let Err(e) = registry.try_register(Arc::new(crate::tools::ExtensionTool::new(
757 id,
758 spec,
759 handler.clone(),
760 ))) {
761 tracing::warn!(
762 extension = %id,
763 tool = %tool_name,
764 error = %e,
765 "Refusing to expose extension tool the capability catalog could not record"
766 );
767 }
768 }
769 }
770
771 let info = if should_probe_info {
776 match handler.get_info().await {
777 Ok(info) => Some(info),
778 Err(error) => {
779 if error.contains("method not found") || error.contains("unknown method") {
780 tracing::debug!(
781 extension = %id,
782 error = %error,
783 "Extension did not provide optional info.get metadata",
784 );
785 None
786 } else {
787 tracing::warn!(
788 extension = %id,
789 error = %error,
790 "Ignoring invalid optional info.get metadata",
791 );
792 None
793 }
794 }
795 }
796 } else {
797 None
798 };
799
800 for (kind, tool_filter, matcher) in subscriptions {
802 self.hook_bus
803 .subscribe(
804 kind,
805 handler.clone(),
806 tool_filter,
807 matcher,
808 permissions.clone(),
809 )
810 .await?;
811 }
812
813 self.extensions.insert(id.to_string(), handler);
814 self.manifest_configs
815 .insert(id.to_string(), manifest.config.clone());
816 if !manifest.theme_tokens.is_empty() {
817 self.manifest_theme_tokens
818 .insert(id.to_string(), manifest.theme_tokens.clone());
819 }
820 if !capability_declarations.is_empty() {
821 self.capabilities
822 .insert(id.to_string(), capability_declarations);
823 }
824 if let Some(info) = info {
825 self.plugin_info.insert(id.to_string(), info);
826 }
827 self.catalog_context_providers(id, manifest);
833 tracing::info!(extension = %id, hooks = manifest.hooks.len(), "Extension loaded");
834 Ok(())
835 }
836
837 fn validate_provider_config_requirements(
838 id: &str,
839 provider: &crate::extensions::runtime::process::RegisteredProviderSpec,
840 config: &Value,
841 ) -> Result<(), String> {
842 let Some(required) = provider
843 .config_schema
844 .as_ref()
845 .and_then(|schema| schema.get("required"))
846 .and_then(Value::as_array)
847 else {
848 return Ok(());
849 };
850 for key in required {
851 let Some(key) = key.as_str() else {
852 return Err(format!(
853 "Extension '{}' provider '{}' config_schema.required must contain only strings",
854 id, provider.id,
855 ));
856 };
857 let present = config
858 .as_object()
859 .map(|map| map.contains_key(key))
860 .unwrap_or(false);
861 if !present {
862 return Err(format!(
863 "Extension '{}' provider '{}' missing required provider config '{}'",
864 id, provider.id, key,
865 ));
866 }
867 }
868 Ok(())
869 }
870
871 fn resolve_config(
872 id: &str,
873 entries: &[ExtensionConfigEntry],
874 host_project_root: Option<&str>,
875 ) -> Result<Value, String> {
876 let mut out = Map::new();
877 for entry in entries {
878 let key = entry.key.trim();
879 if key.is_empty() {
880 return Err(format!("Extension '{}' declares config with empty key", id));
881 }
882 if key.contains('.') || key.contains('/') || key.contains(' ') {
883 return Err(format!(
884 "Extension '{}' config key '{}' must not contain dots, slashes, or spaces",
885 id, key,
886 ));
887 }
888 if let Some(host_key) = entry.host_context {
894 if entry.secret_env.is_some() {
895 return Err(format!(
896 "Extension '{}' config key '{}' declares both host_context and secret_env; host-context values never carry secrets",
897 id, key,
898 ));
899 }
900 match host_key {
901 crate::extensions::manifest::HostContextKey::ProjectRoot => {
902 if let Some(root) = host_project_root {
903 out.insert(key.to_string(), Value::String(root.to_string()));
904 } else if let Some(default) = &entry.default {
905 out.insert(key.to_string(), default.clone());
906 } else if entry.required {
907 return Err(format!(
908 "Extension '{}' requires host context 'project_root' but the host could not resolve a trusted project root",
909 id,
910 ));
911 }
912 }
913 }
914 continue;
915 }
916 let config_key = format!("extension.{}.{}", id, key);
917 if let Ok(value) = std::env::var(format!(
918 "SYNAPS_EXTENSION_{}_{}",
919 id.replace('-', "_").to_ascii_uppercase(),
920 key.replace('-', "_").to_ascii_uppercase()
921 )) {
922 out.insert(key.to_string(), Value::String(value));
923 continue;
924 }
925 if let Some(secret_env) = &entry.secret_env {
926 if let Ok(value) = std::env::var(secret_env) {
927 out.insert(key.to_string(), Value::String(value));
928 continue;
929 }
930 }
931 if let Some(value) = crate::extensions::config_store::read_plugin_config(id, key) {
932 out.insert(key.to_string(), Value::String(value));
933 continue;
934 }
935 if let Some(value) = crate::config::read_config_value(&config_key) {
936 out.insert(key.to_string(), Value::String(value));
937 continue;
938 }
939 if let Some(default) = &entry.default {
940 out.insert(key.to_string(), default.clone());
941 continue;
942 }
943 if entry.required {
944 let hint = if let Some(secret_env) = &entry.secret_env {
945 format!(
946 "set environment variable '{}' or config key '{}'",
947 secret_env, config_key
948 )
949 } else {
950 format!("set config key '{}'", config_key)
951 };
952 return Err(format!(
953 "Extension '{}' missing required config '{}': {}",
954 id, key, hint
955 ));
956 }
957 }
958 Ok(Value::Object(out))
959 }
960
961 #[cfg(test)]
965 pub(crate) fn test_seed_capabilities(
966 &mut self,
967 id: &str,
968 decls: Vec<crate::extensions::runtime::process::CapabilityDeclaration>,
969 ) {
970 self.capabilities.insert(id.to_string(), decls);
971 }
972
973 pub async fn unload(&mut self, id: &str) -> Result<(), String> {
981 let deferred = self
982 .deferred_tool_only
983 .lock()
984 .unwrap_or_else(std::sync::PoisonError::into_inner)
985 .remove(id);
986 if let Some(record) = deferred {
987 self.uncatalog_context_providers(id);
993 if let Some(runtime) = self.extension_runtime.get() {
994 let revoked = runtime.revoke_plugin_all_sessions(id);
995 if revoked > 0 {
996 tracing::info!(extension = %id, leases = revoked,
997 "Terminated live runtime leases of unloaded deferred extension");
998 }
999 }
1000 self.hook_bus.unsubscribe_all(id).await;
1001 self.providers.unregister_plugin(id);
1002 if let Some(tools) = &self.tools {
1003 let names: Vec<String> = record
1004 .manifest
1005 .deferred
1006 .as_ref()
1007 .map(|d| {
1008 d.tools
1009 .iter()
1010 .map(|t| format!("{}:{}", id, t.name))
1011 .collect()
1012 })
1013 .unwrap_or_default();
1014 if let Err(error) = tools.write().await.try_disable(&names) {
1015 return Err(format!(
1019 "Extension '{}' unloaded (record removed, leases revoked) but its dormant catalog batch could not be deregistered: {}",
1020 id, error
1021 ));
1022 }
1023 }
1024 self.manifest_configs.remove(id);
1025 self.manifest_theme_tokens.remove(id);
1026 tracing::info!(extension = %id, "Deferred extension unloaded");
1027 return Ok(());
1028 }
1029 let handler = self
1030 .extensions
1031 .remove(id)
1032 .ok_or_else(|| format!("Extension '{}' not found", id))?;
1033 self.uncatalog_context_providers(id);
1036
1037 self.hook_bus.unsubscribe_all(id).await;
1038 self.providers.unregister_plugin(id);
1039 self.manifest_configs.remove(id);
1040 self.capabilities.remove(id);
1041 self.plugin_info.remove(id);
1042 self.manifest_theme_tokens.remove(id);
1043 handler.shutdown().await;
1044
1045 tracing::info!(extension = %id, "Extension unloaded");
1046 Ok(())
1047 }
1048
1049 pub async fn reload(
1053 &mut self,
1054 id: &str,
1055 manifest: &ExtensionManifest,
1056 cwd: Option<std::path::PathBuf>,
1057 ) -> Result<(), String> {
1058 if self.extensions.contains_key(id) || self.is_deferred_tool_only(id) {
1059 self.unload(id).await?;
1060 }
1061 self.load_with_cwd(id, manifest, cwd).await
1062 }
1063
1064 pub async fn shutdown_all(&mut self) {
1066 let ids: Vec<String> = self.extensions.keys().cloned().collect();
1067 for id in ids {
1068 let _ = self.unload(&id).await;
1069 }
1070 let deferred_ids: Vec<String> = {
1073 let map = self
1074 .deferred_tool_only
1075 .lock()
1076 .unwrap_or_else(std::sync::PoisonError::into_inner);
1077 map.keys().cloned().collect()
1078 };
1079 for id in deferred_ids {
1080 let _ = self.unload(&id).await;
1081 }
1082 if let Some(runtime) = self.extension_runtime.get() {
1083 runtime.terminate_all();
1084 }
1085 }
1086
1087 pub fn shutdown_all_detached(
1093 manager: Arc<tokio::sync::RwLock<Self>>,
1094 ) -> tokio::task::JoinHandle<()> {
1095 tokio::spawn(async move {
1096 manager.write().await.shutdown_all().await;
1097 })
1098 }
1099
1100 pub fn list(&self) -> Vec<&str> {
1102 self.extensions.keys().map(|s| s.as_str()).collect()
1103 }
1104
1105 pub fn handlers(&self) -> Vec<(String, Arc<dyn super::runtime::ExtensionHandler>)> {
1109 let mut out: Vec<_> = self
1110 .extensions
1111 .iter()
1112 .map(|(id, h)| (id.clone(), Arc::clone(h)))
1113 .collect();
1114 out.sort_by(|a, b| a.0.cmp(&b.0));
1115 out
1116 }
1117
1118 pub fn count(&self) -> usize {
1120 self.extensions.len()
1121 }
1122
1123 pub async fn statuses(&self) -> Vec<ExtensionStatus> {
1125 let mut handlers: Vec<(String, Arc<dyn ExtensionHandler>)> = self
1126 .extensions
1127 .iter()
1128 .map(|(id, handler)| (id.clone(), handler.clone()))
1129 .collect();
1130 handlers.sort_by(|a, b| a.0.cmp(&b.0));
1131
1132 let mut statuses = Vec::with_capacity(handlers.len());
1133 for (id, handler) in handlers {
1134 statuses.push(ExtensionStatus {
1135 id,
1136 health: handler.health().await,
1137 restart_count: handler.restart_count().await,
1138 });
1139 }
1140 statuses
1141 }
1142
1143 pub fn providers(&self) -> Vec<&RegisteredProvider> {
1145 self.providers.list()
1146 }
1147
1148 pub fn provider(&self, runtime_id: &str) -> Option<&RegisteredProvider> {
1150 self.providers.get(runtime_id)
1151 }
1152
1153 pub fn context_providers(&self) -> Vec<&RegisteredContextProviderDescriptor> {
1159 let mut out: Vec<&RegisteredContextProviderDescriptor> =
1160 self.context_providers.iter().collect();
1161 out.sort_by_key(|descriptor| descriptor.runtime_address());
1162 out
1163 }
1164
1165 pub fn context_provider(
1172 &self,
1173 provider_id: &ContextProviderId,
1174 ) -> Option<&RegisteredContextProviderDescriptor> {
1175 resolve_context_provider(&self.context_providers, Some(provider_id)).ok()
1176 }
1177
1178 pub fn plugin_info(&self, id: &str) -> Option<&PluginInfo> {
1180 self.plugin_info.get(id)
1181 }
1182
1183 pub fn theme_tokens(&self) -> Vec<(String, std::collections::BTreeMap<String, String>)> {
1188 let mut out: Vec<_> = self
1189 .manifest_theme_tokens
1190 .iter()
1191 .map(|(id, tokens)| (id.clone(), tokens.clone()))
1192 .collect();
1193 out.sort_by(|a, b| a.0.cmp(&b.0));
1194 out
1195 }
1196
1197 fn user_action_handler(
1208 &self,
1209 id: &str,
1210 ) -> Result<Arc<dyn super::runtime::ExtensionHandler>, String> {
1211 if let Some(handler) = self.extensions.get(id) {
1212 return Ok(handler.clone());
1213 }
1214 if self.is_deferred(id) {
1215 return Ok(Arc::new(
1216 crate::extensions::lease::LazyExtensionHandler::new(id, self.extension_runtime()),
1217 ));
1218 }
1219 Err(format!("unknown extension '{}'", id))
1220 }
1221
1222 pub async fn sidecar_spawn_args(
1223 &self,
1224 id: &str,
1225 ) -> Result<crate::sidecar::spawn::SidecarSpawnArgs, String> {
1226 let handler = self.user_action_handler(id)?;
1227 handler.sidecar_spawn_args().await
1228 }
1229
1230 pub async fn invoke_command(
1241 &self,
1242 id: &str,
1243 command: &str,
1244 args: Vec<String>,
1245 request_id: &str,
1246 sink: crate::extensions::invoke_output::InvokeEventSink,
1247 ) -> Result<serde_json::Value, String> {
1248 let handler = self.user_action_handler(id)?;
1249 handler
1250 .invoke_command(command, args, request_id, sink)
1251 .await
1252 }
1253
1254 pub async fn invoke_command_collected(
1266 &self,
1267 id: &str,
1268 command: &str,
1269 args: Vec<String>,
1270 request_id: &str,
1271 ) -> (
1272 Result<serde_json::Value, String>,
1273 crate::extensions::invoke_output::InvokeOutputReport,
1274 ) {
1275 let (sink, collector) = crate::extensions::invoke_output::invoke_event_channel(
1276 crate::extensions::invoke_output::InvokeOutputBudget::default(),
1277 );
1278 let handler = match self.user_action_handler(id) {
1279 Ok(handler) => handler,
1280 Err(error) => {
1281 drop(sink);
1282 return (Err(error), collector.collect().await);
1283 }
1284 };
1285 let invoke = handler.invoke_command(command, args, request_id, sink);
1286 let (result, report) = tokio::join!(invoke, collector.collect());
1287 (result, report)
1288 }
1289
1290 pub async fn settings_editor_open(
1291 &self,
1292 id: &str,
1293 category: &str,
1294 field: &str,
1295 ) -> Result<serde_json::Value, String> {
1296 let handler = self.user_action_handler(id)?;
1297 handler.settings_editor_open(category, field).await
1298 }
1299
1300 pub async fn settings_editor_key(
1301 &self,
1302 id: &str,
1303 category: &str,
1304 field: &str,
1305 key: &str,
1306 ) -> Result<serde_json::Value, String> {
1307 let handler = self.user_action_handler(id)?;
1308 handler.settings_editor_key(category, field, key).await
1309 }
1310
1311 pub async fn settings_editor_commit(
1312 &self,
1313 id: &str,
1314 category: &str,
1315 field: &str,
1316 value: serde_json::Value,
1317 ) -> Result<serde_json::Value, String> {
1318 let handler = self.user_action_handler(id)?;
1319 handler.settings_editor_commit(category, field, value).await
1320 }
1321
1322 pub fn plugin_infos(&self) -> Vec<(&str, &PluginInfo)> {
1324 let mut entries: Vec<_> = self
1325 .plugin_info
1326 .iter()
1327 .map(|(id, info)| (id.as_str(), info))
1328 .collect();
1329 entries.sort_by(|a, b| a.0.cmp(b.0));
1330 entries
1331 }
1332
1333 pub fn provider_summaries(&self) -> Vec<RegisteredProviderSummary> {
1335 self.providers.summaries()
1336 }
1337
1338 pub async fn capability_snapshots(&self) -> Vec<ExtensionCapabilitySnapshot> {
1344 let mut handlers: Vec<(String, Arc<dyn ExtensionHandler>)> = self
1345 .extensions
1346 .iter()
1347 .map(|(id, handler)| (id.clone(), handler.clone()))
1348 .collect();
1349 handlers.sort_by(|a, b| a.0.cmp(&b.0));
1350
1351 let provider_summaries = self.providers.summaries();
1352 let plugin_id_lookup: std::collections::HashMap<String, String> = self
1353 .providers
1354 .list()
1355 .into_iter()
1356 .map(|p| (p.runtime_id.clone(), p.plugin_id.clone()))
1357 .collect();
1358
1359 let mut out = Vec::with_capacity(handlers.len());
1360 for (id, handler) in handlers {
1361 let health = handler.health().await;
1362 let restart_count = handler.restart_count().await;
1363
1364 let hook_pairs = self.hook_bus.subscriptions_for(&id).await;
1365 let hooks: Vec<HookCapabilityEntry> = hook_pairs
1366 .into_iter()
1367 .map(|(kind, tool_filter)| HookCapabilityEntry {
1368 kind: kind.as_str().to_string(),
1369 tool_filter,
1370 })
1371 .collect();
1372
1373 let tools: Vec<ToolCapabilityEntry> = if let Some(tools) = &self.tools {
1374 let registry = tools.read().await;
1375 registry
1376 .tool_names_for_extension(&id)
1377 .into_iter()
1378 .map(|name| ToolCapabilityEntry { name })
1379 .collect()
1380 } else {
1381 Vec::new()
1382 };
1383
1384 let providers: Vec<RegisteredProviderSummary> = provider_summaries
1385 .iter()
1386 .filter(|summary| {
1387 plugin_id_lookup
1388 .get(&summary.runtime_id)
1389 .map(|p| p == &id)
1390 .unwrap_or(false)
1391 })
1392 .cloned()
1393 .collect();
1394
1395 let future: Vec<FutureCapabilityEntry> = self
1396 .capabilities
1397 .get(&id)
1398 .map(|decls| {
1399 decls
1400 .iter()
1401 .map(|d| FutureCapabilityEntry {
1402 kind: d.kind.clone(),
1403 name: d.name.clone(),
1404 })
1405 .collect()
1406 })
1407 .unwrap_or_default();
1408
1409 out.push(ExtensionCapabilitySnapshot {
1410 id,
1411 health,
1412 restart_count,
1413 hooks,
1414 tools,
1415 providers,
1416 future,
1417 });
1418 }
1419 out
1420 }
1421
1422 pub fn provider_tool_use_runtime_ids(&self) -> Vec<String> {
1425 let mut ids: Vec<String> = self
1426 .providers
1427 .list()
1428 .into_iter()
1429 .filter(|p| {
1430 p.spec.models.iter().any(|m| {
1431 m.capabilities
1432 .get("tool_use")
1433 .and_then(|v| v.as_bool())
1434 .unwrap_or(false)
1435 })
1436 })
1437 .map(|p| p.runtime_id.clone())
1438 .collect();
1439 ids.sort();
1440 ids
1441 }
1442
1443 pub fn provider_trust_view(&self) -> std::collections::BTreeMap<String, bool> {
1449 let trust = match crate::extensions::trust::load_trust_state() {
1450 Ok(t) => t,
1451 Err(e) => {
1452 tracing::warn!("trust.json corrupt or unreadable, failing closed (all providers disabled): {e}");
1453 return self
1455 .providers
1456 .list()
1457 .into_iter()
1458 .map(|p| (p.runtime_id.clone(), false))
1459 .collect();
1460 }
1461 };
1462 self.providers
1463 .list()
1464 .into_iter()
1465 .map(|p| {
1466 let enabled = crate::extensions::trust::is_provider_enabled(&trust, &p.runtime_id);
1467 (p.runtime_id.clone(), enabled)
1468 })
1469 .collect()
1470 }
1471
1472 pub fn config_diagnostics(&self, id: &str) -> Option<ExtensionConfigDiagnostics> {
1475 let manifest_config = self.manifest_configs.get(id)?;
1476
1477 let mut provider_required: Vec<(String, Vec<String>)> = Vec::new();
1479 for provider in self.providers.list() {
1480 if provider.plugin_id != id {
1481 continue;
1482 }
1483 let required: Vec<String> = provider
1484 .spec
1485 .config_schema
1486 .as_ref()
1487 .and_then(|schema| schema.get("required"))
1488 .and_then(Value::as_array)
1489 .map(|arr| {
1490 arr.iter()
1491 .filter_map(|v| v.as_str().map(|s| s.to_string()))
1492 .collect()
1493 })
1494 .unwrap_or_default();
1495 provider_required.push((provider.provider_id.clone(), required));
1496 }
1497 provider_required.sort_by(|a, b| a.0.cmp(&b.0));
1498
1499 let env_lookup = |name: &str| std::env::var(name).ok();
1500 let plugin_config_lookup =
1501 |key: &str| crate::extensions::config_store::read_plugin_config(id, key);
1502 let legacy_config_lookup = |key: &str| crate::config::read_config_value(key);
1503
1504 Some(diagnose_extension_config(
1505 id,
1506 manifest_config,
1507 &provider_required,
1508 &env_lookup,
1509 &plugin_config_lookup,
1510 &legacy_config_lookup,
1511 ))
1512 }
1513
1514 pub fn all_config_diagnostics(&self) -> Vec<ExtensionConfigDiagnostics> {
1516 let mut ids: Vec<&String> = self.manifest_configs.keys().collect();
1517 ids.sort();
1518 ids.into_iter()
1519 .filter_map(|id| self.config_diagnostics(id))
1520 .collect()
1521 }
1522
1523 pub fn hook_bus(&self) -> &Arc<HookBus> {
1525 &self.hook_bus
1526 }
1527
1528 pub fn tools_shared(&self) -> Option<Arc<tokio::sync::RwLock<crate::ToolRegistry>>> {
1530 self.tools.clone()
1531 }
1532
1533 #[cfg(test)]
1536 pub(crate) fn register_provider_handler_for_tests(
1537 &mut self,
1538 plugin_id: &str,
1539 spec: crate::extensions::runtime::process::RegisteredProviderSpec,
1540 handler: Arc<dyn ExtensionHandler>,
1541 ) -> Result<String, String> {
1542 self.providers
1543 .register_with_handler(plugin_id, spec, Some(handler))
1544 }
1545
1546 pub async fn discover_and_load(&mut self) -> (Vec<String>, Vec<ExtensionLoadFailure>) {
1553 self.discover_and_load_with_progress(|_| {}).await
1554 }
1555
1556 pub async fn discover_and_load_with_progress<F>(
1560 &mut self,
1561 mut progress: F,
1562 ) -> (Vec<String>, Vec<ExtensionLoadFailure>)
1563 where
1564 F: FnMut(crate::extensions::loader::ExtensionLoaderEvent),
1565 {
1566 let mut plugin_roots = vec![crate::config::base_dir().join("plugins")];
1567 if !project_plugins_disabled() {
1568 if let Ok(cwd) = std::env::current_dir() {
1569 let project_plugins = cwd.join(".synaps").join("plugins");
1570 if project_plugins != plugin_roots[0] {
1571 plugin_roots.push(project_plugins);
1572 }
1573 }
1574 }
1575
1576 let mut plugin_dirs: HashMap<String, PathBuf> = HashMap::new();
1577 let mut failed: Vec<ExtensionLoadFailure> = Vec::new();
1578
1579 for plugins_dir in plugin_roots {
1580 if !plugins_dir.exists() {
1581 continue;
1582 }
1583
1584 let entries = match std::fs::read_dir(&plugins_dir) {
1585 Ok(e) => e,
1586 Err(e) => {
1587 tracing::warn!(path = %plugins_dir.display(), error = %e, "Failed to read plugins directory");
1588 failed.push(ExtensionLoadFailure::new(
1589 "plugins",
1590 Some(plugins_dir.clone()),
1591 format!("Failed to read plugins directory: {e}"),
1592 "Check directory permissions and retry",
1593 ));
1594 continue;
1595 }
1596 };
1597
1598 for entry in entries.flatten() {
1599 let plugin_name = entry.file_name().to_string_lossy().to_string();
1600 if is_inert_plugin_dir_name(&plugin_name) {
1601 tracing::debug!(
1602 plugin = %plugin_name,
1603 path = %entry.path().display(),
1604 "Skipping backup/disabled/temp plugin directory"
1605 );
1606 continue;
1607 }
1608 plugin_dirs.insert(plugin_name, entry.path());
1609 }
1610 }
1611
1612 let mut plugin_dirs: Vec<(String, PathBuf)> = plugin_dirs.into_iter().collect();
1613 plugin_dirs.sort_by(|a, b| a.0.cmp(&b.0));
1614
1615 let mut loaded = Vec::new();
1616 let disabled_plugins = crate::config::load_config().disabled_plugins;
1617 let plugins_state = {
1620 let state_path = crate::skills::state::PluginsState::default_path();
1621 crate::skills::state::PluginsState::load_from(&state_path).unwrap_or_default()
1622 };
1623 for (plugin_name, plugin_dir) in plugin_dirs {
1624 if disabled_plugins.iter().any(|d| d == &plugin_name) {
1625 tracing::debug!(plugin = %plugin_name, "Extension disabled via disabled_plugins config");
1626 continue;
1627 }
1628 if let Some(message) = installed_plugin_setup_failure_in(&plugins_state, &plugin_name) {
1629 tracing::warn!(plugin = %plugin_name, error = %message, "Skipping extension with failed post-install setup");
1630 failed.push(ExtensionLoadFailure::new(
1631 plugin_name,
1632 None,
1633 format!("Post-install setup failed: {message}"),
1634 "Open /plugins, reinstall or update the plugin after fixing setup; extension load is disabled until setup succeeds",
1635 ));
1636 continue;
1637 }
1638 let manifest_path = plugin_dir.join(".synaps-plugin").join("plugin.json");
1639 if !manifest_path.exists() {
1640 continue;
1641 }
1642
1643 let content = match std::fs::read_to_string(&manifest_path) {
1644 Ok(c) => c,
1645 Err(e) => {
1646 let reason = format!("Failed to read plugin manifest: {e}");
1647 tracing::warn!(plugin = %plugin_name, manifest = %manifest_path.display(), error = %e, "Failed to read plugin manifest");
1648 failed.push(ExtensionLoadFailure::new(
1649 plugin_name,
1650 Some(manifest_path),
1651 reason,
1652 "Check manifest file permissions, then run `plugin validate <plugin-dir>`",
1653 ));
1654 continue;
1655 }
1656 };
1657
1658 let json: serde_json::Value = match serde_json::from_str(&content) {
1659 Ok(v) => v,
1660 Err(e) => {
1661 let reason = format!("Invalid plugin manifest JSON: {e}");
1662 tracing::warn!(plugin = %plugin_name, manifest = %manifest_path.display(), error = %e, "Invalid plugin manifest JSON");
1663 failed.push(ExtensionLoadFailure::new(
1664 plugin_name,
1665 Some(manifest_path),
1666 reason,
1667 "Fix JSON syntax, then run `plugin validate <plugin-dir>`",
1668 ));
1669 continue;
1670 }
1671 };
1672
1673 let ext_value = match json.get("extension") {
1674 Some(v) => v.clone(),
1675 None => continue,
1676 };
1677
1678 let ext_manifest: ExtensionManifest = match serde_json::from_value(ext_value) {
1679 Ok(m) => m,
1680 Err(e) => {
1681 let reason = format!("Failed to parse extension manifest: {e}");
1682 tracing::warn!(plugin = %plugin_name, manifest = %manifest_path.display(), error = %e, "Failed to parse extension manifest");
1683 failed.push(ExtensionLoadFailure::new(
1684 plugin_name,
1685 Some(manifest_path),
1686 reason,
1687 "Check the `extension` block shape against docs/extensions/contract.json, then run `plugin validate <plugin-dir>`",
1688 ));
1689 continue;
1690 }
1691 };
1692
1693 #[allow(clippy::if_same_then_else)]
1694 let command = if std::path::Path::new(&ext_manifest.command).is_absolute() {
1695 ext_manifest.command.clone()
1696 } else if !ext_manifest.command.contains(std::path::MAIN_SEPARATOR)
1697 && !ext_manifest.command.contains('/')
1698 {
1699 ext_manifest.command.clone()
1700 } else {
1701 plugin_dir
1702 .join(&ext_manifest.command)
1703 .to_string_lossy()
1704 .to_string()
1705 };
1706
1707 let args: Vec<String> = ext_manifest
1708 .args
1709 .iter()
1710 .map(|arg| {
1711 let arg_path = plugin_dir.join(arg);
1712 if arg_path.exists() {
1713 if let (Ok(canonical), Ok(plugin_canonical)) =
1714 (arg_path.canonicalize(), plugin_dir.canonicalize())
1715 {
1716 if canonical.starts_with(&plugin_canonical) {
1717 return canonical.to_string_lossy().to_string();
1718 }
1719 }
1720 }
1721 arg.clone()
1722 })
1723 .collect();
1724
1725 let resolved = ExtensionManifest {
1726 theme_tokens: Default::default(),
1727 command,
1733 args,
1734 ..ext_manifest
1735 };
1736
1737 match self
1738 .load_with_cwd(&plugin_name, &resolved, Some(plugin_dir.clone()))
1739 .await
1740 {
1741 Ok(()) => {
1742 tracing::info!(plugin = %plugin_name, path = %plugin_dir.display(), "Extension loaded from plugins/");
1743 loaded.push(plugin_name.clone());
1744 progress(crate::extensions::loader::ExtensionLoaderEvent::Loaded {
1745 plugin: plugin_name,
1746 loaded: loaded.len(),
1747 failed: failed.len(),
1748 });
1749 }
1750 Err(e) => {
1751 tracing::warn!(plugin = %plugin_name, manifest = %manifest_path.display(), error = %e, "Failed to load extension");
1752 let setup_script = json
1753 .pointer("/extension/setup")
1754 .and_then(|v| v.as_str())
1755 .or_else(|| {
1756 json.pointer("/provides/sidecar/setup")
1757 .and_then(|v| v.as_str())
1758 });
1759 let hint = compute_extension_load_hint(&e, &plugin_dir, setup_script);
1760 let failure =
1761 ExtensionLoadFailure::new(plugin_name, Some(manifest_path), e, hint);
1762 failed.push(failure.clone());
1763 progress(crate::extensions::loader::ExtensionLoaderEvent::Failed {
1764 failure,
1765 loaded: loaded.len(),
1766 failed: failed.len(),
1767 });
1768 }
1769 }
1770 }
1771
1772 (loaded, failed)
1773 }
1774}
1775
1776#[cfg(test)]
1777mod invoke_command_collected_tests {
1778 use super::*;
1784 use crate::extensions::commands::CommandOutputEvent;
1785 use crate::extensions::invoke_output::INVOKE_RETAINED_BYTE_BUDGET;
1786 use crate::extensions::runtime::InvokeCommandEvent;
1787
1788 struct FloodingHandler;
1789
1790 #[async_trait::async_trait]
1791 impl super::ExtensionHandler for FloodingHandler {
1792 fn id(&self) -> &str {
1793 "flooding-fake"
1794 }
1795 async fn handle(
1796 &self,
1797 _event: &crate::extensions::hooks::events::HookEvent,
1798 ) -> crate::extensions::hooks::events::HookResult {
1799 Default::default()
1800 }
1801 async fn shutdown(&self) {}
1802
1803 async fn invoke_command(
1804 &self,
1805 _command: &str,
1806 _args: Vec<String>,
1807 _request_id: &str,
1808 sink: crate::extensions::invoke_output::InvokeEventSink,
1809 ) -> Result<Value, String> {
1810 for _ in 0..2048 {
1814 let event = InvokeCommandEvent::Output(CommandOutputEvent::Text {
1815 content: "f".repeat(4096),
1816 });
1817 if sink.send(event).await.is_err() {
1818 return Err("collector vanished".to_string());
1819 }
1820 }
1821 let _ = sink
1822 .send(InvokeCommandEvent::Output(CommandOutputEvent::Done))
1823 .await;
1824 Ok(serde_json::json!({"status": "flooded"}))
1825 }
1826 }
1827
1828 #[tokio::test]
1829 async fn collected_invoke_bounds_in_process_flood_without_deadlock() {
1830 let bus = Arc::new(HookBus::new());
1831 let mut mgr = ExtensionManager::new(bus);
1832 mgr.extensions
1833 .insert("flooding-fake".to_string(), Arc::new(FloodingHandler));
1834
1835 let (result, report) = tokio::time::timeout(
1836 std::time::Duration::from_secs(30),
1837 mgr.invoke_command_collected("flooding-fake", "any", vec![], "req-1"),
1838 )
1839 .await
1840 .expect("bounded sink + concurrent collector must not deadlock");
1841
1842 assert_eq!(result.expect("invoke ok")["status"], "flooded");
1843 let c = &report.counters;
1844 assert_eq!(c.produced_events, 2049);
1845 assert_eq!(c.produced_payload_bytes, 2048 * 4096);
1846 assert_eq!(c.consumed_events, c.produced_events);
1847 assert_eq!(c.consumed_payload_bytes, c.produced_payload_bytes);
1848 assert_eq!(c.retained_payload_bytes, INVOKE_RETAINED_BYTE_BUDGET as u64);
1849 assert!(c.saw_done);
1850 assert_eq!(
1851 c.consumed_payload_bytes,
1852 c.retained_payload_bytes + c.truncated_payload_bytes + c.dropped_payload_bytes,
1853 );
1854 assert_eq!(c.consumed_events, c.retained_events + c.dropped_events);
1855 }
1856
1857 #[tokio::test]
1858 async fn collected_invoke_reports_unknown_extension_with_empty_report() {
1859 let bus = Arc::new(HookBus::new());
1860 let mgr = ExtensionManager::new(bus);
1861 let (result, report) = mgr
1862 .invoke_command_collected("nope", "any", vec![], "req-1")
1863 .await;
1864 assert!(result
1865 .expect_err("unknown id")
1866 .contains("unknown extension"));
1867 assert!(report.events.is_empty());
1868 assert_eq!(report.counters.produced_events, 0);
1869 }
1870}
1871
1872#[cfg(test)]
1873mod tests {
1874 use super::*;
1875
1876 fn context_provider_manifest(provider_id: &str) -> ExtensionManifest {
1879 serde_json::from_value(serde_json::json!({
1880 "runtime": "process",
1881 "command": "/bin/false",
1882 "permissions": ["context_providers.register"],
1883 "deferred": {
1884 "context_providers": [{
1885 "id": provider_id,
1886 "capability": "project-memory",
1887 "description": "test context provider",
1888 "schema_version": 1
1889 }]
1890 }
1891 }))
1892 .unwrap()
1893 }
1894
1895 #[tokio::test]
1900 async fn context_provider_catalog_lists_resolves_and_unloads_exactly() {
1901 let mut mgr = ExtensionManager::new(Arc::new(HookBus::new()));
1902 mgr.set_progressive_deferral(true);
1903 mgr.load("mem-a", &context_provider_manifest("alpha"))
1904 .await
1905 .unwrap();
1906 mgr.load("mem-b", &context_provider_manifest("beta"))
1907 .await
1908 .unwrap();
1909
1910 let listed: Vec<String> = mgr
1912 .context_providers()
1913 .iter()
1914 .map(|d| d.runtime_address())
1915 .collect();
1916 assert_eq!(
1917 listed,
1918 vec![
1919 "extension:mem-a:alpha".to_string(),
1920 "extension:mem-b:beta".to_string(),
1921 ]
1922 );
1923 let alpha = super::super::context_provider::ContextProviderId::parse("alpha").unwrap();
1924 assert_eq!(
1925 mgr.context_provider(&alpha).map(|d| d.extension_id()),
1926 Some("mem-a")
1927 );
1928 let unknown = super::super::context_provider::ContextProviderId::parse("nope").unwrap();
1929 assert!(mgr.context_provider(&unknown).is_none(), "fail closed");
1930
1931 let runtime = mgr.extension_runtime();
1933 assert_eq!(
1934 runtime
1935 .resolve_context_provider(Some(&alpha))
1936 .map(|d| d.runtime_address()),
1937 Ok("extension:mem-a:alpha".to_string())
1938 );
1939
1940 mgr.load("mem-c", &context_provider_manifest("alpha"))
1943 .await
1944 .unwrap();
1945 assert!(mgr.context_provider(&alpha).is_none(), "ambiguous → None");
1946 assert_eq!(
1947 runtime.resolve_context_provider(Some(&alpha)),
1948 Err(super::super::context_provider::ContextProviderLookupError::Ambiguous)
1949 );
1950
1951 mgr.unload("mem-c").await.unwrap();
1953 assert_eq!(
1954 mgr.context_provider(&alpha).map(|d| d.extension_id()),
1955 Some("mem-a")
1956 );
1957 mgr.unload("mem-a").await.unwrap();
1958 assert_eq!(
1959 runtime.resolve_context_provider(Some(&alpha)),
1960 Err(super::super::context_provider::ContextProviderLookupError::NotRegistered)
1961 );
1962 assert_eq!(mgr.context_providers().len(), 1, "mem-b remains");
1963
1964 assert_eq!(mgr.count(), 0, "no live extension handlers");
1966 assert_eq!(runtime.lease_count(), 0, "no runtime leases");
1967 }
1968
1969 #[tokio::test]
1970 async fn capability_snapshots_empty_when_no_extensions() {
1971 let bus = Arc::new(HookBus::new());
1972 let mgr = ExtensionManager::new(bus);
1973 assert!(mgr.capability_snapshots().await.is_empty());
1974 }
1975
1976 #[tokio::test]
1983 async fn deferred_launch_record_never_leaks_secret_config_through_diagnostics() {
1984 const SECRET: &str = "task20-A3-sentinel-5f2c9d";
1985 const VAR: &str = "SYNAPS_TEST_T20_A3_SECRET";
1986 std::env::set_var(VAR, SECRET);
1987
1988 let manifest: ExtensionManifest = serde_json::from_value(serde_json::json!({
1989 "runtime": "process",
1990 "command": "/bin/false",
1991 "permissions": ["tools.register"],
1992 "config": [{
1993 "key": "api_key",
1994 "type": "string",
1995 "required": true,
1996 "secret_env": VAR
1997 }],
1998 "deferred": {
1999 "tools": [{
2000 "name": "search",
2001 "description": "deferred search",
2002 "input_schema": {"type": "object"}
2003 }]
2004 }
2005 }))
2006 .unwrap();
2007
2008 let registry = Arc::new(tokio::sync::RwLock::new(crate::ToolRegistry::new()));
2009 let mut mgr = ExtensionManager::new_with_tools(Arc::new(HookBus::new()), registry);
2010 mgr.set_progressive_deferral(true);
2011 mgr.load("secretive", &manifest).await.unwrap();
2012
2013 assert!(mgr.is_deferred_tool_only("secretive"));
2015 assert_eq!(mgr.deferred_tool_only_count(), 1);
2016 let record = mgr.deferred_launch_record("secretive").expect("retained");
2018 assert_eq!(record.config["api_key"], serde_json::json!(SECRET));
2019
2020 let mut rendered = String::new();
2022 rendered.push_str(&format!("{:?}", mgr.statuses().await));
2023 rendered.push_str(&format!("{:?}", mgr.config_diagnostics("secretive")));
2024 rendered.push_str(&format!("{:?}", mgr.all_config_diagnostics()));
2025 rendered.push_str(&format!("{:?}", mgr.capability_snapshots().await));
2026 rendered.push_str(&format!("{:?}", mgr.plugin_infos()));
2027 rendered.push_str(&format!("{:?}", mgr.list()));
2028 assert!(
2029 !rendered.contains(SECRET),
2030 "resolved secret config leaked through a public diagnostic surface"
2031 );
2032
2033 std::env::remove_var(VAR);
2034 }
2035
2036 #[tokio::test]
2037 async fn capability_snapshot_lists_hooks_for_loaded_extension() {
2038 let bus = Arc::new(HookBus::new());
2039 let mut mgr = ExtensionManager::new(bus.clone());
2040 let manifest = ExtensionManifest {
2041 theme_tokens: Default::default(),
2042 deferred: None,
2043 protocol_version: 1,
2044 runtime: crate::extensions::manifest::ExtensionRuntime::Process,
2045 command: "python3".to_string(),
2046 setup: None,
2047 prebuilt: ::std::collections::HashMap::new(),
2048 args: vec![
2049 "tests/fixtures/process_extension.py".to_string(),
2050 "normal".to_string(),
2051 "/tmp/synaps-capability-test.log".to_string(),
2052 ],
2053 permissions: vec!["tools.intercept".to_string()],
2054 hooks: vec![crate::extensions::manifest::HookSubscription {
2055 hook: "before_tool_call".to_string(),
2056 tool: Some("bash".to_string()),
2057 matcher: None,
2058 }],
2059 config: vec![],
2060 };
2061
2062 mgr.load("cap-snap", &manifest).await.unwrap();
2063
2064 let snaps = mgr.capability_snapshots().await;
2065 assert_eq!(snaps.len(), 1);
2066 let snap = &snaps[0];
2067 assert_eq!(snap.id, "cap-snap");
2068 assert_eq!(snap.hooks.len(), 1);
2069 assert_eq!(snap.hooks[0].kind, "before_tool_call");
2070 assert_eq!(snap.hooks[0].tool_filter.as_deref(), Some("bash"));
2071 assert!(snap.tools.is_empty());
2072 assert!(snap.providers.is_empty());
2073 assert!(snap.future.is_empty());
2074
2075 mgr.shutdown_all().await;
2076 }
2077
2078 #[tokio::test]
2079 async fn capability_snapshot_surfaces_seeded_capabilities() {
2080 let bus = Arc::new(HookBus::new());
2081 let mut mgr = ExtensionManager::new(bus.clone());
2082 let manifest = ExtensionManifest {
2083 theme_tokens: Default::default(),
2084 deferred: None,
2085 protocol_version: 1,
2086 runtime: crate::extensions::manifest::ExtensionRuntime::Process,
2087 command: "python3".to_string(),
2088 setup: None,
2089 prebuilt: ::std::collections::HashMap::new(),
2090 args: vec![
2091 "tests/fixtures/process_extension.py".to_string(),
2092 "normal".to_string(),
2093 "/tmp/synaps-capability-snapshot-test.log".to_string(),
2094 ],
2095 permissions: vec!["tools.intercept".to_string()],
2096 hooks: vec![crate::extensions::manifest::HookSubscription {
2097 hook: "before_tool_call".to_string(),
2098 tool: Some("bash".to_string()),
2099 matcher: None,
2100 }],
2101 config: vec![],
2102 };
2103
2104 mgr.load("multi-cap", &manifest).await.unwrap();
2105
2106 mgr.test_seed_capabilities(
2110 "multi-cap",
2111 vec![
2112 crate::extensions::runtime::process::CapabilityDeclaration {
2113 kind: "capture".to_string(),
2114 name: "Local Sample STT".to_string(),
2115 permissions: vec!["audio.input".to_string()],
2116 params: serde_json::Value::Null,
2117 },
2118 crate::extensions::runtime::process::CapabilityDeclaration {
2119 kind: "ocr".to_string(),
2120 name: "Tesseract".to_string(),
2121 permissions: vec![],
2122 params: serde_json::Value::Null,
2123 },
2124 ],
2125 );
2126
2127 let snaps = mgr.capability_snapshots().await;
2128 let snap = snaps
2129 .iter()
2130 .find(|s| s.id == "multi-cap")
2131 .expect("multi-cap snapshot");
2132 assert_eq!(snap.future.len(), 2);
2133 let kinds: Vec<&str> = snap.future.iter().map(|e| e.kind.as_str()).collect();
2134 assert!(kinds.contains(&"capture"), "got kinds {:?}", kinds);
2135 assert!(kinds.contains(&"ocr"), "got kinds {:?}", kinds);
2136 let names: Vec<&str> = snap.future.iter().map(|e| e.name.as_str()).collect();
2137 assert!(names.contains(&"Local Sample STT"), "got {:?}", names);
2138 assert!(names.contains(&"Tesseract"), "got {:?}", names);
2139
2140 mgr.unload("multi-cap").await.unwrap();
2141 let snaps = mgr.capability_snapshots().await;
2142 assert!(snaps.iter().all(|s| s.id != "multi-cap"));
2143
2144 mgr.shutdown_all().await;
2145 }
2146
2147 #[tokio::test]
2148 async fn new_manager_has_no_extensions() {
2149 let bus = Arc::new(HookBus::new());
2150 let mgr = ExtensionManager::new(bus);
2151 assert_eq!(mgr.count(), 0);
2152 assert!(mgr.list().is_empty());
2153 }
2154
2155 #[tokio::test]
2156 async fn unload_nonexistent_returns_error() {
2157 let bus = Arc::new(HookBus::new());
2158 let mut mgr = ExtensionManager::new(bus);
2159 let result = mgr.unload("nope").await;
2160 assert!(result.is_err());
2161 }
2162
2163 #[tokio::test]
2164 async fn reload_unsubscribes_old_handler_before_loading_new_one() {
2165 let bus = Arc::new(HookBus::new());
2166 let mut mgr = ExtensionManager::new(bus.clone());
2167 let manifest = ExtensionManifest {
2168 theme_tokens: Default::default(),
2169 deferred: None,
2170 protocol_version: 1,
2171 runtime: crate::extensions::manifest::ExtensionRuntime::Process,
2172 command: "python3".to_string(),
2173 setup: None,
2174 prebuilt: ::std::collections::HashMap::new(),
2175 args: vec![
2176 "tests/fixtures/process_extension.py".to_string(),
2177 "normal".to_string(),
2178 "/tmp/synaps-reload-test.log".to_string(),
2179 ],
2180 permissions: vec!["tools.intercept".to_string()],
2181 hooks: vec![crate::extensions::manifest::HookSubscription {
2182 hook: "before_tool_call".to_string(),
2183 tool: Some("bash".to_string()),
2184 matcher: None,
2185 }],
2186 config: vec![],
2187 };
2188
2189 mgr.load("reload-test", &manifest).await.unwrap();
2190 assert_eq!(bus.handler_count().await, 1);
2191
2192 mgr.reload("reload-test", &manifest, None).await.unwrap();
2193
2194 assert_eq!(mgr.count(), 1);
2195 assert_eq!(bus.handler_count().await, 1);
2196 mgr.shutdown_all().await;
2197 }
2198
2199 #[tokio::test]
2200 async fn reload_failure_leaves_previous_instance_unloaded() {
2201 let bus = Arc::new(HookBus::new());
2202 let mut mgr = ExtensionManager::new(bus.clone());
2203 let good = ExtensionManifest {
2204 theme_tokens: Default::default(),
2205 deferred: None,
2206 protocol_version: 1,
2207 runtime: crate::extensions::manifest::ExtensionRuntime::Process,
2208 command: "python3".to_string(),
2209 setup: None,
2210 prebuilt: ::std::collections::HashMap::new(),
2211 args: vec![
2212 "tests/fixtures/process_extension.py".to_string(),
2213 "normal".to_string(),
2214 "/tmp/synaps-reload-failure-test.log".to_string(),
2215 ],
2216 permissions: vec!["tools.intercept".to_string()],
2217 hooks: vec![crate::extensions::manifest::HookSubscription {
2218 hook: "before_tool_call".to_string(),
2219 tool: Some("bash".to_string()),
2220 matcher: None,
2221 }],
2222 config: vec![],
2223 };
2224 let bad = ExtensionManifest {
2225 theme_tokens: Default::default(),
2226 deferred: None,
2227 command: "/definitely/not/a/real/extension-binary".to_string(),
2228 setup: None,
2229 prebuilt: ::std::collections::HashMap::new(),
2230 ..good.clone()
2231 };
2232
2233 mgr.load("reload-failure-test", &good).await.unwrap();
2234 let err = mgr
2235 .reload("reload-failure-test", &bad, None)
2236 .await
2237 .unwrap_err();
2238
2239 assert!(err.contains("Failed to spawn extension"), "{err}");
2240 assert_eq!(mgr.count(), 0);
2241 assert_eq!(bus.handler_count().await, 0);
2242 }
2243
2244 #[test]
2245 fn project_plugins_disable_env_parser_accepts_truthy_values() {
2246 for value in ["1", "true", "TRUE", "yes", "on"] {
2247 std::env::set_var("SYNAPS_DISABLE_PROJECT_PLUGINS", value);
2248 assert!(project_plugins_disabled());
2249 }
2250 for value in ["", "0", "false", "off", "no"] {
2251 std::env::set_var("SYNAPS_DISABLE_PROJECT_PLUGINS", value);
2252 assert!(!project_plugins_disabled());
2253 }
2254 std::env::remove_var("SYNAPS_DISABLE_PROJECT_PLUGINS");
2255 }
2256
2257 fn with_temp_base_dir<T>(path: &std::path::Path, f: impl FnOnce() -> T) -> T {
2258 let old_base_dir = std::env::var("SYNAPS_BASE_DIR").ok();
2259 crate::config::set_base_dir_for_tests(path.to_path_buf());
2260 let out = f();
2261 match old_base_dir {
2262 Some(old) => std::env::set_var("SYNAPS_BASE_DIR", old),
2263 None => std::env::remove_var("SYNAPS_BASE_DIR"),
2264 }
2265 out
2266 }
2267
2268 #[test]
2269 #[serial_test::serial(synaps_base_dir)]
2270 fn resolve_config_prefers_plugin_namespaced_config_before_legacy_global_key() {
2271 let dir = tempfile::tempdir().unwrap();
2272 with_temp_base_dir(dir.path(), || {
2273 crate::extensions::config_store::write_plugin_config(
2274 "sample-sidecar",
2275 "backend",
2276 "cpu",
2277 )
2278 .unwrap();
2279 crate::config::write_config_value("extension.sample-sidecar.backend", "auto").unwrap();
2280
2281 let resolved = ExtensionManager::resolve_config(
2282 "sample-sidecar",
2283 &[ExtensionConfigEntry {
2284 key: "backend".to_string(),
2285 value_type: None,
2286 description: None,
2287 required: true,
2288 default: None,
2289 secret_env: None,
2290 host_context: None,
2291 }],
2292 None,
2293 )
2294 .unwrap();
2295
2296 assert_eq!(
2297 resolved["backend"],
2298 serde_json::Value::String("cpu".to_string())
2299 );
2300 });
2301 }
2302
2303 #[test]
2304 #[serial_test::serial(synaps_base_dir)]
2305 fn resolve_config_keeps_legacy_global_extension_key_as_fallback() {
2306 let dir = tempfile::tempdir().unwrap();
2307 with_temp_base_dir(dir.path(), || {
2308 crate::config::write_config_value("extension.sample-sidecar.backend", "auto").unwrap();
2309
2310 let resolved = ExtensionManager::resolve_config(
2311 "sample-sidecar",
2312 &[ExtensionConfigEntry {
2313 key: "backend".to_string(),
2314 value_type: None,
2315 description: None,
2316 required: true,
2317 default: None,
2318 secret_env: None,
2319 host_context: None,
2320 }],
2321 None,
2322 )
2323 .unwrap();
2324
2325 assert_eq!(
2326 resolved["backend"],
2327 serde_json::Value::String("auto".to_string())
2328 );
2329 });
2330 }
2331
2332 #[tokio::test]
2333 async fn config_diagnostics_returns_none_for_unknown_extension() {
2334 let bus = Arc::new(HookBus::new());
2335 let mgr = ExtensionManager::new(bus);
2336 assert!(mgr.config_diagnostics("nope").is_none());
2337 assert!(mgr.all_config_diagnostics().is_empty());
2338 }
2339
2340 #[tokio::test]
2341 async fn config_diagnostics_reports_loaded_manifest_entries() {
2342 let bus = Arc::new(HookBus::new());
2343 let mut mgr = ExtensionManager::new(bus);
2344 let manifest = ExtensionManifest {
2345 theme_tokens: Default::default(),
2346 deferred: None,
2347 protocol_version: 1,
2348 runtime: crate::extensions::manifest::ExtensionRuntime::Process,
2349 command: "python3".to_string(),
2350 setup: None,
2351 prebuilt: ::std::collections::HashMap::new(),
2352 args: vec![
2353 "tests/fixtures/process_extension.py".to_string(),
2354 "normal".to_string(),
2355 "/tmp/synaps-config-diag-test.log".to_string(),
2356 ],
2357 permissions: vec!["tools.intercept".to_string()],
2358 hooks: vec![crate::extensions::manifest::HookSubscription {
2359 hook: "before_tool_call".to_string(),
2360 tool: Some("bash".to_string()),
2361 matcher: None,
2362 }],
2363 config: vec![crate::extensions::manifest::ExtensionConfigEntry {
2364 key: "region".to_string(),
2365 value_type: None,
2366 description: Some("AWS region".to_string()),
2367 required: false,
2368 default: Some(serde_json::Value::String("us-east-1".to_string())),
2369 secret_env: None,
2370 host_context: None,
2371 }],
2372 };
2373
2374 mgr.load("config-diag-test", &manifest).await.unwrap();
2375
2376 let diag = mgr
2377 .config_diagnostics("config-diag-test")
2378 .expect("diagnostics should be available for loaded extension");
2379 assert_eq!(diag.extension_id, "config-diag-test");
2380 assert_eq!(diag.entries.len(), 1);
2381 assert_eq!(diag.entries[0].key, "region");
2382 assert!(diag.entries[0].has_value);
2383 assert!(diag.provider_missing.is_empty());
2384
2385 let all = mgr.all_config_diagnostics();
2386 assert_eq!(all.len(), 1);
2387 assert_eq!(all[0].extension_id, "config-diag-test");
2388
2389 mgr.shutdown_all().await;
2390 assert!(mgr.config_diagnostics("config-diag-test").is_none());
2392 }
2393
2394 #[tokio::test]
2395 async fn provider_trust_view_is_empty_for_no_providers() {
2396 let bus = Arc::new(HookBus::new());
2397 let mgr = ExtensionManager::new(bus);
2398 let view = mgr.provider_trust_view();
2399 assert!(view.is_empty());
2400 }
2401
2402 #[tokio::test]
2403 async fn provider_tool_use_runtime_ids_lists_only_tool_use_capable() {
2404 use crate::extensions::runtime::process::{
2405 RegisteredProviderModelSpec, RegisteredProviderSpec,
2406 };
2407 let bus = Arc::new(HookBus::new());
2408 let mut mgr = ExtensionManager::new(bus);
2409 let tool_spec = RegisteredProviderSpec {
2411 id: "alpha".into(),
2412 display_name: "Alpha".into(),
2413 description: "tool-use".into(),
2414 models: vec![RegisteredProviderModelSpec {
2415 id: "m1".into(),
2416 display_name: None,
2417 capabilities: serde_json::json!({"tool_use": true}),
2418 context_window: None,
2419 }],
2420 config_schema: None,
2421 };
2422 let plain_spec = RegisteredProviderSpec {
2424 id: "beta".into(),
2425 display_name: "Beta".into(),
2426 description: "plain".into(),
2427 models: vec![RegisteredProviderModelSpec {
2428 id: "m1".into(),
2429 display_name: None,
2430 capabilities: serde_json::json!({"streaming": true}),
2431 context_window: None,
2432 }],
2433 config_schema: None,
2434 };
2435 mgr.providers.register("plug", tool_spec).unwrap();
2436 mgr.providers.register("plug", plain_spec).unwrap();
2437 let ids = mgr.provider_tool_use_runtime_ids();
2438 assert_eq!(ids, vec!["plug:alpha".to_string()]);
2439 }
2440
2441 #[test]
2444 fn hint_missing_binary_with_declared_setup_points_at_script() {
2445 let hint = compute_extension_load_hint(
2446 "Failed to spawn extension 'sample-sidecar': No such file or directory (os error 2)",
2447 std::path::Path::new("/home/u/.synaps-cli/plugins/sample-sidecar"),
2448 Some("scripts/setup.sh"),
2449 );
2450 assert!(
2451 hint.contains("Extension binary missing"),
2452 "missing-binary case should be flagged: {hint}"
2453 );
2454 assert!(
2455 hint.contains("/home/u/.synaps-cli/plugins/sample-sidecar"),
2456 "hint should include the plugin dir: {hint}"
2457 );
2458 assert!(
2459 hint.contains("setup=scripts/setup.sh"),
2460 "hint should show sanitized setup path without copy-paste shell command: {hint}"
2461 );
2462 }
2463
2464 #[test]
2465 fn hint_missing_binary_without_declared_setup_falls_back_to_generic() {
2466 let hint = compute_extension_load_hint(
2467 "Failed to spawn extension 'foo': No such file or directory (os error 2)",
2468 std::path::Path::new("/x/y"),
2469 None,
2470 );
2471 assert!(
2472 hint.contains("plugin validate"),
2473 "no setup declared → generic hint: {hint}"
2474 );
2475 assert!(
2476 !hint.contains("Extension binary missing"),
2477 "should not falsely promise a setup script: {hint}"
2478 );
2479 }
2480
2481 #[test]
2482 fn hint_other_error_with_declared_setup_falls_back_to_generic() {
2483 let hint = compute_extension_load_hint(
2484 "Extension 'foo' must subscribe to at least one hook or request a registration permission",
2485 std::path::Path::new("/x/y"),
2486 Some("scripts/setup.sh"),
2487 );
2488 assert!(hint.contains("plugin validate"), "got {hint}");
2492 assert!(!hint.contains("Extension binary missing"), "got {hint}");
2493 }
2494
2495 #[test]
2496 fn hint_recognises_os_error_2_format() {
2497 let hint = compute_extension_load_hint(
2500 "spawn failed (os error 2)",
2501 std::path::Path::new("/p"),
2502 Some("setup.sh"),
2503 );
2504 assert!(hint.contains("Extension binary missing"), "got {hint}");
2505 }
2506
2507 fn write_installed_plugin(plugins_root: &std::path::Path, dir_name: &str) {
2512 let plugin_dir = plugins_root.join(dir_name).join(".synaps-plugin");
2513 std::fs::create_dir_all(&plugin_dir).unwrap();
2514 let manifest = serde_json::json!({
2515 "extension": {
2516 "runtime": "process",
2517 "command": "/bin/false",
2518 "permissions": ["tools.register"],
2519 "deferred": {
2520 "tools": [{
2521 "name": "memory_fetch",
2522 "description": "Fetch one memory by exact ID.",
2523 "input_schema": {
2524 "type": "object",
2525 "properties": {"id": {"type": "string"}},
2526 "required": ["id"]
2527 }
2528 }]
2529 }
2530 }
2531 });
2532 std::fs::write(
2533 plugin_dir.join("plugin.json"),
2534 serde_json::to_string_pretty(&manifest).unwrap(),
2535 )
2536 .unwrap();
2537 }
2538
2539 #[tokio::test]
2549 #[serial_test::serial(synaps_base_dir)]
2550 async fn discover_and_load_skips_backup_and_disabled_plugin_directories() {
2551 let base = crate::test_env::BaseDirGuard::new();
2552 let _no_project_plugins =
2553 crate::test_env::EnvVarGuard::set("SYNAPS_DISABLE_PROJECT_PLUGINS", "1");
2554 let plugins_root = base.path().join("plugins");
2555
2556 write_installed_plugin(&plugins_root, "axel-memory-manager");
2557 for inert in [
2560 "axel-memory-manager.backup.20260719210053",
2561 "axel-memory-manager.backup.20260720101416",
2562 "axel-memory-manager.update-backup",
2563 "axel-memory-manager.backup",
2564 "axel-memory-manager.bak",
2565 "axel-memory-manager.disabled",
2566 "axel-memory-manager.old",
2567 "axel-memory-manager.orig",
2568 "axel-memory-manager.tmp",
2569 ] {
2570 write_installed_plugin(&plugins_root, inert);
2571 }
2572 write_installed_plugin(&plugins_root, "com.example.notes");
2574
2575 let registry = Arc::new(tokio::sync::RwLock::new(
2576 crate::ToolRegistry::without_subagent(),
2577 ));
2578 let mut mgr = ExtensionManager::new_with_tools(Arc::new(HookBus::new()), registry.clone());
2579 mgr.set_progressive_deferral(true);
2580
2581 let (mut loaded, failed) = mgr.discover_and_load().await;
2582 loaded.sort();
2583
2584 assert!(failed.is_empty(), "no failures expected: {failed:?}");
2585 assert_eq!(
2586 loaded,
2587 vec![
2588 "axel-memory-manager".to_string(),
2589 "com.example.notes".to_string()
2590 ],
2591 "backup/disabled/temp plugin directories must not be discovered"
2592 );
2593
2594 let reg = registry.read().await;
2595 assert!(reg.get("axel-memory-manager:memory_fetch").is_some());
2596 assert!(reg.get("com.example.notes:memory_fetch").is_some());
2597 assert!(
2598 reg.get("axel-memory-manager.backup.20260719210053:memory_fetch")
2599 .is_none(),
2600 "backup plugin tools must never be cataloged"
2601 );
2602 assert!(reg
2603 .get("axel-memory-manager.backup.20260720101416:memory_fetch")
2604 .is_none());
2605 drop(reg);
2606
2607 assert_eq!(mgr.count(), 0, "discovery must spawn nothing");
2610 }
2611
2612 #[test]
2616 fn inert_plugin_dir_name_filter_is_exact() {
2617 for inert in [
2618 "axel-memory-manager.backup.20260720101416",
2619 "axel-memory-manager.BACKUP.1",
2620 "axel-memory-manager.backup",
2621 "axel-memory-manager.update-backup",
2622 "axel-memory-manager.bak",
2623 "axel-memory-manager.disabled",
2624 "axel-memory-manager.old",
2625 "axel-memory-manager.orig",
2626 "axel-memory-manager.tmp",
2627 "axel-memory-manager.temp",
2628 ] {
2629 assert!(
2630 is_inert_plugin_dir_name(inert),
2631 "{inert} should be excluded from discovery"
2632 );
2633 }
2634 for live in [
2635 "axel-memory-manager",
2636 "com.example.notes",
2637 "backup-manager",
2638 "old-school-tools",
2639 "tmpfs-inspector",
2640 "my.backups.viewer",
2641 ] {
2642 assert!(
2643 !is_inert_plugin_dir_name(live),
2644 "{live} is a legitimate plugin name and must stay discoverable"
2645 );
2646 }
2647 }
2648}