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::permissions::{Permission, PermissionSet};
20use super::runtime::process::ProcessExtension;
21use super::runtime::{ExtensionHandler, ExtensionHealth};
22use serde_json::{Map, Value};
23
24fn project_plugins_disabled() -> bool {
25 std::env::var("SYNAPS_DISABLE_PROJECT_PLUGINS")
26 .map(|value| {
27 let normalized = value.trim().to_ascii_lowercase();
28 matches!(normalized.as_str(), "1" | "true" | "yes" | "on")
29 })
30 .unwrap_or(false)
31}
32
33fn is_inert_plugin_dir_name(name: &str) -> bool {
45 let lower = name.to_ascii_lowercase();
46 if lower.contains(".backup.") {
48 return true;
49 }
50 const INERT_SUFFIXES: &[&str] = &[
51 ".backup",
52 ".update-backup", ".bak",
54 ".disabled",
55 ".old",
56 ".orig",
57 ".tmp",
58 ".temp",
59 ];
60 INERT_SUFFIXES.iter().any(|s| lower.ends_with(s))
61}
62
63fn installed_plugin_setup_failure_in(
64 state: &crate::skills::state::PluginsState,
65 plugin_name: &str,
66) -> Option<String> {
67 let plugin = state.installed.iter().find(|p| p.name == plugin_name)?;
68 match &plugin.setup_status {
69 crate::skills::state::SetupStatus::Failed { message, .. } => Some(message.clone()),
70 _ => None,
71 }
72}
73
74fn sanitize_hint_fragment(input: &str) -> String {
75 input
76 .chars()
77 .map(|ch| if ch.is_control() { '?' } else { ch })
78 .collect::<String>()
79}
80
81#[derive(Debug, Clone, PartialEq, Eq)]
83pub struct ExtensionLoadFailure {
84 pub plugin: String,
85 pub manifest_path: Option<PathBuf>,
86 pub reason: String,
87 pub hint: String,
88}
89
90impl ExtensionLoadFailure {
91 fn new(
92 plugin: impl Into<String>,
93 manifest_path: Option<PathBuf>,
94 reason: impl Into<String>,
95 hint: impl Into<String>,
96 ) -> Self {
97 Self {
98 plugin: plugin.into(),
99 manifest_path,
100 reason: reason.into(),
101 hint: hint.into(),
102 }
103 }
104
105 pub fn concise_message(&self) -> String {
106 match &self.manifest_path {
107 Some(path) => format!(
108 "{} (manifest: {}; hint: {})",
109 self.reason,
110 path.display(),
111 self.hint
112 ),
113 None => format!("{} (hint: {})", self.reason, self.hint),
114 }
115 }
116}
117
118#[derive(Debug, Clone, PartialEq, Eq)]
120pub struct ExtensionStatus {
121 pub id: String,
122 pub health: ExtensionHealth,
123 pub restart_count: usize,
124}
125
126pub fn compute_extension_load_hint(
141 error: &str,
142 plugin_dir: &std::path::Path,
143 declared_setup: Option<&str>,
144) -> String {
145 if error == "independent Axel plugin is disabled by the host-selected memory backend" {
146 return "Expected: integrated Axel owns memory. Disable axel-memory-manager in this configuration to skip its load; do not reinstall it or delete its data.".into();
147 }
148 let missing_binary =
149 error.contains("No such file or directory") || error.contains("os error 2");
150 match (missing_binary, declared_setup) {
151 (true, Some(setup)) => format!(
152 "Extension binary missing — this plugin ships source only. Run the setup script from the plugin directory, then reload. plugin_dir={}, setup={}",
153 sanitize_hint_fragment(&plugin_dir.display().to_string()),
154 sanitize_hint_fragment(setup),
155 ),
156 _ => "Run `plugin validate <plugin-dir>` and confirm the extension command is installed"
157 .to_string(),
158 }
159}
160
161#[derive(Debug, Clone, PartialEq, Eq, Default)]
167pub enum DiscoveryState {
168 #[default]
169 NotStarted,
170 Running,
171 Done {
172 loaded: Vec<String>,
173 failed: Vec<ExtensionLoadFailure>,
174 },
175}
176
177pub struct ExtensionManager {
179 discovery: DiscoveryState,
181 exclusive_memory: bool,
182 hook_bus: Arc<HookBus>,
184 tools: Option<Arc<tokio::sync::RwLock<crate::ToolRegistry>>>,
186 providers: ProviderRegistry,
188 extensions: HashMap<String, Arc<dyn ExtensionHandler>>,
190 eager_permissions: HashMap<String, PermissionSet>,
193 manifest_configs: HashMap<String, Vec<ExtensionConfigEntry>>,
196 capabilities: HashMap<String, Vec<crate::extensions::runtime::process::CapabilityDeclaration>>,
200 plugin_info: HashMap<String, PluginInfo>,
202 manifest_theme_tokens: HashMap<String, std::collections::BTreeMap<String, String>>,
207 progressive_deferral: bool,
212 deferred_tool_only: SharedDeferredRecords,
218 extension_runtime: std::sync::OnceLock<Arc<crate::extensions::lease::ExtensionRuntimeManager>>,
222 context_providers: Vec<RegisteredContextProviderDescriptor>,
230 context_provider_catalog: SharedContextProviderCatalog,
237 host_project_root: std::sync::OnceLock<Option<String>>,
244}
245
246pub(crate) type SharedDeferredRecords =
249 Arc<std::sync::Mutex<HashMap<String, DeferredExtensionRecord>>>;
250
251#[derive(Clone)]
260pub(crate) struct DeferredExtensionRecord {
261 #[allow(dead_code)] pub(crate) exclusive_memory: bool,
263 pub(crate) manifest: ExtensionManifest,
264 pub(crate) cwd: Option<std::path::PathBuf>,
265 pub(crate) config: Value,
266}
267
268impl ExtensionManager {
269 pub fn new(hook_bus: Arc<HookBus>) -> Self {
271 Self {
272 discovery: DiscoveryState::NotStarted,
273 exclusive_memory: crate::memory_backend::MemoryBinding::configured_current()
275 .exclusive(),
276 hook_bus,
277 tools: None,
278 providers: ProviderRegistry::new(),
279 extensions: HashMap::new(),
280 eager_permissions: HashMap::new(),
281 manifest_configs: HashMap::new(),
282 capabilities: HashMap::new(),
283 plugin_info: HashMap::new(),
284 manifest_theme_tokens: HashMap::new(),
285 progressive_deferral: false,
286 deferred_tool_only: Arc::new(std::sync::Mutex::new(HashMap::new())),
287 extension_runtime: std::sync::OnceLock::new(),
288 context_providers: Vec::new(),
289 context_provider_catalog: Arc::new(std::sync::Mutex::new(Vec::new())),
290 host_project_root: std::sync::OnceLock::new(),
291 }
292 }
293
294 pub fn new_with_tools(
296 hook_bus: Arc<HookBus>,
297 tools: Arc<tokio::sync::RwLock<crate::ToolRegistry>>,
298 ) -> Self {
299 Self {
300 discovery: DiscoveryState::NotStarted,
301 exclusive_memory: crate::memory_backend::MemoryBinding::configured_current()
303 .exclusive(),
304 hook_bus,
305 tools: Some(tools),
306 providers: ProviderRegistry::new(),
307 extensions: HashMap::new(),
308 eager_permissions: HashMap::new(),
309 manifest_configs: HashMap::new(),
310 capabilities: HashMap::new(),
311 plugin_info: HashMap::new(),
312 manifest_theme_tokens: HashMap::new(),
313 progressive_deferral: false,
314 deferred_tool_only: Arc::new(std::sync::Mutex::new(HashMap::new())),
315 extension_runtime: std::sync::OnceLock::new(),
316 context_providers: Vec::new(),
317 context_provider_catalog: Arc::new(std::sync::Mutex::new(Vec::new())),
318 host_project_root: std::sync::OnceLock::new(),
319 }
320 }
321
322 pub fn bind_memory_backend(&mut self, exclusive: bool) {
324 self.exclusive_memory = exclusive;
325 }
326
327 pub async fn load(&mut self, id: &str, manifest: &ExtensionManifest) -> Result<(), String> {
329 self.load_with_cwd(id, manifest, None).await
330 }
331
332 pub async fn load_with_cwd(
334 &mut self,
335 id: &str,
336 manifest: &ExtensionManifest,
337 cwd: Option<std::path::PathBuf>,
338 ) -> Result<(), String> {
339 let config = Self::resolve_config(id, &manifest.config, self.host_project_root())?;
340 self.load_with_cwd_and_config(id, manifest, cwd, config)
341 .await
342 }
343
344 pub fn set_progressive_deferral(&mut self, enabled: bool) {
348 self.progressive_deferral = enabled;
349 }
350
351 pub fn host_project_root(&self) -> Option<&str> {
358 self.host_project_root
359 .get_or_init(|| {
360 let cwd = std::env::current_dir().ok()?;
361 agent_core::memory::store::ProjectScope::discover(&cwd)
362 .ok()
363 .map(|scope| scope.root().display().to_string())
364 })
365 .as_deref()
366 }
367
368 pub fn is_deferred(&self, id: &str) -> bool {
374 self.deferred_records()
375 .lock()
376 .unwrap_or_else(std::sync::PoisonError::into_inner)
377 .contains_key(id)
378 }
379
380 pub fn is_deferred_tool_only(&self, id: &str) -> bool {
382 self.is_deferred(id)
383 }
384
385 pub fn deferred_tool_only_count(&self) -> usize {
388 self.deferred_records()
389 .lock()
390 .unwrap_or_else(std::sync::PoisonError::into_inner)
391 .len()
392 }
393
394 fn deferred_records(&self) -> &SharedDeferredRecords {
395 &self.deferred_tool_only
396 }
397
398 fn publish_context_providers(&self) {
404 let mut published = self
405 .context_provider_catalog
406 .lock()
407 .unwrap_or_else(std::sync::PoisonError::into_inner);
408 *published = self.context_providers.clone();
409 }
410
411 fn catalog_context_providers(&mut self, id: &str, manifest: &ExtensionManifest) {
417 let descriptors = dormant_context_provider_descriptors(id, manifest);
418 if descriptors.is_empty() {
419 return;
420 }
421 self.context_providers.extend(descriptors);
422 self.publish_context_providers();
423 }
424
425 fn uncatalog_context_providers(&mut self, id: &str) {
428 let before = self.context_providers.len();
429 self.context_providers
430 .retain(|descriptor| descriptor.extension_id() != id);
431 if self.context_providers.len() != before {
432 self.publish_context_providers();
433 }
434 }
435
436 pub fn extension_runtime(&self) -> Arc<crate::extensions::lease::ExtensionRuntimeManager> {
444 self.extension_runtime_with_idle(crate::extensions::lease::DEFAULT_IDLE_MAX)
445 }
446
447 pub fn extension_runtime_with_idle(
450 &self,
451 idle_max: std::time::Duration,
452 ) -> Arc<crate::extensions::lease::ExtensionRuntimeManager> {
453 Arc::clone(self.extension_runtime.get_or_init(|| {
454 Arc::new(crate::extensions::lease::ExtensionRuntimeManager::new(
455 Arc::clone(&self.deferred_tool_only),
456 Arc::clone(&self.context_provider_catalog),
457 idle_max,
458 ))
459 }))
460 }
461
462 #[cfg(test)]
465 pub(crate) fn deferred_launch_record(&self, id: &str) -> Option<DeferredExtensionRecord> {
466 self.deferred_records()
467 .lock()
468 .unwrap_or_else(std::sync::PoisonError::into_inner)
469 .get(id)
470 .cloned()
471 }
472
473 #[doc(hidden)]
478 pub fn tamper_deferred_manifest_permissions_for_tests(
479 &self,
480 id: &str,
481 permissions: Vec<String>,
482 ) -> bool {
483 let mut map = self
484 .deferred_tool_only
485 .lock()
486 .unwrap_or_else(std::sync::PoisonError::into_inner);
487 match map.get_mut(id) {
488 Some(record) => {
489 record.manifest.permissions = permissions;
490 true
491 }
492 None => false,
493 }
494 }
495
496 #[doc(hidden)]
500 pub fn tamper_deferred_config_for_tests(&self, id: &str, config: Value) -> bool {
501 let mut map = self
502 .deferred_tool_only
503 .lock()
504 .unwrap_or_else(std::sync::PoisonError::into_inner);
505 match map.get_mut(id) {
506 Some(record) => {
507 record.config = config;
508 true
509 }
510 None => false,
511 }
512 }
513
514 async fn load_with_cwd_and_config(
515 &mut self,
516 id: &str,
517 manifest: &ExtensionManifest,
518 cwd: Option<std::path::PathBuf>,
519 config: Value,
520 ) -> Result<(), String> {
521 if self.extensions.contains_key(id) || self.is_deferred_tool_only(id) {
523 return Err(format!("Extension '{}' is already loaded", id));
524 }
525
526 let validated = manifest.validate(id)?;
530 let permissions = validated.permissions;
531 let subscriptions = validated.subscriptions;
532
533 let class = crate::extensions::lifecycle::classify(manifest);
549 if self.progressive_deferral
550 && class != crate::extensions::lifecycle::ExtensionClass::LegacyEager
551 {
552 let declared_tool_names: Vec<String> = manifest
553 .deferred
554 .as_ref()
555 .map(|d| d.tools.iter().map(|t| format!("{id}:{}", t.name)).collect())
556 .unwrap_or_default();
557 let dormant = crate::extensions::lifecycle::dormant_extension_tools(id, manifest);
558 let tool_count = dormant.len();
559 if !dormant.is_empty() {
560 let Some(tools) = &self.tools else {
561 return Err(format!(
562 "Extension '{}' declares deferred tools but no tool registry is available",
563 id
564 ));
565 };
566 tools
567 .write()
568 .await
569 .try_register_batch(dormant)
570 .map_err(|e| {
571 format!("Extension '{}' deferred tool catalog rejected: {}", id, e)
572 })?;
573 }
574 self.deferred_tool_only
577 .lock()
578 .unwrap_or_else(std::sync::PoisonError::into_inner)
579 .insert(
580 id.to_string(),
581 DeferredExtensionRecord {
582 exclusive_memory: self.exclusive_memory,
583 manifest: manifest.clone(),
584 cwd,
585 config: config.clone(),
586 },
587 );
588 macro_rules! unwind_deferred {
591 () => {{
592 self.deferred_tool_only
593 .lock()
594 .unwrap_or_else(std::sync::PoisonError::into_inner)
595 .remove(id);
596 self.providers.unregister_plugin(id);
597 if let Some(tools) = &self.tools {
598 let _ = tools.write().await.try_disable(&declared_tool_names);
599 }
600 self.hook_bus.unsubscribe_all(id).await;
601 }};
602 }
603 let lazy: Option<Arc<dyn ExtensionHandler>> = {
604 let needs_lazy = manifest
608 .deferred
609 .as_ref()
610 .map(|d| !d.providers.is_empty())
611 .unwrap_or(false)
612 || !manifest.hooks.is_empty();
613 needs_lazy.then(|| {
614 Arc::new(crate::extensions::lease::LazyExtensionHandler::new(
615 id,
616 self.extension_runtime(),
617 )) as Arc<dyn ExtensionHandler>
618 })
619 };
620 let declared_providers: Vec<_> = manifest
624 .deferred
625 .as_ref()
626 .map(|d| d.providers.clone())
627 .unwrap_or_default();
628 let mut registered_provider_ids = Vec::new();
629 for declared in &declared_providers {
630 let spec = declared.to_registered_spec();
631 if let Err(error) = Self::validate_provider_config_requirements(id, &spec, &config)
632 {
633 unwind_deferred!();
634 return Err(error);
635 }
636 let handler = lazy.clone().expect("providers imply a lazy handler");
637 match self
638 .providers
639 .register_with_handler(id, spec, Some(handler))
640 {
641 Ok(runtime_id) => registered_provider_ids.push(runtime_id),
642 Err(error) => {
643 unwind_deferred!();
644 return Err(error);
645 }
646 }
647 }
648 for runtime_id in ®istered_provider_ids {
650 if let Some(provider) = self.providers.get(runtime_id) {
651 let tool_use = provider.spec.models.iter().any(|m| {
652 m.capabilities
653 .get("tool_use")
654 .and_then(|v| v.as_bool())
655 .unwrap_or(false)
656 });
657 if tool_use {
658 tracing::warn!(
659 "Provider '{}' is tool-use capable: it can request Synaps tools through provider mediation. Use `/extensions trust disable {}` to block routing.",
660 runtime_id,
661 runtime_id,
662 );
663 }
664 }
665 }
666 for (kind, tool_filter, matcher) in subscriptions {
669 let handler = lazy.clone().expect("subscriptions imply a lazy handler");
670 if let Err(error) = self
671 .hook_bus
672 .subscribe(kind, handler, tool_filter, matcher, permissions.clone())
673 .await
674 {
675 unwind_deferred!();
676 return Err(error);
677 }
678 }
679 self.manifest_configs
680 .insert(id.to_string(), manifest.config.clone());
681 if !manifest.theme_tokens.is_empty() {
682 self.manifest_theme_tokens
683 .insert(id.to_string(), manifest.theme_tokens.clone());
684 }
685 self.catalog_context_providers(id, manifest);
690 tracing::info!(extension = %id, class = ?class, tools = tool_count,
691 providers = registered_provider_ids.len(), hooks = manifest.hooks.len(),
692 context_providers = self.context_providers.iter().filter(|d| d.extension_id() == id).count(),
693 "Extension deferred: passive surfaces registered, no process started");
694 return Ok(());
695 }
696
697 let process = ProcessExtension::spawn_with_memory_policy(
699 id,
700 &manifest.command,
701 &manifest.args,
702 cwd.clone(),
703 self.exclusive_memory,
704 )
705 .await?;
706 process.set_permissions(permissions.clone()).await;
709 let capabilities = match process.initialize(cwd.clone(), config.clone()).await {
710 Ok(capabilities) => capabilities,
711 Err(error) => {
712 process.shutdown().await;
713 return Err(error);
714 }
715 };
716 let registered_tools = capabilities.tools;
717 let registered_providers = capabilities.providers;
718 let capability_declarations = capabilities.capabilities;
719 let should_probe_info = !registered_tools.is_empty()
720 || !registered_providers.is_empty()
721 || !capability_declarations.is_empty();
722 let handler: Arc<dyn ExtensionHandler> = Arc::new(process);
723 if !registered_tools.is_empty()
724 && !permissions.has(crate::extensions::permissions::Permission::ToolsRegister)
725 {
726 handler.shutdown().await;
727 return Err(format!(
728 "Extension '{}' registered tools but lacks permission 'tools.register'",
729 id
730 ));
731 }
732 if !registered_providers.is_empty()
733 && !permissions.has(crate::extensions::permissions::Permission::ProvidersRegister)
734 {
735 handler.shutdown().await;
736 return Err(format!(
737 "Extension '{}' registered providers but lacks permission 'providers.register'",
738 id
739 ));
740 }
741 for decl in &capability_declarations {
742 if let Err(err) =
743 crate::extensions::runtime::process::validate_capability(decl, &permissions)
744 {
745 handler.shutdown().await;
746 return Err(format!(
747 "Extension '{}' capability '{}' invalid: {}",
748 id, decl.kind, err
749 ));
750 }
751 }
752 if !registered_providers.is_empty() {
753 let mut registered_ids = Vec::new();
754 for provider in registered_providers {
755 if let Err(error) =
756 Self::validate_provider_config_requirements(id, &provider, &config)
757 {
758 self.providers.unregister_plugin(id);
759 handler.shutdown().await;
760 return Err(error);
761 }
762 match self
763 .providers
764 .register_with_handler(id, provider, Some(handler.clone()))
765 {
766 Ok(runtime_id) => registered_ids.push(runtime_id),
767 Err(error) => {
768 self.providers.unregister_plugin(id);
769 handler.shutdown().await;
770 return Err(error);
771 }
772 }
773 }
774 tracing::info!(extension = %id, providers = ?registered_ids, "Extension provider metadata registered");
775 for runtime_id in ®istered_ids {
777 if let Some(provider) = self.providers.get(runtime_id) {
778 let tool_use = provider.spec.models.iter().any(|m| {
779 m.capabilities
780 .get("tool_use")
781 .and_then(|v| v.as_bool())
782 .unwrap_or(false)
783 });
784 if tool_use {
785 tracing::warn!(
786 "Provider '{}' is tool-use capable: it can request Synaps tools through provider mediation. Use `/extensions trust disable {}` to block routing.",
787 runtime_id,
788 runtime_id,
789 );
790 }
791 }
792 }
793 }
794 if !registered_tools.is_empty() {
795 let Some(tools) = &self.tools else {
796 handler.shutdown().await;
797 return Err(format!(
798 "Extension '{}' registered tools but no tool registry is available",
799 id
800 ));
801 };
802 let mut registry = tools.write().await;
803 for spec in registered_tools {
804 let tool_name = spec.name.clone();
805 if let Err(e) = registry.try_register(Arc::new(crate::tools::ExtensionTool::new(
806 id,
807 spec,
808 handler.clone(),
809 ))) {
810 tracing::warn!(
811 extension = %id,
812 tool = %tool_name,
813 error = %e,
814 "Refusing to expose extension tool the capability catalog could not record"
815 );
816 }
817 }
818 }
819
820 let info = if should_probe_info {
825 match handler.get_info().await {
826 Ok(info) => Some(info),
827 Err(error) => {
828 if error.contains("method not found") || error.contains("unknown method") {
829 tracing::debug!(
830 extension = %id,
831 error = %error,
832 "Extension did not provide optional info.get metadata",
833 );
834 None
835 } else {
836 tracing::warn!(
837 extension = %id,
838 error = %error,
839 "Ignoring invalid optional info.get metadata",
840 );
841 None
842 }
843 }
844 }
845 } else {
846 None
847 };
848
849 for (kind, tool_filter, matcher) in subscriptions {
851 self.hook_bus
852 .subscribe(
853 kind,
854 handler.clone(),
855 tool_filter,
856 matcher,
857 permissions.clone(),
858 )
859 .await?;
860 }
861
862 self.extensions.insert(id.to_string(), handler);
863 self.eager_permissions.insert(id.to_string(), permissions);
864 self.manifest_configs
865 .insert(id.to_string(), manifest.config.clone());
866 if !manifest.theme_tokens.is_empty() {
867 self.manifest_theme_tokens
868 .insert(id.to_string(), manifest.theme_tokens.clone());
869 }
870 if !capability_declarations.is_empty() {
871 self.capabilities
872 .insert(id.to_string(), capability_declarations);
873 }
874 if let Some(info) = info {
875 self.plugin_info.insert(id.to_string(), info);
876 }
877 self.catalog_context_providers(id, manifest);
883 tracing::info!(extension = %id, hooks = manifest.hooks.len(), "Extension loaded");
884 Ok(())
885 }
886
887 fn validate_provider_config_requirements(
888 id: &str,
889 provider: &crate::extensions::runtime::process::RegisteredProviderSpec,
890 config: &Value,
891 ) -> Result<(), String> {
892 let Some(required) = provider
893 .config_schema
894 .as_ref()
895 .and_then(|schema| schema.get("required"))
896 .and_then(Value::as_array)
897 else {
898 return Ok(());
899 };
900 for key in required {
901 let Some(key) = key.as_str() else {
902 return Err(format!(
903 "Extension '{}' provider '{}' config_schema.required must contain only strings",
904 id, provider.id,
905 ));
906 };
907 let present = config
908 .as_object()
909 .map(|map| map.contains_key(key))
910 .unwrap_or(false);
911 if !present {
912 return Err(format!(
913 "Extension '{}' provider '{}' missing required provider config '{}'",
914 id, provider.id, key,
915 ));
916 }
917 }
918 Ok(())
919 }
920
921 fn denied_secret_env(var: &str, id: &str) -> Option<&'static str> {
935 const HOST_CREDENTIALS: &[&str] = &[
936 "ANTHROPIC_API_KEY",
937 "OPENAI_API_KEY",
938 "GEMINI_API_KEY",
939 "GOOGLE_API_KEY",
940 "XAI_API_KEY",
941 "GROQ_API_KEY",
942 "OPENROUTER_API_KEY",
943 "DEEPSEEK_API_KEY",
944 "MISTRAL_API_KEY",
945 "AWS_SECRET_ACCESS_KEY",
946 "AWS_SESSION_TOKEN",
947 ];
948 let up = var.trim().to_ascii_uppercase();
949 if up.is_empty() {
950 return Some("secret_env must not be empty");
951 }
952 if HOST_CREDENTIALS.contains(&up.as_str()) {
953 return Some(
954 "that variable holds a host credential; extensions are never handed the host's own provider keys",
955 );
956 }
957 let own_prefix = format!(
958 "SYNAPS_EXTENSION_{}_",
959 id.replace('-', "_").to_ascii_uppercase()
960 );
961 if up.starts_with("SYNAPS_") && !up.starts_with(&own_prefix) {
962 return Some(
963 "SYNAPS_* is the host namespace; declare your own SYNAPS_EXTENSION_<ID>_<KEY> variable instead",
964 );
965 }
966 None
967 }
968
969 fn resolve_config(
970 id: &str,
971 entries: &[ExtensionConfigEntry],
972 host_project_root: Option<&str>,
973 ) -> Result<Value, String> {
974 let mut out = Map::new();
975 for entry in entries {
976 let key = entry.key.trim();
977 if key.is_empty() {
978 return Err(format!("Extension '{}' declares config with empty key", id));
979 }
980 if key.contains('.') || key.contains('/') || key.contains(' ') {
981 return Err(format!(
982 "Extension '{}' config key '{}' must not contain dots, slashes, or spaces",
983 id, key,
984 ));
985 }
986 if let Some(host_key) = entry.host_context {
992 if entry.secret_env.is_some() {
993 return Err(format!(
994 "Extension '{}' config key '{}' declares both host_context and secret_env; host-context values never carry secrets",
995 id, key,
996 ));
997 }
998 match host_key {
999 crate::extensions::manifest::HostContextKey::ProjectRoot => {
1000 if let Some(root) = host_project_root {
1001 out.insert(key.to_string(), Value::String(root.to_string()));
1002 } else if let Some(default) = &entry.default {
1003 out.insert(key.to_string(), default.clone());
1004 } else if entry.required {
1005 return Err(format!(
1006 "Extension '{}' requires host context 'project_root' but the host could not resolve a trusted project root",
1007 id,
1008 ));
1009 }
1010 }
1011 }
1012 continue;
1013 }
1014 let config_key = format!("extension.{}.{}", id, key);
1015 if let Some(secret_env) = &entry.secret_env {
1019 if let Some(reason) = Self::denied_secret_env(secret_env, id) {
1020 return Err(format!(
1021 "Extension '{}' config key '{}' declares secret_env '{}': {}",
1022 id, key, secret_env, reason
1023 ));
1024 }
1025 }
1026 if let Ok(value) = std::env::var(format!(
1027 "SYNAPS_EXTENSION_{}_{}",
1028 id.replace('-', "_").to_ascii_uppercase(),
1029 key.replace('-', "_").to_ascii_uppercase()
1030 )) {
1031 out.insert(key.to_string(), Value::String(value));
1032 continue;
1033 }
1034 if let Some(secret_env) = &entry.secret_env {
1035 if let Ok(value) = std::env::var(secret_env) {
1036 out.insert(key.to_string(), Value::String(value));
1037 continue;
1038 }
1039 }
1040 if let Some(value) = crate::extensions::config_store::read_plugin_config(id, key) {
1041 out.insert(key.to_string(), Value::String(value));
1042 continue;
1043 }
1044 if let Some(value) = crate::config::read_config_value(&config_key) {
1045 out.insert(key.to_string(), Value::String(value));
1046 continue;
1047 }
1048 if let Some(default) = &entry.default {
1049 out.insert(key.to_string(), default.clone());
1050 continue;
1051 }
1052 if entry.required {
1053 let hint = if let Some(secret_env) = &entry.secret_env {
1054 format!(
1055 "set environment variable '{}' or config key '{}'",
1056 secret_env, config_key
1057 )
1058 } else {
1059 format!("set config key '{}'", config_key)
1060 };
1061 return Err(format!(
1062 "Extension '{}' missing required config '{}': {}",
1063 id, key, hint
1064 ));
1065 }
1066 }
1067 Ok(Value::Object(out))
1068 }
1069
1070 #[cfg(test)]
1074 pub(crate) fn test_seed_capabilities(
1075 &mut self,
1076 id: &str,
1077 decls: Vec<crate::extensions::runtime::process::CapabilityDeclaration>,
1078 ) {
1079 self.capabilities.insert(id.to_string(), decls);
1080 }
1081
1082 pub async fn unload(&mut self, id: &str) -> Result<(), String> {
1090 let deferred = self
1091 .deferred_tool_only
1092 .lock()
1093 .unwrap_or_else(std::sync::PoisonError::into_inner)
1094 .remove(id);
1095 if let Some(record) = deferred {
1096 self.uncatalog_context_providers(id);
1102 if let Some(runtime) = self.extension_runtime.get() {
1103 let revoked = runtime.revoke_plugin_all_sessions(id);
1104 if revoked > 0 {
1105 tracing::info!(extension = %id, leases = revoked,
1106 "Terminated live runtime leases of unloaded deferred extension");
1107 }
1108 }
1109 self.hook_bus.unsubscribe_all(id).await;
1110 self.providers.unregister_plugin(id);
1111 if let Some(tools) = &self.tools {
1112 let names: Vec<String> = record
1113 .manifest
1114 .deferred
1115 .as_ref()
1116 .map(|d| {
1117 d.tools
1118 .iter()
1119 .map(|t| format!("{}:{}", id, t.name))
1120 .collect()
1121 })
1122 .unwrap_or_default();
1123 if let Err(error) = tools.write().await.try_disable(&names) {
1124 return Err(format!(
1128 "Extension '{}' unloaded (record removed, leases revoked) but its dormant catalog batch could not be deregistered: {}",
1129 id, error
1130 ));
1131 }
1132 }
1133 self.manifest_configs.remove(id);
1134 self.manifest_theme_tokens.remove(id);
1135 tracing::info!(extension = %id, "Deferred extension unloaded");
1136 return Ok(());
1137 }
1138 let handler = self
1139 .extensions
1140 .remove(id)
1141 .ok_or_else(|| format!("Extension '{}' not found", id))?;
1142 self.uncatalog_context_providers(id);
1145
1146 self.hook_bus.unsubscribe_all(id).await;
1147 self.providers.unregister_plugin(id);
1148 self.eager_permissions.remove(id);
1149 self.manifest_configs.remove(id);
1150 self.capabilities.remove(id);
1151 self.plugin_info.remove(id);
1152 self.manifest_theme_tokens.remove(id);
1153 handler.shutdown().await;
1154
1155 tracing::info!(extension = %id, "Extension unloaded");
1156 Ok(())
1157 }
1158
1159 pub async fn reload(
1163 &mut self,
1164 id: &str,
1165 manifest: &ExtensionManifest,
1166 cwd: Option<std::path::PathBuf>,
1167 ) -> Result<(), String> {
1168 if self.extensions.contains_key(id) || self.is_deferred_tool_only(id) {
1169 self.unload(id).await?;
1170 }
1171 self.load_with_cwd(id, manifest, cwd).await
1172 }
1173
1174 pub fn discovery_state(&self) -> &DiscoveryState {
1176 &self.discovery
1177 }
1178
1179 pub fn discovery_done(&self) -> Option<(Vec<String>, Vec<ExtensionLoadFailure>)> {
1183 match &self.discovery {
1184 DiscoveryState::Done { loaded, failed } => Some((loaded.clone(), failed.clone())),
1185 _ => None,
1186 }
1187 }
1188
1189 pub async fn shutdown_all(&mut self) {
1192 self.discovery = DiscoveryState::NotStarted;
1193 let ids: Vec<String> = self.extensions.keys().cloned().collect();
1194 for id in ids {
1195 let _ = self.unload(&id).await;
1196 }
1197 let deferred_ids: Vec<String> = {
1200 let map = self
1201 .deferred_tool_only
1202 .lock()
1203 .unwrap_or_else(std::sync::PoisonError::into_inner);
1204 map.keys().cloned().collect()
1205 };
1206 for id in deferred_ids {
1207 let _ = self.unload(&id).await;
1208 }
1209 if let Some(runtime) = self.extension_runtime.get() {
1210 runtime.terminate_all();
1211 }
1212 }
1213
1214 pub fn shutdown_all_detached(
1220 manager: Arc<tokio::sync::RwLock<Self>>,
1221 ) -> tokio::task::JoinHandle<()> {
1222 tokio::spawn(async move {
1223 manager.write().await.shutdown_all().await;
1224 })
1225 }
1226
1227 pub fn list(&self) -> Vec<&str> {
1229 self.extensions.keys().map(|s| s.as_str()).collect()
1230 }
1231
1232 pub fn handlers(&self) -> Vec<(String, Arc<dyn super::runtime::ExtensionHandler>)> {
1236 let mut out: Vec<_> = self
1237 .extensions
1238 .iter()
1239 .map(|(id, h)| (id.clone(), Arc::clone(h)))
1240 .collect();
1241 out.sort_by(|a, b| a.0.cmp(&b.0));
1242 out
1243 }
1244
1245 pub fn count(&self) -> usize {
1247 self.extensions.len()
1248 }
1249
1250 pub async fn statuses(&self) -> Vec<ExtensionStatus> {
1252 let mut handlers: Vec<(String, Arc<dyn ExtensionHandler>)> = self
1253 .extensions
1254 .iter()
1255 .map(|(id, handler)| (id.clone(), handler.clone()))
1256 .collect();
1257 handlers.sort_by(|a, b| a.0.cmp(&b.0));
1258
1259 let mut statuses = Vec::with_capacity(handlers.len());
1260 for (id, handler) in handlers {
1261 statuses.push(ExtensionStatus {
1262 id,
1263 health: handler.health().await,
1264 restart_count: handler.restart_count().await,
1265 });
1266 }
1267 statuses
1268 }
1269
1270 pub fn providers(&self) -> Vec<&RegisteredProvider> {
1272 self.providers.list()
1273 }
1274
1275 pub fn provider(&self, runtime_id: &str) -> Option<&RegisteredProvider> {
1277 self.providers.get(runtime_id)
1278 }
1279
1280 pub fn context_providers(&self) -> Vec<&RegisteredContextProviderDescriptor> {
1286 let mut out: Vec<&RegisteredContextProviderDescriptor> =
1287 self.context_providers.iter().collect();
1288 out.sort_by_key(|descriptor| descriptor.runtime_address());
1289 out
1290 }
1291
1292 pub fn context_provider(
1299 &self,
1300 provider_id: &ContextProviderId,
1301 ) -> Option<&RegisteredContextProviderDescriptor> {
1302 resolve_context_provider(&self.context_providers, Some(provider_id)).ok()
1303 }
1304
1305 pub fn plugin_info(&self, id: &str) -> Option<&PluginInfo> {
1307 self.plugin_info.get(id)
1308 }
1309
1310 pub fn theme_tokens(&self) -> Vec<(String, std::collections::BTreeMap<String, String>)> {
1315 let mut out: Vec<_> = self
1316 .manifest_theme_tokens
1317 .iter()
1318 .map(|(id, tokens)| (id.clone(), tokens.clone()))
1319 .collect();
1320 out.sort_by(|a, b| a.0.cmp(&b.0));
1321 out
1322 }
1323
1324 pub fn user_action_handler(
1335 &self,
1336 id: &str,
1337 ) -> Result<Arc<dyn super::runtime::ExtensionHandler>, String> {
1338 if let Some(handler) = self.extensions.get(id) {
1339 return Ok(handler.clone());
1340 }
1341 if self.is_deferred(id) {
1342 return Ok(Arc::new(
1343 crate::extensions::lease::LazyExtensionHandler::new(id, self.extension_runtime()),
1344 ));
1345 }
1346 Err(format!("unknown extension '{}'", id))
1347 }
1348
1349 pub fn session_driver_handler(&self, id: &str) -> Result<Arc<dyn ExtensionHandler>, String> {
1355 if self.is_deferred(id) {
1356 return Err("session drivers require an eagerly loaded extension; deferred drivers are unsupported".into());
1357 }
1358 let handler = self
1359 .extensions
1360 .get(id)
1361 .ok_or_else(|| "session driver extension is not loaded".to_string())?;
1362 if !self
1363 .eager_permissions
1364 .get(id)
1365 .is_some_and(|permissions| permissions.has(Permission::SessionDrive))
1366 {
1367 return Err("session driver extension lacks validated session.drive permission".into());
1368 }
1369 Ok(handler.clone())
1370 }
1371
1372 pub fn has_session_drive(&self, id: &str) -> bool {
1378 self.eager_permissions
1379 .get(id)
1380 .is_some_and(|permissions| permissions.has(Permission::SessionDrive))
1381 }
1382
1383 pub async fn sidecar_spawn_args(
1384 &self,
1385 id: &str,
1386 ) -> Result<crate::sidecar::spawn::SidecarSpawnArgs, String> {
1387 let handler = self.user_action_handler(id)?;
1388 handler.sidecar_spawn_args().await
1389 }
1390
1391 pub async fn invoke_command(
1402 &self,
1403 id: &str,
1404 command: &str,
1405 args: Vec<String>,
1406 request_id: &str,
1407 sink: crate::extensions::invoke_output::InvokeEventSink,
1408 ) -> Result<serde_json::Value, String> {
1409 let handler = self.user_action_handler(id)?;
1410 handler
1411 .invoke_command(command, args, request_id, sink)
1412 .await
1413 }
1414
1415 pub async fn invoke_command_collected(
1427 &self,
1428 id: &str,
1429 command: &str,
1430 args: Vec<String>,
1431 request_id: &str,
1432 ) -> (
1433 Result<serde_json::Value, String>,
1434 crate::extensions::invoke_output::InvokeOutputReport,
1435 ) {
1436 let (sink, collector) = crate::extensions::invoke_output::invoke_event_channel(
1437 crate::extensions::invoke_output::InvokeOutputBudget::default(),
1438 );
1439 let handler = match self.user_action_handler(id) {
1440 Ok(handler) => handler,
1441 Err(error) => {
1442 drop(sink);
1443 return (Err(error), collector.collect().await);
1444 }
1445 };
1446 let invoke = handler.invoke_command(command, args, request_id, sink);
1447 let (result, report) = tokio::join!(invoke, collector.collect());
1448 (result, report)
1449 }
1450
1451 pub async fn settings_editor_open(
1452 &self,
1453 id: &str,
1454 category: &str,
1455 field: &str,
1456 ) -> Result<serde_json::Value, String> {
1457 let handler = self.user_action_handler(id)?;
1458 handler.settings_editor_open(category, field).await
1459 }
1460
1461 pub async fn settings_editor_key(
1462 &self,
1463 id: &str,
1464 category: &str,
1465 field: &str,
1466 key: &str,
1467 ) -> Result<serde_json::Value, String> {
1468 let handler = self.user_action_handler(id)?;
1469 handler.settings_editor_key(category, field, key).await
1470 }
1471
1472 pub async fn settings_editor_commit(
1473 &self,
1474 id: &str,
1475 category: &str,
1476 field: &str,
1477 value: serde_json::Value,
1478 ) -> Result<serde_json::Value, String> {
1479 let handler = self.user_action_handler(id)?;
1480 handler.settings_editor_commit(category, field, value).await
1481 }
1482
1483 pub fn plugin_infos(&self) -> Vec<(&str, &PluginInfo)> {
1485 let mut entries: Vec<_> = self
1486 .plugin_info
1487 .iter()
1488 .map(|(id, info)| (id.as_str(), info))
1489 .collect();
1490 entries.sort_by(|a, b| a.0.cmp(b.0));
1491 entries
1492 }
1493
1494 pub fn provider_summaries(&self) -> Vec<RegisteredProviderSummary> {
1496 self.providers.summaries()
1497 }
1498
1499 pub async fn capability_snapshots(&self) -> Vec<ExtensionCapabilitySnapshot> {
1505 let mut handlers: Vec<(String, Arc<dyn ExtensionHandler>)> = self
1506 .extensions
1507 .iter()
1508 .map(|(id, handler)| (id.clone(), handler.clone()))
1509 .collect();
1510 handlers.sort_by(|a, b| a.0.cmp(&b.0));
1511
1512 let provider_summaries = self.providers.summaries();
1513 let plugin_id_lookup: std::collections::HashMap<String, String> = self
1514 .providers
1515 .list()
1516 .into_iter()
1517 .map(|p| (p.runtime_id.clone(), p.plugin_id.clone()))
1518 .collect();
1519
1520 let mut out = Vec::with_capacity(handlers.len());
1521 for (id, handler) in handlers {
1522 let health = handler.health().await;
1523 let restart_count = handler.restart_count().await;
1524
1525 let hook_pairs = self.hook_bus.subscriptions_for(&id).await;
1526 let hooks: Vec<HookCapabilityEntry> = hook_pairs
1527 .into_iter()
1528 .map(|(kind, tool_filter)| HookCapabilityEntry {
1529 kind: kind.as_str().to_string(),
1530 tool_filter,
1531 })
1532 .collect();
1533
1534 let tools: Vec<ToolCapabilityEntry> = if let Some(tools) = &self.tools {
1535 let registry = tools.read().await;
1536 registry
1537 .tool_names_for_extension(&id)
1538 .into_iter()
1539 .map(|name| ToolCapabilityEntry { name })
1540 .collect()
1541 } else {
1542 Vec::new()
1543 };
1544
1545 let providers: Vec<RegisteredProviderSummary> = provider_summaries
1546 .iter()
1547 .filter(|summary| {
1548 plugin_id_lookup
1549 .get(&summary.runtime_id)
1550 .map(|p| p == &id)
1551 .unwrap_or(false)
1552 })
1553 .cloned()
1554 .collect();
1555
1556 let future: Vec<FutureCapabilityEntry> = self
1557 .capabilities
1558 .get(&id)
1559 .map(|decls| {
1560 decls
1561 .iter()
1562 .map(|d| FutureCapabilityEntry {
1563 kind: d.kind.clone(),
1564 name: d.name.clone(),
1565 })
1566 .collect()
1567 })
1568 .unwrap_or_default();
1569
1570 out.push(ExtensionCapabilitySnapshot {
1571 id,
1572 health,
1573 restart_count,
1574 hooks,
1575 tools,
1576 providers,
1577 future,
1578 });
1579 }
1580 out
1581 }
1582
1583 pub fn provider_tool_use_runtime_ids(&self) -> Vec<String> {
1586 let mut ids: Vec<String> = self
1587 .providers
1588 .list()
1589 .into_iter()
1590 .filter(|p| {
1591 p.spec.models.iter().any(|m| {
1592 m.capabilities
1593 .get("tool_use")
1594 .and_then(|v| v.as_bool())
1595 .unwrap_or(false)
1596 })
1597 })
1598 .map(|p| p.runtime_id.clone())
1599 .collect();
1600 ids.sort();
1601 ids
1602 }
1603
1604 pub fn provider_trust_view(&self) -> std::collections::BTreeMap<String, bool> {
1610 let trust = match crate::extensions::trust::load_trust_state() {
1611 Ok(t) => t,
1612 Err(e) => {
1613 tracing::warn!("trust.json corrupt or unreadable, failing closed (all providers disabled): {e}");
1614 return self
1616 .providers
1617 .list()
1618 .into_iter()
1619 .map(|p| (p.runtime_id.clone(), false))
1620 .collect();
1621 }
1622 };
1623 self.providers
1624 .list()
1625 .into_iter()
1626 .map(|p| {
1627 let enabled = crate::extensions::trust::is_provider_enabled(&trust, &p.runtime_id);
1628 (p.runtime_id.clone(), enabled)
1629 })
1630 .collect()
1631 }
1632
1633 pub fn config_diagnostics(&self, id: &str) -> Option<ExtensionConfigDiagnostics> {
1636 let manifest_config = self.manifest_configs.get(id)?;
1637
1638 let mut provider_required: Vec<(String, Vec<String>)> = Vec::new();
1640 for provider in self.providers.list() {
1641 if provider.plugin_id != id {
1642 continue;
1643 }
1644 let required: Vec<String> = provider
1645 .spec
1646 .config_schema
1647 .as_ref()
1648 .and_then(|schema| schema.get("required"))
1649 .and_then(Value::as_array)
1650 .map(|arr| {
1651 arr.iter()
1652 .filter_map(|v| v.as_str().map(|s| s.to_string()))
1653 .collect()
1654 })
1655 .unwrap_or_default();
1656 provider_required.push((provider.provider_id.clone(), required));
1657 }
1658 provider_required.sort_by(|a, b| a.0.cmp(&b.0));
1659
1660 let env_lookup = |name: &str| std::env::var(name).ok();
1661 let plugin_config_lookup =
1662 |key: &str| crate::extensions::config_store::read_plugin_config(id, key);
1663 let legacy_config_lookup = |key: &str| crate::config::read_config_value(key);
1664
1665 Some(diagnose_extension_config(
1666 id,
1667 manifest_config,
1668 &provider_required,
1669 &env_lookup,
1670 &plugin_config_lookup,
1671 &legacy_config_lookup,
1672 ))
1673 }
1674
1675 pub fn all_config_diagnostics(&self) -> Vec<ExtensionConfigDiagnostics> {
1677 let mut ids: Vec<&String> = self.manifest_configs.keys().collect();
1678 ids.sort();
1679 ids.into_iter()
1680 .filter_map(|id| self.config_diagnostics(id))
1681 .collect()
1682 }
1683
1684 pub fn hook_bus(&self) -> &Arc<HookBus> {
1686 &self.hook_bus
1687 }
1688
1689 pub fn tools_shared(&self) -> Option<Arc<tokio::sync::RwLock<crate::ToolRegistry>>> {
1691 self.tools.clone()
1692 }
1693
1694 #[cfg(test)]
1697 pub(crate) fn register_provider_handler_for_tests(
1698 &mut self,
1699 plugin_id: &str,
1700 spec: crate::extensions::runtime::process::RegisteredProviderSpec,
1701 handler: Arc<dyn ExtensionHandler>,
1702 ) -> Result<String, String> {
1703 self.providers
1704 .register_with_handler(plugin_id, spec, Some(handler))
1705 }
1706
1707 pub async fn discover_and_load(&mut self) -> (Vec<String>, Vec<ExtensionLoadFailure>) {
1714 self.discover_and_load_with_progress(|_| {}).await
1715 }
1716
1717 pub async fn discover_and_load_with_progress<F>(
1721 &mut self,
1722 mut progress: F,
1723 ) -> (Vec<String>, Vec<ExtensionLoadFailure>)
1724 where
1725 F: FnMut(crate::extensions::loader::ExtensionLoaderEvent),
1726 {
1727 if let Some(done) = self.discovery_done() {
1730 tracing::debug!(
1731 loaded = done.0.len(),
1732 failed = done.1.len(),
1733 "Extension discovery already done; replaying recorded result"
1734 );
1735 return done;
1736 }
1737 self.discovery = DiscoveryState::Running;
1738 let (loaded, failed) = self.discover_and_load_uncached(&mut progress).await;
1739 self.discovery = DiscoveryState::Done {
1740 loaded: loaded.clone(),
1741 failed: failed.clone(),
1742 };
1743 (loaded, failed)
1744 }
1745
1746 async fn discover_and_load_uncached<F>(
1747 &mut self,
1748 progress: &mut F,
1749 ) -> (Vec<String>, Vec<ExtensionLoadFailure>)
1750 where
1751 F: FnMut(crate::extensions::loader::ExtensionLoaderEvent),
1752 {
1753 let mut plugin_roots = vec![crate::config::base_dir().join("plugins")];
1754 if !project_plugins_disabled() {
1755 if let Ok(cwd) = std::env::current_dir() {
1756 let project_plugins = cwd.join(".synaps").join("plugins");
1757 if project_plugins != plugin_roots[0] {
1758 plugin_roots.push(project_plugins);
1759 }
1760 }
1761 }
1762
1763 let mut plugin_dirs: HashMap<String, PathBuf> = HashMap::new();
1764 let mut failed: Vec<ExtensionLoadFailure> = Vec::new();
1765
1766 for plugins_dir in plugin_roots {
1767 if !plugins_dir.exists() {
1768 continue;
1769 }
1770
1771 let entries = match std::fs::read_dir(&plugins_dir) {
1772 Ok(e) => e,
1773 Err(e) => {
1774 tracing::warn!(path = %plugins_dir.display(), error = %e, "Failed to read plugins directory");
1775 failed.push(ExtensionLoadFailure::new(
1776 "plugins",
1777 Some(plugins_dir.clone()),
1778 format!("Failed to read plugins directory: {e}"),
1779 "Check directory permissions and retry",
1780 ));
1781 continue;
1782 }
1783 };
1784
1785 for entry in entries.flatten() {
1786 let plugin_name = entry.file_name().to_string_lossy().to_string();
1787 if is_inert_plugin_dir_name(&plugin_name) {
1788 tracing::debug!(
1789 plugin = %plugin_name,
1790 path = %entry.path().display(),
1791 "Skipping backup/disabled/temp plugin directory"
1792 );
1793 continue;
1794 }
1795 plugin_dirs.insert(plugin_name, entry.path());
1796 }
1797 }
1798
1799 let mut plugin_dirs: Vec<(String, PathBuf)> = plugin_dirs.into_iter().collect();
1800 plugin_dirs.sort_by(|a, b| a.0.cmp(&b.0));
1801
1802 let mut loaded = Vec::new();
1803 let disabled_plugins = crate::config::load_config().disabled_plugins;
1804 let plugins_state = {
1807 let state_path = crate::skills::state::PluginsState::default_path();
1808 crate::skills::state::PluginsState::load_from(&state_path).unwrap_or_default()
1809 };
1810 for (plugin_name, plugin_dir) in plugin_dirs {
1811 if disabled_plugins.iter().any(|d| d == &plugin_name) {
1812 tracing::debug!(plugin = %plugin_name, "Extension disabled via disabled_plugins config");
1813 continue;
1814 }
1815 if let Some(message) = installed_plugin_setup_failure_in(&plugins_state, &plugin_name) {
1816 tracing::warn!(plugin = %plugin_name, error = %message, "Skipping extension with failed post-install setup");
1817 failed.push(ExtensionLoadFailure::new(
1818 plugin_name,
1819 None,
1820 format!("Post-install setup failed: {message}"),
1821 "Open /plugins, reinstall or update the plugin after fixing setup; extension load is disabled until setup succeeds",
1822 ));
1823 continue;
1824 }
1825 let manifest_path = plugin_dir.join(".synaps-plugin").join("plugin.json");
1826 if !manifest_path.exists() {
1827 continue;
1828 }
1829
1830 let content = match std::fs::read_to_string(&manifest_path) {
1831 Ok(c) => c,
1832 Err(e) => {
1833 let reason = format!("Failed to read plugin manifest: {e}");
1834 tracing::warn!(plugin = %plugin_name, manifest = %manifest_path.display(), error = %e, "Failed to read plugin manifest");
1835 failed.push(ExtensionLoadFailure::new(
1836 plugin_name,
1837 Some(manifest_path),
1838 reason,
1839 "Check manifest file permissions, then run `plugin validate <plugin-dir>`",
1840 ));
1841 continue;
1842 }
1843 };
1844
1845 let json: serde_json::Value = match serde_json::from_str(&content) {
1846 Ok(v) => v,
1847 Err(e) => {
1848 let reason = format!("Invalid plugin manifest JSON: {e}");
1849 tracing::warn!(plugin = %plugin_name, manifest = %manifest_path.display(), error = %e, "Invalid plugin manifest JSON");
1850 failed.push(ExtensionLoadFailure::new(
1851 plugin_name,
1852 Some(manifest_path),
1853 reason,
1854 "Fix JSON syntax, then run `plugin validate <plugin-dir>`",
1855 ));
1856 continue;
1857 }
1858 };
1859
1860 let ext_value = match json.get("extension") {
1861 Some(v) => v.clone(),
1862 None => continue,
1863 };
1864
1865 let ext_manifest: ExtensionManifest = match serde_json::from_value(ext_value) {
1866 Ok(m) => m,
1867 Err(e) => {
1868 let reason = format!("Failed to parse extension manifest: {e}");
1869 tracing::warn!(plugin = %plugin_name, manifest = %manifest_path.display(), error = %e, "Failed to parse extension manifest");
1870 failed.push(ExtensionLoadFailure::new(
1871 plugin_name,
1872 Some(manifest_path),
1873 reason,
1874 "Check the `extension` block shape against docs/extensions/contract.json, then run `plugin validate <plugin-dir>`",
1875 ));
1876 continue;
1877 }
1878 };
1879
1880 #[allow(clippy::if_same_then_else)]
1881 let command = if std::path::Path::new(&ext_manifest.command).is_absolute() {
1882 ext_manifest.command.clone()
1883 } else if !ext_manifest.command.contains(std::path::MAIN_SEPARATOR)
1884 && !ext_manifest.command.contains('/')
1885 {
1886 ext_manifest.command.clone()
1887 } else {
1888 plugin_dir
1889 .join(&ext_manifest.command)
1890 .to_string_lossy()
1891 .to_string()
1892 };
1893
1894 let args: Vec<String> = ext_manifest
1895 .args
1896 .iter()
1897 .map(|arg| {
1898 let arg_path = plugin_dir.join(arg);
1899 if arg_path.exists() {
1900 if let (Ok(canonical), Ok(plugin_canonical)) =
1901 (arg_path.canonicalize(), plugin_dir.canonicalize())
1902 {
1903 if canonical.starts_with(&plugin_canonical) {
1904 return canonical.to_string_lossy().to_string();
1905 }
1906 }
1907 }
1908 arg.clone()
1909 })
1910 .collect();
1911
1912 let resolved = ExtensionManifest {
1913 theme_tokens: Default::default(),
1914 command,
1920 args,
1921 ..ext_manifest
1922 };
1923
1924 match self
1925 .load_with_cwd(&plugin_name, &resolved, Some(plugin_dir.clone()))
1926 .await
1927 {
1928 Ok(()) => {
1929 tracing::info!(plugin = %plugin_name, path = %plugin_dir.display(), "Extension loaded from plugins/");
1930 loaded.push(plugin_name.clone());
1931 progress(crate::extensions::loader::ExtensionLoaderEvent::Loaded {
1932 plugin: plugin_name,
1933 loaded: loaded.len(),
1934 failed: failed.len(),
1935 });
1936 }
1937 Err(e) => {
1938 tracing::warn!(plugin = %plugin_name, manifest = %manifest_path.display(), error = %e, "Failed to load extension");
1939 let setup_script = json
1940 .pointer("/extension/setup")
1941 .and_then(|v| v.as_str())
1942 .or_else(|| {
1943 json.pointer("/provides/sidecar/setup")
1944 .and_then(|v| v.as_str())
1945 });
1946 let hint = compute_extension_load_hint(&e, &plugin_dir, setup_script);
1947 let failure =
1948 ExtensionLoadFailure::new(plugin_name, Some(manifest_path), e, hint);
1949 failed.push(failure.clone());
1950 progress(crate::extensions::loader::ExtensionLoaderEvent::Failed {
1951 failure,
1952 loaded: loaded.len(),
1953 failed: failed.len(),
1954 });
1955 }
1956 }
1957 }
1958
1959 (loaded, failed)
1960 }
1961}
1962
1963#[cfg(test)]
1964mod invoke_command_collected_tests {
1965 use super::*;
1971 use crate::extensions::commands::CommandOutputEvent;
1972 use crate::extensions::invoke_output::INVOKE_RETAINED_BYTE_BUDGET;
1973 use crate::extensions::runtime::InvokeCommandEvent;
1974
1975 struct FloodingHandler;
1976
1977 #[async_trait::async_trait]
1978 impl super::ExtensionHandler for FloodingHandler {
1979 fn id(&self) -> &str {
1980 "flooding-fake"
1981 }
1982 async fn handle(
1983 &self,
1984 _event: &crate::extensions::hooks::events::HookEvent,
1985 ) -> crate::extensions::hooks::events::HookResult {
1986 Default::default()
1987 }
1988 async fn shutdown(&self) {}
1989
1990 async fn invoke_command(
1991 &self,
1992 _command: &str,
1993 _args: Vec<String>,
1994 _request_id: &str,
1995 sink: crate::extensions::invoke_output::InvokeEventSink,
1996 ) -> Result<Value, String> {
1997 for _ in 0..2048 {
2001 let event = InvokeCommandEvent::Output(CommandOutputEvent::Text {
2002 content: "f".repeat(4096),
2003 });
2004 if sink.send(event).await.is_err() {
2005 return Err("collector vanished".to_string());
2006 }
2007 }
2008 let _ = sink
2009 .send(InvokeCommandEvent::Output(CommandOutputEvent::Done))
2010 .await;
2011 Ok(serde_json::json!({"status": "flooded"}))
2012 }
2013 }
2014
2015 #[tokio::test]
2016 async fn collected_invoke_bounds_in_process_flood_without_deadlock() {
2017 let bus = Arc::new(HookBus::new());
2018 let mut mgr = ExtensionManager::new(bus);
2019 mgr.extensions
2020 .insert("flooding-fake".to_string(), Arc::new(FloodingHandler));
2021
2022 let (result, report) = tokio::time::timeout(
2023 std::time::Duration::from_secs(30),
2024 mgr.invoke_command_collected("flooding-fake", "any", vec![], "req-1"),
2025 )
2026 .await
2027 .expect("bounded sink + concurrent collector must not deadlock");
2028
2029 assert_eq!(result.expect("invoke ok")["status"], "flooded");
2030 let c = &report.counters;
2031 assert_eq!(c.produced_events, 2049);
2032 assert_eq!(c.produced_payload_bytes, 2048 * 4096);
2033 assert_eq!(c.consumed_events, c.produced_events);
2034 assert_eq!(c.consumed_payload_bytes, c.produced_payload_bytes);
2035 assert_eq!(c.retained_payload_bytes, INVOKE_RETAINED_BYTE_BUDGET as u64);
2036 assert!(c.saw_done);
2037 assert_eq!(
2038 c.consumed_payload_bytes,
2039 c.retained_payload_bytes + c.truncated_payload_bytes + c.dropped_payload_bytes,
2040 );
2041 assert_eq!(c.consumed_events, c.retained_events + c.dropped_events);
2042 }
2043
2044 #[tokio::test]
2045 async fn collected_invoke_reports_unknown_extension_with_empty_report() {
2046 let bus = Arc::new(HookBus::new());
2047 let mgr = ExtensionManager::new(bus);
2048 let (result, report) = mgr
2049 .invoke_command_collected("nope", "any", vec![], "req-1")
2050 .await;
2051 assert!(result
2052 .expect_err("unknown id")
2053 .contains("unknown extension"));
2054 assert!(report.events.is_empty());
2055 assert_eq!(report.counters.produced_events, 0);
2056 }
2057}
2058
2059#[cfg(test)]
2060mod tests {
2061 use super::*;
2062
2063 fn context_provider_manifest(provider_id: &str) -> ExtensionManifest {
2066 serde_json::from_value(serde_json::json!({
2067 "runtime": "process",
2068 "command": "/bin/false",
2069 "permissions": ["context_providers.register"],
2070 "deferred": {
2071 "context_providers": [{
2072 "id": provider_id,
2073 "capability": "project-memory",
2074 "description": "test context provider",
2075 "schema_version": 1
2076 }]
2077 }
2078 }))
2079 .unwrap()
2080 }
2081
2082 #[tokio::test]
2087 async fn context_provider_catalog_lists_resolves_and_unloads_exactly() {
2088 let mut mgr = ExtensionManager::new(Arc::new(HookBus::new()));
2089 mgr.set_progressive_deferral(true);
2090 mgr.load("mem-a", &context_provider_manifest("alpha"))
2091 .await
2092 .unwrap();
2093 mgr.load("mem-b", &context_provider_manifest("beta"))
2094 .await
2095 .unwrap();
2096
2097 let listed: Vec<String> = mgr
2099 .context_providers()
2100 .iter()
2101 .map(|d| d.runtime_address())
2102 .collect();
2103 assert_eq!(
2104 listed,
2105 vec![
2106 "extension:mem-a:alpha".to_string(),
2107 "extension:mem-b:beta".to_string(),
2108 ]
2109 );
2110 let alpha = super::super::context_provider::ContextProviderId::parse("alpha").unwrap();
2111 assert_eq!(
2112 mgr.context_provider(&alpha).map(|d| d.extension_id()),
2113 Some("mem-a")
2114 );
2115 let unknown = super::super::context_provider::ContextProviderId::parse("nope").unwrap();
2116 assert!(mgr.context_provider(&unknown).is_none(), "fail closed");
2117
2118 let runtime = mgr.extension_runtime();
2120 assert_eq!(
2121 runtime
2122 .resolve_context_provider(Some(&alpha))
2123 .map(|d| d.runtime_address()),
2124 Ok("extension:mem-a:alpha".to_string())
2125 );
2126
2127 mgr.load("mem-c", &context_provider_manifest("alpha"))
2130 .await
2131 .unwrap();
2132 assert!(mgr.context_provider(&alpha).is_none(), "ambiguous → None");
2133 assert_eq!(
2134 runtime.resolve_context_provider(Some(&alpha)),
2135 Err(super::super::context_provider::ContextProviderLookupError::Ambiguous)
2136 );
2137
2138 mgr.unload("mem-c").await.unwrap();
2140 assert_eq!(
2141 mgr.context_provider(&alpha).map(|d| d.extension_id()),
2142 Some("mem-a")
2143 );
2144 mgr.unload("mem-a").await.unwrap();
2145 assert_eq!(
2146 runtime.resolve_context_provider(Some(&alpha)),
2147 Err(super::super::context_provider::ContextProviderLookupError::NotRegistered)
2148 );
2149 assert_eq!(mgr.context_providers().len(), 1, "mem-b remains");
2150
2151 assert_eq!(mgr.count(), 0, "no live extension handlers");
2153 assert_eq!(runtime.lease_count(), 0, "no runtime leases");
2154 }
2155
2156 #[tokio::test]
2157 async fn capability_snapshots_empty_when_no_extensions() {
2158 let bus = Arc::new(HookBus::new());
2159 let mgr = ExtensionManager::new(bus);
2160 assert!(mgr.capability_snapshots().await.is_empty());
2161 }
2162
2163 #[tokio::test]
2170 async fn deferred_launch_record_never_leaks_secret_config_through_diagnostics() {
2171 const SECRET: &str = "task20-A3-sentinel-5f2c9d";
2172 const VAR: &str = "T20_A3_THIRD_PARTY_SECRET";
2176 std::env::set_var(VAR, SECRET);
2177
2178 let manifest: ExtensionManifest = serde_json::from_value(serde_json::json!({
2179 "runtime": "process",
2180 "command": "/bin/false",
2181 "permissions": ["tools.register"],
2182 "config": [{
2183 "key": "api_key",
2184 "type": "string",
2185 "required": true,
2186 "secret_env": VAR
2187 }],
2188 "deferred": {
2189 "tools": [{
2190 "name": "search",
2191 "description": "deferred search",
2192 "input_schema": {"type": "object"}
2193 }]
2194 }
2195 }))
2196 .unwrap();
2197
2198 let registry = Arc::new(tokio::sync::RwLock::new(crate::ToolRegistry::new()));
2199 let mut mgr = ExtensionManager::new_with_tools(Arc::new(HookBus::new()), registry);
2200 mgr.set_progressive_deferral(true);
2201 mgr.load("secretive", &manifest).await.unwrap();
2202
2203 assert!(mgr.is_deferred_tool_only("secretive"));
2205 assert_eq!(mgr.deferred_tool_only_count(), 1);
2206 let record = mgr.deferred_launch_record("secretive").expect("retained");
2208 assert_eq!(record.config["api_key"], serde_json::json!(SECRET));
2209
2210 let mut rendered = String::new();
2212 rendered.push_str(&format!("{:?}", mgr.statuses().await));
2213 rendered.push_str(&format!("{:?}", mgr.config_diagnostics("secretive")));
2214 rendered.push_str(&format!("{:?}", mgr.all_config_diagnostics()));
2215 rendered.push_str(&format!("{:?}", mgr.capability_snapshots().await));
2216 rendered.push_str(&format!("{:?}", mgr.plugin_infos()));
2217 rendered.push_str(&format!("{:?}", mgr.list()));
2218 assert!(
2219 !rendered.contains(SECRET),
2220 "resolved secret config leaked through a public diagnostic surface"
2221 );
2222
2223 std::env::remove_var(VAR);
2224 }
2225
2226 #[tokio::test]
2227 async fn capability_snapshot_lists_hooks_for_loaded_extension() {
2228 let bus = Arc::new(HookBus::new());
2229 let mut mgr = ExtensionManager::new(bus.clone());
2230 let manifest = ExtensionManifest {
2231 theme_tokens: Default::default(),
2232 deferred: None,
2233 protocol_version: 1,
2234 runtime: crate::extensions::manifest::ExtensionRuntime::Process,
2235 command: "python3".to_string(),
2236 setup: None,
2237 prebuilt: ::std::collections::HashMap::new(),
2238 args: vec![
2239 "tests/fixtures/process_extension.py".to_string(),
2240 "normal".to_string(),
2241 "/tmp/synaps-capability-test.log".to_string(),
2242 ],
2243 permissions: vec!["tools.intercept".to_string()],
2244 hooks: vec![crate::extensions::manifest::HookSubscription {
2245 hook: "before_tool_call".to_string(),
2246 tool: Some("bash".to_string()),
2247 matcher: None,
2248 }],
2249 config: vec![],
2250 };
2251
2252 mgr.load("cap-snap", &manifest).await.unwrap();
2253
2254 let snaps = mgr.capability_snapshots().await;
2255 assert_eq!(snaps.len(), 1);
2256 let snap = &snaps[0];
2257 assert_eq!(snap.id, "cap-snap");
2258 assert_eq!(snap.hooks.len(), 1);
2259 assert_eq!(snap.hooks[0].kind, "before_tool_call");
2260 assert_eq!(snap.hooks[0].tool_filter.as_deref(), Some("bash"));
2261 assert!(snap.tools.is_empty());
2262 assert!(snap.providers.is_empty());
2263 assert!(snap.future.is_empty());
2264
2265 mgr.shutdown_all().await;
2266 }
2267
2268 #[tokio::test]
2269 async fn capability_snapshot_surfaces_seeded_capabilities() {
2270 let bus = Arc::new(HookBus::new());
2271 let mut mgr = ExtensionManager::new(bus.clone());
2272 let manifest = ExtensionManifest {
2273 theme_tokens: Default::default(),
2274 deferred: None,
2275 protocol_version: 1,
2276 runtime: crate::extensions::manifest::ExtensionRuntime::Process,
2277 command: "python3".to_string(),
2278 setup: None,
2279 prebuilt: ::std::collections::HashMap::new(),
2280 args: vec![
2281 "tests/fixtures/process_extension.py".to_string(),
2282 "normal".to_string(),
2283 "/tmp/synaps-capability-snapshot-test.log".to_string(),
2284 ],
2285 permissions: vec!["tools.intercept".to_string()],
2286 hooks: vec![crate::extensions::manifest::HookSubscription {
2287 hook: "before_tool_call".to_string(),
2288 tool: Some("bash".to_string()),
2289 matcher: None,
2290 }],
2291 config: vec![],
2292 };
2293
2294 mgr.load("multi-cap", &manifest).await.unwrap();
2295
2296 mgr.test_seed_capabilities(
2300 "multi-cap",
2301 vec![
2302 crate::extensions::runtime::process::CapabilityDeclaration {
2303 kind: "capture".to_string(),
2304 name: "Local Sample STT".to_string(),
2305 permissions: vec!["audio.input".to_string()],
2306 params: serde_json::Value::Null,
2307 },
2308 crate::extensions::runtime::process::CapabilityDeclaration {
2309 kind: "ocr".to_string(),
2310 name: "Tesseract".to_string(),
2311 permissions: vec![],
2312 params: serde_json::Value::Null,
2313 },
2314 ],
2315 );
2316
2317 let snaps = mgr.capability_snapshots().await;
2318 let snap = snaps
2319 .iter()
2320 .find(|s| s.id == "multi-cap")
2321 .expect("multi-cap snapshot");
2322 assert_eq!(snap.future.len(), 2);
2323 let kinds: Vec<&str> = snap.future.iter().map(|e| e.kind.as_str()).collect();
2324 assert!(kinds.contains(&"capture"), "got kinds {:?}", kinds);
2325 assert!(kinds.contains(&"ocr"), "got kinds {:?}", kinds);
2326 let names: Vec<&str> = snap.future.iter().map(|e| e.name.as_str()).collect();
2327 assert!(names.contains(&"Local Sample STT"), "got {:?}", names);
2328 assert!(names.contains(&"Tesseract"), "got {:?}", names);
2329
2330 mgr.unload("multi-cap").await.unwrap();
2331 let snaps = mgr.capability_snapshots().await;
2332 assert!(snaps.iter().all(|s| s.id != "multi-cap"));
2333
2334 mgr.shutdown_all().await;
2335 }
2336
2337 #[tokio::test]
2338 async fn new_manager_has_no_extensions() {
2339 let bus = Arc::new(HookBus::new());
2340 let mgr = ExtensionManager::new(bus);
2341 assert_eq!(mgr.count(), 0);
2342 assert!(mgr.list().is_empty());
2343 }
2344
2345 #[tokio::test]
2346 async fn unload_nonexistent_returns_error() {
2347 let bus = Arc::new(HookBus::new());
2348 let mut mgr = ExtensionManager::new(bus);
2349 let result = mgr.unload("nope").await;
2350 assert!(result.is_err());
2351 }
2352
2353 #[tokio::test]
2354 async fn reload_unsubscribes_old_handler_before_loading_new_one() {
2355 let bus = Arc::new(HookBus::new());
2356 let mut mgr = ExtensionManager::new(bus.clone());
2357 let manifest = ExtensionManifest {
2358 theme_tokens: Default::default(),
2359 deferred: None,
2360 protocol_version: 1,
2361 runtime: crate::extensions::manifest::ExtensionRuntime::Process,
2362 command: "python3".to_string(),
2363 setup: None,
2364 prebuilt: ::std::collections::HashMap::new(),
2365 args: vec![
2366 "tests/fixtures/process_extension.py".to_string(),
2367 "normal".to_string(),
2368 "/tmp/synaps-reload-test.log".to_string(),
2369 ],
2370 permissions: vec!["tools.intercept".to_string()],
2371 hooks: vec![crate::extensions::manifest::HookSubscription {
2372 hook: "before_tool_call".to_string(),
2373 tool: Some("bash".to_string()),
2374 matcher: None,
2375 }],
2376 config: vec![],
2377 };
2378
2379 mgr.load("reload-test", &manifest).await.unwrap();
2380 assert_eq!(bus.handler_count().await, 1);
2381
2382 mgr.reload("reload-test", &manifest, None).await.unwrap();
2383
2384 assert_eq!(mgr.count(), 1);
2385 assert_eq!(bus.handler_count().await, 1);
2386 mgr.shutdown_all().await;
2387 }
2388
2389 #[tokio::test]
2390 async fn reload_failure_leaves_previous_instance_unloaded() {
2391 let bus = Arc::new(HookBus::new());
2392 let mut mgr = ExtensionManager::new(bus.clone());
2393 let good = ExtensionManifest {
2394 theme_tokens: Default::default(),
2395 deferred: None,
2396 protocol_version: 1,
2397 runtime: crate::extensions::manifest::ExtensionRuntime::Process,
2398 command: "python3".to_string(),
2399 setup: None,
2400 prebuilt: ::std::collections::HashMap::new(),
2401 args: vec![
2402 "tests/fixtures/process_extension.py".to_string(),
2403 "normal".to_string(),
2404 "/tmp/synaps-reload-failure-test.log".to_string(),
2405 ],
2406 permissions: vec!["tools.intercept".to_string()],
2407 hooks: vec![crate::extensions::manifest::HookSubscription {
2408 hook: "before_tool_call".to_string(),
2409 tool: Some("bash".to_string()),
2410 matcher: None,
2411 }],
2412 config: vec![],
2413 };
2414 let bad = ExtensionManifest {
2415 theme_tokens: Default::default(),
2416 deferred: None,
2417 command: "/definitely/not/a/real/extension-binary".to_string(),
2418 setup: None,
2419 prebuilt: ::std::collections::HashMap::new(),
2420 ..good.clone()
2421 };
2422
2423 mgr.load("reload-failure-test", &good).await.unwrap();
2424 let err = mgr
2425 .reload("reload-failure-test", &bad, None)
2426 .await
2427 .unwrap_err();
2428
2429 assert!(err.contains("Failed to spawn extension"), "{err}");
2430 assert_eq!(mgr.count(), 0);
2431 assert_eq!(bus.handler_count().await, 0);
2432 }
2433
2434 #[test]
2435 fn project_plugins_disable_env_parser_accepts_truthy_values() {
2436 for value in ["1", "true", "TRUE", "yes", "on"] {
2437 std::env::set_var("SYNAPS_DISABLE_PROJECT_PLUGINS", value);
2438 assert!(project_plugins_disabled());
2439 }
2440 for value in ["", "0", "false", "off", "no"] {
2441 std::env::set_var("SYNAPS_DISABLE_PROJECT_PLUGINS", value);
2442 assert!(!project_plugins_disabled());
2443 }
2444 std::env::remove_var("SYNAPS_DISABLE_PROJECT_PLUGINS");
2445 }
2446
2447 fn with_temp_base_dir<T>(path: &std::path::Path, f: impl FnOnce() -> T) -> T {
2448 let old_base_dir = std::env::var("SYNAPS_BASE_DIR").ok();
2449 crate::config::set_base_dir_for_tests(path.to_path_buf());
2450 let out = f();
2451 match old_base_dir {
2452 Some(old) => std::env::set_var("SYNAPS_BASE_DIR", old),
2453 None => std::env::remove_var("SYNAPS_BASE_DIR"),
2454 }
2455 out
2456 }
2457
2458 #[test]
2459 #[serial_test::serial(synaps_base_dir)]
2460 fn resolve_config_prefers_plugin_namespaced_config_before_legacy_global_key() {
2461 let dir = tempfile::tempdir().unwrap();
2462 with_temp_base_dir(dir.path(), || {
2463 crate::extensions::config_store::write_plugin_config(
2464 "sample-sidecar",
2465 "backend",
2466 "cpu",
2467 )
2468 .unwrap();
2469 crate::config::write_config_value("extension.sample-sidecar.backend", "auto").unwrap();
2470
2471 let resolved = ExtensionManager::resolve_config(
2472 "sample-sidecar",
2473 &[ExtensionConfigEntry {
2474 key: "backend".to_string(),
2475 value_type: None,
2476 description: None,
2477 required: true,
2478 default: None,
2479 secret_env: None,
2480 host_context: None,
2481 }],
2482 None,
2483 )
2484 .unwrap();
2485
2486 assert_eq!(
2487 resolved["backend"],
2488 serde_json::Value::String("cpu".to_string())
2489 );
2490 });
2491 }
2492
2493 #[test]
2494 #[serial_test::serial(synaps_base_dir)]
2495 fn resolve_config_keeps_legacy_global_extension_key_as_fallback() {
2496 let dir = tempfile::tempdir().unwrap();
2497 with_temp_base_dir(dir.path(), || {
2498 crate::config::write_config_value("extension.sample-sidecar.backend", "auto").unwrap();
2499
2500 let resolved = ExtensionManager::resolve_config(
2501 "sample-sidecar",
2502 &[ExtensionConfigEntry {
2503 key: "backend".to_string(),
2504 value_type: None,
2505 description: None,
2506 required: true,
2507 default: None,
2508 secret_env: None,
2509 host_context: None,
2510 }],
2511 None,
2512 )
2513 .unwrap();
2514
2515 assert_eq!(
2516 resolved["backend"],
2517 serde_json::Value::String("auto".to_string())
2518 );
2519 });
2520 }
2521
2522 #[tokio::test]
2523 async fn config_diagnostics_returns_none_for_unknown_extension() {
2524 let bus = Arc::new(HookBus::new());
2525 let mgr = ExtensionManager::new(bus);
2526 assert!(mgr.config_diagnostics("nope").is_none());
2527 assert!(mgr.all_config_diagnostics().is_empty());
2528 }
2529
2530 #[tokio::test]
2531 async fn config_diagnostics_reports_loaded_manifest_entries() {
2532 let bus = Arc::new(HookBus::new());
2533 let mut mgr = ExtensionManager::new(bus);
2534 let manifest = ExtensionManifest {
2535 theme_tokens: Default::default(),
2536 deferred: None,
2537 protocol_version: 1,
2538 runtime: crate::extensions::manifest::ExtensionRuntime::Process,
2539 command: "python3".to_string(),
2540 setup: None,
2541 prebuilt: ::std::collections::HashMap::new(),
2542 args: vec![
2543 "tests/fixtures/process_extension.py".to_string(),
2544 "normal".to_string(),
2545 "/tmp/synaps-config-diag-test.log".to_string(),
2546 ],
2547 permissions: vec!["tools.intercept".to_string()],
2548 hooks: vec![crate::extensions::manifest::HookSubscription {
2549 hook: "before_tool_call".to_string(),
2550 tool: Some("bash".to_string()),
2551 matcher: None,
2552 }],
2553 config: vec![crate::extensions::manifest::ExtensionConfigEntry {
2554 key: "region".to_string(),
2555 value_type: None,
2556 description: Some("AWS region".to_string()),
2557 required: false,
2558 default: Some(serde_json::Value::String("us-east-1".to_string())),
2559 secret_env: None,
2560 host_context: None,
2561 }],
2562 };
2563
2564 mgr.load("config-diag-test", &manifest).await.unwrap();
2565
2566 let diag = mgr
2567 .config_diagnostics("config-diag-test")
2568 .expect("diagnostics should be available for loaded extension");
2569 assert_eq!(diag.extension_id, "config-diag-test");
2570 assert_eq!(diag.entries.len(), 1);
2571 assert_eq!(diag.entries[0].key, "region");
2572 assert!(diag.entries[0].has_value);
2573 assert!(diag.provider_missing.is_empty());
2574
2575 let all = mgr.all_config_diagnostics();
2576 assert_eq!(all.len(), 1);
2577 assert_eq!(all[0].extension_id, "config-diag-test");
2578
2579 mgr.shutdown_all().await;
2580 assert!(mgr.config_diagnostics("config-diag-test").is_none());
2582 }
2583
2584 #[tokio::test]
2585 async fn provider_trust_view_is_empty_for_no_providers() {
2586 let bus = Arc::new(HookBus::new());
2587 let mgr = ExtensionManager::new(bus);
2588 let view = mgr.provider_trust_view();
2589 assert!(view.is_empty());
2590 }
2591
2592 #[tokio::test]
2593 async fn provider_tool_use_runtime_ids_lists_only_tool_use_capable() {
2594 use crate::extensions::runtime::process::{
2595 RegisteredProviderModelSpec, RegisteredProviderSpec,
2596 };
2597 let bus = Arc::new(HookBus::new());
2598 let mut mgr = ExtensionManager::new(bus);
2599 let tool_spec = RegisteredProviderSpec {
2601 id: "alpha".into(),
2602 display_name: "Alpha".into(),
2603 description: "tool-use".into(),
2604 models: vec![RegisteredProviderModelSpec {
2605 id: "m1".into(),
2606 display_name: None,
2607 capabilities: serde_json::json!({"tool_use": true}),
2608 context_window: None,
2609 }],
2610 config_schema: None,
2611 };
2612 let plain_spec = RegisteredProviderSpec {
2614 id: "beta".into(),
2615 display_name: "Beta".into(),
2616 description: "plain".into(),
2617 models: vec![RegisteredProviderModelSpec {
2618 id: "m1".into(),
2619 display_name: None,
2620 capabilities: serde_json::json!({"streaming": true}),
2621 context_window: None,
2622 }],
2623 config_schema: None,
2624 };
2625 mgr.providers.register("plug", tool_spec).unwrap();
2626 mgr.providers.register("plug", plain_spec).unwrap();
2627 let ids = mgr.provider_tool_use_runtime_ids();
2628 assert_eq!(ids, vec!["plug:alpha".to_string()]);
2629 }
2630
2631 #[test]
2634 fn hint_missing_binary_with_declared_setup_points_at_script() {
2635 let hint = compute_extension_load_hint(
2636 "Failed to spawn extension 'sample-sidecar': No such file or directory (os error 2)",
2637 std::path::Path::new("/home/u/.synaps-cli/plugins/sample-sidecar"),
2638 Some("scripts/setup.sh"),
2639 );
2640 assert!(
2641 hint.contains("Extension binary missing"),
2642 "missing-binary case should be flagged: {hint}"
2643 );
2644 assert!(
2645 hint.contains("/home/u/.synaps-cli/plugins/sample-sidecar"),
2646 "hint should include the plugin dir: {hint}"
2647 );
2648 assert!(
2649 hint.contains("setup=scripts/setup.sh"),
2650 "hint should show sanitized setup path without copy-paste shell command: {hint}"
2651 );
2652 }
2653
2654 #[test]
2655 fn hint_missing_binary_without_declared_setup_falls_back_to_generic() {
2656 let hint = compute_extension_load_hint(
2657 "Failed to spawn extension 'foo': No such file or directory (os error 2)",
2658 std::path::Path::new("/x/y"),
2659 None,
2660 );
2661 assert!(
2662 hint.contains("plugin validate"),
2663 "no setup declared → generic hint: {hint}"
2664 );
2665 assert!(
2666 !hint.contains("Extension binary missing"),
2667 "should not falsely promise a setup script: {hint}"
2668 );
2669 }
2670
2671 #[test]
2672 fn hint_other_error_with_declared_setup_falls_back_to_generic() {
2673 let hint = compute_extension_load_hint(
2674 "Extension 'foo' must subscribe to at least one hook or request a registration permission",
2675 std::path::Path::new("/x/y"),
2676 Some("scripts/setup.sh"),
2677 );
2678 assert!(hint.contains("plugin validate"), "got {hint}");
2682 assert!(!hint.contains("Extension binary missing"), "got {hint}");
2683 }
2684
2685 #[test]
2686 fn hint_recognises_os_error_2_format() {
2687 let hint = compute_extension_load_hint(
2690 "spawn failed (os error 2)",
2691 std::path::Path::new("/p"),
2692 Some("setup.sh"),
2693 );
2694 assert!(hint.contains("Extension binary missing"), "got {hint}");
2695 }
2696
2697 #[test]
2698 fn hint_exclusive_axel_is_expected_not_missing_binary() {
2699 let hint = compute_extension_load_hint(
2700 "independent Axel plugin is disabled by the host-selected memory backend",
2701 std::path::Path::new("/synthetic/plugin"),
2702 None,
2703 );
2704 assert!(hint.contains("Expected: integrated Axel"));
2705 assert!(!hint.contains("plugin validate"));
2706 }
2707
2708 fn write_installed_plugin(plugins_root: &std::path::Path, dir_name: &str) {
2713 let plugin_dir = plugins_root.join(dir_name).join(".synaps-plugin");
2714 std::fs::create_dir_all(&plugin_dir).unwrap();
2715 let manifest = serde_json::json!({
2716 "extension": {
2717 "runtime": "process",
2718 "command": "/bin/false",
2719 "permissions": ["tools.register"],
2720 "deferred": {
2721 "tools": [{
2722 "name": "memory_fetch",
2723 "description": "Fetch one memory by exact ID.",
2724 "input_schema": {
2725 "type": "object",
2726 "properties": {"id": {"type": "string"}},
2727 "required": ["id"]
2728 }
2729 }]
2730 }
2731 }
2732 });
2733 std::fs::write(
2734 plugin_dir.join("plugin.json"),
2735 serde_json::to_string_pretty(&manifest).unwrap(),
2736 )
2737 .unwrap();
2738 }
2739
2740 #[tokio::test]
2750 #[serial_test::serial(synaps_base_dir)]
2751 async fn discover_and_load_skips_backup_and_disabled_plugin_directories() {
2752 let base = crate::test_env::BaseDirGuard::new();
2753 let _no_project_plugins =
2754 crate::test_env::EnvVarGuard::set("SYNAPS_DISABLE_PROJECT_PLUGINS", "1");
2755 let plugins_root = base.path().join("plugins");
2756
2757 write_installed_plugin(&plugins_root, "axel-memory-manager");
2758 for inert in [
2761 "axel-memory-manager.backup.20260719210053",
2762 "axel-memory-manager.backup.20260720101416",
2763 "axel-memory-manager.update-backup",
2764 "axel-memory-manager.backup",
2765 "axel-memory-manager.bak",
2766 "axel-memory-manager.disabled",
2767 "axel-memory-manager.old",
2768 "axel-memory-manager.orig",
2769 "axel-memory-manager.tmp",
2770 ] {
2771 write_installed_plugin(&plugins_root, inert);
2772 }
2773 write_installed_plugin(&plugins_root, "com.example.notes");
2775
2776 let registry = Arc::new(tokio::sync::RwLock::new(
2777 crate::ToolRegistry::without_subagent(),
2778 ));
2779 let mut mgr = ExtensionManager::new_with_tools(Arc::new(HookBus::new()), registry.clone());
2780 mgr.set_progressive_deferral(true);
2781
2782 let (mut loaded, failed) = mgr.discover_and_load().await;
2783 loaded.sort();
2784
2785 assert!(failed.is_empty(), "no failures expected: {failed:?}");
2786 assert_eq!(
2787 loaded,
2788 vec![
2789 "axel-memory-manager".to_string(),
2790 "com.example.notes".to_string()
2791 ],
2792 "backup/disabled/temp plugin directories must not be discovered"
2793 );
2794
2795 let reg = registry.read().await;
2796 assert!(reg.get("axel-memory-manager:memory_fetch").is_some());
2797 assert!(reg.get("com.example.notes:memory_fetch").is_some());
2798 assert!(
2799 reg.get("axel-memory-manager.backup.20260719210053:memory_fetch")
2800 .is_none(),
2801 "backup plugin tools must never be cataloged"
2802 );
2803 assert!(reg
2804 .get("axel-memory-manager.backup.20260720101416:memory_fetch")
2805 .is_none());
2806 drop(reg);
2807
2808 assert_eq!(mgr.count(), 0, "discovery must spawn nothing");
2811 }
2812
2813 #[test]
2817 fn inert_plugin_dir_name_filter_is_exact() {
2818 for inert in [
2819 "axel-memory-manager.backup.20260720101416",
2820 "axel-memory-manager.BACKUP.1",
2821 "axel-memory-manager.backup",
2822 "axel-memory-manager.update-backup",
2823 "axel-memory-manager.bak",
2824 "axel-memory-manager.disabled",
2825 "axel-memory-manager.old",
2826 "axel-memory-manager.orig",
2827 "axel-memory-manager.tmp",
2828 "axel-memory-manager.temp",
2829 ] {
2830 assert!(
2831 is_inert_plugin_dir_name(inert),
2832 "{inert} should be excluded from discovery"
2833 );
2834 }
2835 for live in [
2836 "axel-memory-manager",
2837 "com.example.notes",
2838 "backup-manager",
2839 "old-school-tools",
2840 "tmpfs-inspector",
2841 "my.backups.viewer",
2842 ] {
2843 assert!(
2844 !is_inert_plugin_dir_name(live),
2845 "{live} is a legitimate plugin name and must stay discoverable"
2846 );
2847 }
2848 }
2849}
2850
2851#[cfg(test)]
2852mod secret_env_allowlist_tests {
2853 use super::*;
2854
2855 fn entry(key: &str, secret_env: &str) -> ExtensionConfigEntry {
2856 serde_json::from_value(serde_json::json!({
2857 "key": key,
2858 "type": "string",
2859 "secret_env": secret_env,
2860 }))
2861 .unwrap()
2862 }
2863
2864 #[test]
2867 fn host_provider_credentials_are_rejected() {
2868 for var in [
2869 "ANTHROPIC_API_KEY",
2870 "OPENAI_API_KEY",
2871 "AWS_SECRET_ACCESS_KEY",
2872 "anthropic_api_key", ] {
2874 let err = ExtensionManager::resolve_config("evil", &[entry("k", var)], None)
2875 .expect_err(&format!("{var} must be rejected"));
2876 assert!(err.contains("host credential"), "unexpected error: {err}");
2877 }
2878 }
2879
2880 #[test]
2883 fn host_namespace_is_rejected_but_own_scope_is_allowed() {
2884 let err =
2885 ExtensionManager::resolve_config("evil", &[entry("k", "SYNAPS_BROKER_TOKEN")], None)
2886 .expect_err("host namespace must be rejected");
2887 assert!(err.contains("host namespace"), "unexpected error: {err}");
2888
2889 assert!(ExtensionManager::denied_secret_env(
2891 "SYNAPS_EXTENSION_MY_PLUGIN_TOKEN",
2892 "my-plugin"
2893 )
2894 .is_none());
2895 }
2896
2897 #[test]
2899 fn third_party_secrets_are_still_allowed() {
2900 for var in ["GITHUB_TOKEN", "STRIPE_KEY", "MY_SERVICE_TOKEN"] {
2901 assert!(
2902 ExtensionManager::denied_secret_env(var, "some-ext").is_none(),
2903 "{var} should be allowed"
2904 );
2905 }
2906 }
2907
2908 #[test]
2909 fn empty_secret_env_is_rejected() {
2910 assert!(ExtensionManager::denied_secret_env(" ", "x").is_some());
2911 }
2912}