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 denied_secret_env(var: &str, id: &str) -> Option<&'static str> {
885 const HOST_CREDENTIALS: &[&str] = &[
886 "ANTHROPIC_API_KEY",
887 "OPENAI_API_KEY",
888 "GEMINI_API_KEY",
889 "GOOGLE_API_KEY",
890 "XAI_API_KEY",
891 "GROQ_API_KEY",
892 "OPENROUTER_API_KEY",
893 "DEEPSEEK_API_KEY",
894 "MISTRAL_API_KEY",
895 "AWS_SECRET_ACCESS_KEY",
896 "AWS_SESSION_TOKEN",
897 ];
898 let up = var.trim().to_ascii_uppercase();
899 if up.is_empty() {
900 return Some("secret_env must not be empty");
901 }
902 if HOST_CREDENTIALS.contains(&up.as_str()) {
903 return Some(
904 "that variable holds a host credential; extensions are never handed the host's own provider keys",
905 );
906 }
907 let own_prefix = format!(
908 "SYNAPS_EXTENSION_{}_",
909 id.replace('-', "_").to_ascii_uppercase()
910 );
911 if up.starts_with("SYNAPS_") && !up.starts_with(&own_prefix) {
912 return Some(
913 "SYNAPS_* is the host namespace; declare your own SYNAPS_EXTENSION_<ID>_<KEY> variable instead",
914 );
915 }
916 None
917 }
918
919 fn resolve_config(
920 id: &str,
921 entries: &[ExtensionConfigEntry],
922 host_project_root: Option<&str>,
923 ) -> Result<Value, String> {
924 let mut out = Map::new();
925 for entry in entries {
926 let key = entry.key.trim();
927 if key.is_empty() {
928 return Err(format!("Extension '{}' declares config with empty key", id));
929 }
930 if key.contains('.') || key.contains('/') || key.contains(' ') {
931 return Err(format!(
932 "Extension '{}' config key '{}' must not contain dots, slashes, or spaces",
933 id, key,
934 ));
935 }
936 if let Some(host_key) = entry.host_context {
942 if entry.secret_env.is_some() {
943 return Err(format!(
944 "Extension '{}' config key '{}' declares both host_context and secret_env; host-context values never carry secrets",
945 id, key,
946 ));
947 }
948 match host_key {
949 crate::extensions::manifest::HostContextKey::ProjectRoot => {
950 if let Some(root) = host_project_root {
951 out.insert(key.to_string(), Value::String(root.to_string()));
952 } else if let Some(default) = &entry.default {
953 out.insert(key.to_string(), default.clone());
954 } else if entry.required {
955 return Err(format!(
956 "Extension '{}' requires host context 'project_root' but the host could not resolve a trusted project root",
957 id,
958 ));
959 }
960 }
961 }
962 continue;
963 }
964 let config_key = format!("extension.{}.{}", id, key);
965 if let Some(secret_env) = &entry.secret_env {
969 if let Some(reason) = Self::denied_secret_env(secret_env, id) {
970 return Err(format!(
971 "Extension '{}' config key '{}' declares secret_env '{}': {}",
972 id, key, secret_env, reason
973 ));
974 }
975 }
976 if let Ok(value) = std::env::var(format!(
977 "SYNAPS_EXTENSION_{}_{}",
978 id.replace('-', "_").to_ascii_uppercase(),
979 key.replace('-', "_").to_ascii_uppercase()
980 )) {
981 out.insert(key.to_string(), Value::String(value));
982 continue;
983 }
984 if let Some(secret_env) = &entry.secret_env {
985 if let Ok(value) = std::env::var(secret_env) {
986 out.insert(key.to_string(), Value::String(value));
987 continue;
988 }
989 }
990 if let Some(value) = crate::extensions::config_store::read_plugin_config(id, key) {
991 out.insert(key.to_string(), Value::String(value));
992 continue;
993 }
994 if let Some(value) = crate::config::read_config_value(&config_key) {
995 out.insert(key.to_string(), Value::String(value));
996 continue;
997 }
998 if let Some(default) = &entry.default {
999 out.insert(key.to_string(), default.clone());
1000 continue;
1001 }
1002 if entry.required {
1003 let hint = if let Some(secret_env) = &entry.secret_env {
1004 format!(
1005 "set environment variable '{}' or config key '{}'",
1006 secret_env, config_key
1007 )
1008 } else {
1009 format!("set config key '{}'", config_key)
1010 };
1011 return Err(format!(
1012 "Extension '{}' missing required config '{}': {}",
1013 id, key, hint
1014 ));
1015 }
1016 }
1017 Ok(Value::Object(out))
1018 }
1019
1020 #[cfg(test)]
1024 pub(crate) fn test_seed_capabilities(
1025 &mut self,
1026 id: &str,
1027 decls: Vec<crate::extensions::runtime::process::CapabilityDeclaration>,
1028 ) {
1029 self.capabilities.insert(id.to_string(), decls);
1030 }
1031
1032 pub async fn unload(&mut self, id: &str) -> Result<(), String> {
1040 let deferred = self
1041 .deferred_tool_only
1042 .lock()
1043 .unwrap_or_else(std::sync::PoisonError::into_inner)
1044 .remove(id);
1045 if let Some(record) = deferred {
1046 self.uncatalog_context_providers(id);
1052 if let Some(runtime) = self.extension_runtime.get() {
1053 let revoked = runtime.revoke_plugin_all_sessions(id);
1054 if revoked > 0 {
1055 tracing::info!(extension = %id, leases = revoked,
1056 "Terminated live runtime leases of unloaded deferred extension");
1057 }
1058 }
1059 self.hook_bus.unsubscribe_all(id).await;
1060 self.providers.unregister_plugin(id);
1061 if let Some(tools) = &self.tools {
1062 let names: Vec<String> = record
1063 .manifest
1064 .deferred
1065 .as_ref()
1066 .map(|d| {
1067 d.tools
1068 .iter()
1069 .map(|t| format!("{}:{}", id, t.name))
1070 .collect()
1071 })
1072 .unwrap_or_default();
1073 if let Err(error) = tools.write().await.try_disable(&names) {
1074 return Err(format!(
1078 "Extension '{}' unloaded (record removed, leases revoked) but its dormant catalog batch could not be deregistered: {}",
1079 id, error
1080 ));
1081 }
1082 }
1083 self.manifest_configs.remove(id);
1084 self.manifest_theme_tokens.remove(id);
1085 tracing::info!(extension = %id, "Deferred extension unloaded");
1086 return Ok(());
1087 }
1088 let handler = self
1089 .extensions
1090 .remove(id)
1091 .ok_or_else(|| format!("Extension '{}' not found", id))?;
1092 self.uncatalog_context_providers(id);
1095
1096 self.hook_bus.unsubscribe_all(id).await;
1097 self.providers.unregister_plugin(id);
1098 self.manifest_configs.remove(id);
1099 self.capabilities.remove(id);
1100 self.plugin_info.remove(id);
1101 self.manifest_theme_tokens.remove(id);
1102 handler.shutdown().await;
1103
1104 tracing::info!(extension = %id, "Extension unloaded");
1105 Ok(())
1106 }
1107
1108 pub async fn reload(
1112 &mut self,
1113 id: &str,
1114 manifest: &ExtensionManifest,
1115 cwd: Option<std::path::PathBuf>,
1116 ) -> Result<(), String> {
1117 if self.extensions.contains_key(id) || self.is_deferred_tool_only(id) {
1118 self.unload(id).await?;
1119 }
1120 self.load_with_cwd(id, manifest, cwd).await
1121 }
1122
1123 pub async fn shutdown_all(&mut self) {
1125 let ids: Vec<String> = self.extensions.keys().cloned().collect();
1126 for id in ids {
1127 let _ = self.unload(&id).await;
1128 }
1129 let deferred_ids: Vec<String> = {
1132 let map = self
1133 .deferred_tool_only
1134 .lock()
1135 .unwrap_or_else(std::sync::PoisonError::into_inner);
1136 map.keys().cloned().collect()
1137 };
1138 for id in deferred_ids {
1139 let _ = self.unload(&id).await;
1140 }
1141 if let Some(runtime) = self.extension_runtime.get() {
1142 runtime.terminate_all();
1143 }
1144 }
1145
1146 pub fn shutdown_all_detached(
1152 manager: Arc<tokio::sync::RwLock<Self>>,
1153 ) -> tokio::task::JoinHandle<()> {
1154 tokio::spawn(async move {
1155 manager.write().await.shutdown_all().await;
1156 })
1157 }
1158
1159 pub fn list(&self) -> Vec<&str> {
1161 self.extensions.keys().map(|s| s.as_str()).collect()
1162 }
1163
1164 pub fn handlers(&self) -> Vec<(String, Arc<dyn super::runtime::ExtensionHandler>)> {
1168 let mut out: Vec<_> = self
1169 .extensions
1170 .iter()
1171 .map(|(id, h)| (id.clone(), Arc::clone(h)))
1172 .collect();
1173 out.sort_by(|a, b| a.0.cmp(&b.0));
1174 out
1175 }
1176
1177 pub fn count(&self) -> usize {
1179 self.extensions.len()
1180 }
1181
1182 pub async fn statuses(&self) -> Vec<ExtensionStatus> {
1184 let mut handlers: Vec<(String, Arc<dyn ExtensionHandler>)> = self
1185 .extensions
1186 .iter()
1187 .map(|(id, handler)| (id.clone(), handler.clone()))
1188 .collect();
1189 handlers.sort_by(|a, b| a.0.cmp(&b.0));
1190
1191 let mut statuses = Vec::with_capacity(handlers.len());
1192 for (id, handler) in handlers {
1193 statuses.push(ExtensionStatus {
1194 id,
1195 health: handler.health().await,
1196 restart_count: handler.restart_count().await,
1197 });
1198 }
1199 statuses
1200 }
1201
1202 pub fn providers(&self) -> Vec<&RegisteredProvider> {
1204 self.providers.list()
1205 }
1206
1207 pub fn provider(&self, runtime_id: &str) -> Option<&RegisteredProvider> {
1209 self.providers.get(runtime_id)
1210 }
1211
1212 pub fn context_providers(&self) -> Vec<&RegisteredContextProviderDescriptor> {
1218 let mut out: Vec<&RegisteredContextProviderDescriptor> =
1219 self.context_providers.iter().collect();
1220 out.sort_by_key(|descriptor| descriptor.runtime_address());
1221 out
1222 }
1223
1224 pub fn context_provider(
1231 &self,
1232 provider_id: &ContextProviderId,
1233 ) -> Option<&RegisteredContextProviderDescriptor> {
1234 resolve_context_provider(&self.context_providers, Some(provider_id)).ok()
1235 }
1236
1237 pub fn plugin_info(&self, id: &str) -> Option<&PluginInfo> {
1239 self.plugin_info.get(id)
1240 }
1241
1242 pub fn theme_tokens(&self) -> Vec<(String, std::collections::BTreeMap<String, String>)> {
1247 let mut out: Vec<_> = self
1248 .manifest_theme_tokens
1249 .iter()
1250 .map(|(id, tokens)| (id.clone(), tokens.clone()))
1251 .collect();
1252 out.sort_by(|a, b| a.0.cmp(&b.0));
1253 out
1254 }
1255
1256 fn user_action_handler(
1267 &self,
1268 id: &str,
1269 ) -> Result<Arc<dyn super::runtime::ExtensionHandler>, String> {
1270 if let Some(handler) = self.extensions.get(id) {
1271 return Ok(handler.clone());
1272 }
1273 if self.is_deferred(id) {
1274 return Ok(Arc::new(
1275 crate::extensions::lease::LazyExtensionHandler::new(id, self.extension_runtime()),
1276 ));
1277 }
1278 Err(format!("unknown extension '{}'", id))
1279 }
1280
1281 pub async fn sidecar_spawn_args(
1282 &self,
1283 id: &str,
1284 ) -> Result<crate::sidecar::spawn::SidecarSpawnArgs, String> {
1285 let handler = self.user_action_handler(id)?;
1286 handler.sidecar_spawn_args().await
1287 }
1288
1289 pub async fn invoke_command(
1300 &self,
1301 id: &str,
1302 command: &str,
1303 args: Vec<String>,
1304 request_id: &str,
1305 sink: crate::extensions::invoke_output::InvokeEventSink,
1306 ) -> Result<serde_json::Value, String> {
1307 let handler = self.user_action_handler(id)?;
1308 handler
1309 .invoke_command(command, args, request_id, sink)
1310 .await
1311 }
1312
1313 pub async fn invoke_command_collected(
1325 &self,
1326 id: &str,
1327 command: &str,
1328 args: Vec<String>,
1329 request_id: &str,
1330 ) -> (
1331 Result<serde_json::Value, String>,
1332 crate::extensions::invoke_output::InvokeOutputReport,
1333 ) {
1334 let (sink, collector) = crate::extensions::invoke_output::invoke_event_channel(
1335 crate::extensions::invoke_output::InvokeOutputBudget::default(),
1336 );
1337 let handler = match self.user_action_handler(id) {
1338 Ok(handler) => handler,
1339 Err(error) => {
1340 drop(sink);
1341 return (Err(error), collector.collect().await);
1342 }
1343 };
1344 let invoke = handler.invoke_command(command, args, request_id, sink);
1345 let (result, report) = tokio::join!(invoke, collector.collect());
1346 (result, report)
1347 }
1348
1349 pub async fn settings_editor_open(
1350 &self,
1351 id: &str,
1352 category: &str,
1353 field: &str,
1354 ) -> Result<serde_json::Value, String> {
1355 let handler = self.user_action_handler(id)?;
1356 handler.settings_editor_open(category, field).await
1357 }
1358
1359 pub async fn settings_editor_key(
1360 &self,
1361 id: &str,
1362 category: &str,
1363 field: &str,
1364 key: &str,
1365 ) -> Result<serde_json::Value, String> {
1366 let handler = self.user_action_handler(id)?;
1367 handler.settings_editor_key(category, field, key).await
1368 }
1369
1370 pub async fn settings_editor_commit(
1371 &self,
1372 id: &str,
1373 category: &str,
1374 field: &str,
1375 value: serde_json::Value,
1376 ) -> Result<serde_json::Value, String> {
1377 let handler = self.user_action_handler(id)?;
1378 handler.settings_editor_commit(category, field, value).await
1379 }
1380
1381 pub fn plugin_infos(&self) -> Vec<(&str, &PluginInfo)> {
1383 let mut entries: Vec<_> = self
1384 .plugin_info
1385 .iter()
1386 .map(|(id, info)| (id.as_str(), info))
1387 .collect();
1388 entries.sort_by(|a, b| a.0.cmp(b.0));
1389 entries
1390 }
1391
1392 pub fn provider_summaries(&self) -> Vec<RegisteredProviderSummary> {
1394 self.providers.summaries()
1395 }
1396
1397 pub async fn capability_snapshots(&self) -> Vec<ExtensionCapabilitySnapshot> {
1403 let mut handlers: Vec<(String, Arc<dyn ExtensionHandler>)> = self
1404 .extensions
1405 .iter()
1406 .map(|(id, handler)| (id.clone(), handler.clone()))
1407 .collect();
1408 handlers.sort_by(|a, b| a.0.cmp(&b.0));
1409
1410 let provider_summaries = self.providers.summaries();
1411 let plugin_id_lookup: std::collections::HashMap<String, String> = self
1412 .providers
1413 .list()
1414 .into_iter()
1415 .map(|p| (p.runtime_id.clone(), p.plugin_id.clone()))
1416 .collect();
1417
1418 let mut out = Vec::with_capacity(handlers.len());
1419 for (id, handler) in handlers {
1420 let health = handler.health().await;
1421 let restart_count = handler.restart_count().await;
1422
1423 let hook_pairs = self.hook_bus.subscriptions_for(&id).await;
1424 let hooks: Vec<HookCapabilityEntry> = hook_pairs
1425 .into_iter()
1426 .map(|(kind, tool_filter)| HookCapabilityEntry {
1427 kind: kind.as_str().to_string(),
1428 tool_filter,
1429 })
1430 .collect();
1431
1432 let tools: Vec<ToolCapabilityEntry> = if let Some(tools) = &self.tools {
1433 let registry = tools.read().await;
1434 registry
1435 .tool_names_for_extension(&id)
1436 .into_iter()
1437 .map(|name| ToolCapabilityEntry { name })
1438 .collect()
1439 } else {
1440 Vec::new()
1441 };
1442
1443 let providers: Vec<RegisteredProviderSummary> = provider_summaries
1444 .iter()
1445 .filter(|summary| {
1446 plugin_id_lookup
1447 .get(&summary.runtime_id)
1448 .map(|p| p == &id)
1449 .unwrap_or(false)
1450 })
1451 .cloned()
1452 .collect();
1453
1454 let future: Vec<FutureCapabilityEntry> = self
1455 .capabilities
1456 .get(&id)
1457 .map(|decls| {
1458 decls
1459 .iter()
1460 .map(|d| FutureCapabilityEntry {
1461 kind: d.kind.clone(),
1462 name: d.name.clone(),
1463 })
1464 .collect()
1465 })
1466 .unwrap_or_default();
1467
1468 out.push(ExtensionCapabilitySnapshot {
1469 id,
1470 health,
1471 restart_count,
1472 hooks,
1473 tools,
1474 providers,
1475 future,
1476 });
1477 }
1478 out
1479 }
1480
1481 pub fn provider_tool_use_runtime_ids(&self) -> Vec<String> {
1484 let mut ids: Vec<String> = self
1485 .providers
1486 .list()
1487 .into_iter()
1488 .filter(|p| {
1489 p.spec.models.iter().any(|m| {
1490 m.capabilities
1491 .get("tool_use")
1492 .and_then(|v| v.as_bool())
1493 .unwrap_or(false)
1494 })
1495 })
1496 .map(|p| p.runtime_id.clone())
1497 .collect();
1498 ids.sort();
1499 ids
1500 }
1501
1502 pub fn provider_trust_view(&self) -> std::collections::BTreeMap<String, bool> {
1508 let trust = match crate::extensions::trust::load_trust_state() {
1509 Ok(t) => t,
1510 Err(e) => {
1511 tracing::warn!("trust.json corrupt or unreadable, failing closed (all providers disabled): {e}");
1512 return self
1514 .providers
1515 .list()
1516 .into_iter()
1517 .map(|p| (p.runtime_id.clone(), false))
1518 .collect();
1519 }
1520 };
1521 self.providers
1522 .list()
1523 .into_iter()
1524 .map(|p| {
1525 let enabled = crate::extensions::trust::is_provider_enabled(&trust, &p.runtime_id);
1526 (p.runtime_id.clone(), enabled)
1527 })
1528 .collect()
1529 }
1530
1531 pub fn config_diagnostics(&self, id: &str) -> Option<ExtensionConfigDiagnostics> {
1534 let manifest_config = self.manifest_configs.get(id)?;
1535
1536 let mut provider_required: Vec<(String, Vec<String>)> = Vec::new();
1538 for provider in self.providers.list() {
1539 if provider.plugin_id != id {
1540 continue;
1541 }
1542 let required: Vec<String> = provider
1543 .spec
1544 .config_schema
1545 .as_ref()
1546 .and_then(|schema| schema.get("required"))
1547 .and_then(Value::as_array)
1548 .map(|arr| {
1549 arr.iter()
1550 .filter_map(|v| v.as_str().map(|s| s.to_string()))
1551 .collect()
1552 })
1553 .unwrap_or_default();
1554 provider_required.push((provider.provider_id.clone(), required));
1555 }
1556 provider_required.sort_by(|a, b| a.0.cmp(&b.0));
1557
1558 let env_lookup = |name: &str| std::env::var(name).ok();
1559 let plugin_config_lookup =
1560 |key: &str| crate::extensions::config_store::read_plugin_config(id, key);
1561 let legacy_config_lookup = |key: &str| crate::config::read_config_value(key);
1562
1563 Some(diagnose_extension_config(
1564 id,
1565 manifest_config,
1566 &provider_required,
1567 &env_lookup,
1568 &plugin_config_lookup,
1569 &legacy_config_lookup,
1570 ))
1571 }
1572
1573 pub fn all_config_diagnostics(&self) -> Vec<ExtensionConfigDiagnostics> {
1575 let mut ids: Vec<&String> = self.manifest_configs.keys().collect();
1576 ids.sort();
1577 ids.into_iter()
1578 .filter_map(|id| self.config_diagnostics(id))
1579 .collect()
1580 }
1581
1582 pub fn hook_bus(&self) -> &Arc<HookBus> {
1584 &self.hook_bus
1585 }
1586
1587 pub fn tools_shared(&self) -> Option<Arc<tokio::sync::RwLock<crate::ToolRegistry>>> {
1589 self.tools.clone()
1590 }
1591
1592 #[cfg(test)]
1595 pub(crate) fn register_provider_handler_for_tests(
1596 &mut self,
1597 plugin_id: &str,
1598 spec: crate::extensions::runtime::process::RegisteredProviderSpec,
1599 handler: Arc<dyn ExtensionHandler>,
1600 ) -> Result<String, String> {
1601 self.providers
1602 .register_with_handler(plugin_id, spec, Some(handler))
1603 }
1604
1605 pub async fn discover_and_load(&mut self) -> (Vec<String>, Vec<ExtensionLoadFailure>) {
1612 self.discover_and_load_with_progress(|_| {}).await
1613 }
1614
1615 pub async fn discover_and_load_with_progress<F>(
1619 &mut self,
1620 mut progress: F,
1621 ) -> (Vec<String>, Vec<ExtensionLoadFailure>)
1622 where
1623 F: FnMut(crate::extensions::loader::ExtensionLoaderEvent),
1624 {
1625 let mut plugin_roots = vec![crate::config::base_dir().join("plugins")];
1626 if !project_plugins_disabled() {
1627 if let Ok(cwd) = std::env::current_dir() {
1628 let project_plugins = cwd.join(".synaps").join("plugins");
1629 if project_plugins != plugin_roots[0] {
1630 plugin_roots.push(project_plugins);
1631 }
1632 }
1633 }
1634
1635 let mut plugin_dirs: HashMap<String, PathBuf> = HashMap::new();
1636 let mut failed: Vec<ExtensionLoadFailure> = Vec::new();
1637
1638 for plugins_dir in plugin_roots {
1639 if !plugins_dir.exists() {
1640 continue;
1641 }
1642
1643 let entries = match std::fs::read_dir(&plugins_dir) {
1644 Ok(e) => e,
1645 Err(e) => {
1646 tracing::warn!(path = %plugins_dir.display(), error = %e, "Failed to read plugins directory");
1647 failed.push(ExtensionLoadFailure::new(
1648 "plugins",
1649 Some(plugins_dir.clone()),
1650 format!("Failed to read plugins directory: {e}"),
1651 "Check directory permissions and retry",
1652 ));
1653 continue;
1654 }
1655 };
1656
1657 for entry in entries.flatten() {
1658 let plugin_name = entry.file_name().to_string_lossy().to_string();
1659 if is_inert_plugin_dir_name(&plugin_name) {
1660 tracing::debug!(
1661 plugin = %plugin_name,
1662 path = %entry.path().display(),
1663 "Skipping backup/disabled/temp plugin directory"
1664 );
1665 continue;
1666 }
1667 plugin_dirs.insert(plugin_name, entry.path());
1668 }
1669 }
1670
1671 let mut plugin_dirs: Vec<(String, PathBuf)> = plugin_dirs.into_iter().collect();
1672 plugin_dirs.sort_by(|a, b| a.0.cmp(&b.0));
1673
1674 let mut loaded = Vec::new();
1675 let disabled_plugins = crate::config::load_config().disabled_plugins;
1676 let plugins_state = {
1679 let state_path = crate::skills::state::PluginsState::default_path();
1680 crate::skills::state::PluginsState::load_from(&state_path).unwrap_or_default()
1681 };
1682 for (plugin_name, plugin_dir) in plugin_dirs {
1683 if disabled_plugins.iter().any(|d| d == &plugin_name) {
1684 tracing::debug!(plugin = %plugin_name, "Extension disabled via disabled_plugins config");
1685 continue;
1686 }
1687 if let Some(message) = installed_plugin_setup_failure_in(&plugins_state, &plugin_name) {
1688 tracing::warn!(plugin = %plugin_name, error = %message, "Skipping extension with failed post-install setup");
1689 failed.push(ExtensionLoadFailure::new(
1690 plugin_name,
1691 None,
1692 format!("Post-install setup failed: {message}"),
1693 "Open /plugins, reinstall or update the plugin after fixing setup; extension load is disabled until setup succeeds",
1694 ));
1695 continue;
1696 }
1697 let manifest_path = plugin_dir.join(".synaps-plugin").join("plugin.json");
1698 if !manifest_path.exists() {
1699 continue;
1700 }
1701
1702 let content = match std::fs::read_to_string(&manifest_path) {
1703 Ok(c) => c,
1704 Err(e) => {
1705 let reason = format!("Failed to read plugin manifest: {e}");
1706 tracing::warn!(plugin = %plugin_name, manifest = %manifest_path.display(), error = %e, "Failed to read plugin manifest");
1707 failed.push(ExtensionLoadFailure::new(
1708 plugin_name,
1709 Some(manifest_path),
1710 reason,
1711 "Check manifest file permissions, then run `plugin validate <plugin-dir>`",
1712 ));
1713 continue;
1714 }
1715 };
1716
1717 let json: serde_json::Value = match serde_json::from_str(&content) {
1718 Ok(v) => v,
1719 Err(e) => {
1720 let reason = format!("Invalid plugin manifest JSON: {e}");
1721 tracing::warn!(plugin = %plugin_name, manifest = %manifest_path.display(), error = %e, "Invalid plugin manifest JSON");
1722 failed.push(ExtensionLoadFailure::new(
1723 plugin_name,
1724 Some(manifest_path),
1725 reason,
1726 "Fix JSON syntax, then run `plugin validate <plugin-dir>`",
1727 ));
1728 continue;
1729 }
1730 };
1731
1732 let ext_value = match json.get("extension") {
1733 Some(v) => v.clone(),
1734 None => continue,
1735 };
1736
1737 let ext_manifest: ExtensionManifest = match serde_json::from_value(ext_value) {
1738 Ok(m) => m,
1739 Err(e) => {
1740 let reason = format!("Failed to parse extension manifest: {e}");
1741 tracing::warn!(plugin = %plugin_name, manifest = %manifest_path.display(), error = %e, "Failed to parse extension manifest");
1742 failed.push(ExtensionLoadFailure::new(
1743 plugin_name,
1744 Some(manifest_path),
1745 reason,
1746 "Check the `extension` block shape against docs/extensions/contract.json, then run `plugin validate <plugin-dir>`",
1747 ));
1748 continue;
1749 }
1750 };
1751
1752 #[allow(clippy::if_same_then_else)]
1753 let command = if std::path::Path::new(&ext_manifest.command).is_absolute() {
1754 ext_manifest.command.clone()
1755 } else if !ext_manifest.command.contains(std::path::MAIN_SEPARATOR)
1756 && !ext_manifest.command.contains('/')
1757 {
1758 ext_manifest.command.clone()
1759 } else {
1760 plugin_dir
1761 .join(&ext_manifest.command)
1762 .to_string_lossy()
1763 .to_string()
1764 };
1765
1766 let args: Vec<String> = ext_manifest
1767 .args
1768 .iter()
1769 .map(|arg| {
1770 let arg_path = plugin_dir.join(arg);
1771 if arg_path.exists() {
1772 if let (Ok(canonical), Ok(plugin_canonical)) =
1773 (arg_path.canonicalize(), plugin_dir.canonicalize())
1774 {
1775 if canonical.starts_with(&plugin_canonical) {
1776 return canonical.to_string_lossy().to_string();
1777 }
1778 }
1779 }
1780 arg.clone()
1781 })
1782 .collect();
1783
1784 let resolved = ExtensionManifest {
1785 theme_tokens: Default::default(),
1786 command,
1792 args,
1793 ..ext_manifest
1794 };
1795
1796 match self
1797 .load_with_cwd(&plugin_name, &resolved, Some(plugin_dir.clone()))
1798 .await
1799 {
1800 Ok(()) => {
1801 tracing::info!(plugin = %plugin_name, path = %plugin_dir.display(), "Extension loaded from plugins/");
1802 loaded.push(plugin_name.clone());
1803 progress(crate::extensions::loader::ExtensionLoaderEvent::Loaded {
1804 plugin: plugin_name,
1805 loaded: loaded.len(),
1806 failed: failed.len(),
1807 });
1808 }
1809 Err(e) => {
1810 tracing::warn!(plugin = %plugin_name, manifest = %manifest_path.display(), error = %e, "Failed to load extension");
1811 let setup_script = json
1812 .pointer("/extension/setup")
1813 .and_then(|v| v.as_str())
1814 .or_else(|| {
1815 json.pointer("/provides/sidecar/setup")
1816 .and_then(|v| v.as_str())
1817 });
1818 let hint = compute_extension_load_hint(&e, &plugin_dir, setup_script);
1819 let failure =
1820 ExtensionLoadFailure::new(plugin_name, Some(manifest_path), e, hint);
1821 failed.push(failure.clone());
1822 progress(crate::extensions::loader::ExtensionLoaderEvent::Failed {
1823 failure,
1824 loaded: loaded.len(),
1825 failed: failed.len(),
1826 });
1827 }
1828 }
1829 }
1830
1831 (loaded, failed)
1832 }
1833}
1834
1835#[cfg(test)]
1836mod invoke_command_collected_tests {
1837 use super::*;
1843 use crate::extensions::commands::CommandOutputEvent;
1844 use crate::extensions::invoke_output::INVOKE_RETAINED_BYTE_BUDGET;
1845 use crate::extensions::runtime::InvokeCommandEvent;
1846
1847 struct FloodingHandler;
1848
1849 #[async_trait::async_trait]
1850 impl super::ExtensionHandler for FloodingHandler {
1851 fn id(&self) -> &str {
1852 "flooding-fake"
1853 }
1854 async fn handle(
1855 &self,
1856 _event: &crate::extensions::hooks::events::HookEvent,
1857 ) -> crate::extensions::hooks::events::HookResult {
1858 Default::default()
1859 }
1860 async fn shutdown(&self) {}
1861
1862 async fn invoke_command(
1863 &self,
1864 _command: &str,
1865 _args: Vec<String>,
1866 _request_id: &str,
1867 sink: crate::extensions::invoke_output::InvokeEventSink,
1868 ) -> Result<Value, String> {
1869 for _ in 0..2048 {
1873 let event = InvokeCommandEvent::Output(CommandOutputEvent::Text {
1874 content: "f".repeat(4096),
1875 });
1876 if sink.send(event).await.is_err() {
1877 return Err("collector vanished".to_string());
1878 }
1879 }
1880 let _ = sink
1881 .send(InvokeCommandEvent::Output(CommandOutputEvent::Done))
1882 .await;
1883 Ok(serde_json::json!({"status": "flooded"}))
1884 }
1885 }
1886
1887 #[tokio::test]
1888 async fn collected_invoke_bounds_in_process_flood_without_deadlock() {
1889 let bus = Arc::new(HookBus::new());
1890 let mut mgr = ExtensionManager::new(bus);
1891 mgr.extensions
1892 .insert("flooding-fake".to_string(), Arc::new(FloodingHandler));
1893
1894 let (result, report) = tokio::time::timeout(
1895 std::time::Duration::from_secs(30),
1896 mgr.invoke_command_collected("flooding-fake", "any", vec![], "req-1"),
1897 )
1898 .await
1899 .expect("bounded sink + concurrent collector must not deadlock");
1900
1901 assert_eq!(result.expect("invoke ok")["status"], "flooded");
1902 let c = &report.counters;
1903 assert_eq!(c.produced_events, 2049);
1904 assert_eq!(c.produced_payload_bytes, 2048 * 4096);
1905 assert_eq!(c.consumed_events, c.produced_events);
1906 assert_eq!(c.consumed_payload_bytes, c.produced_payload_bytes);
1907 assert_eq!(c.retained_payload_bytes, INVOKE_RETAINED_BYTE_BUDGET as u64);
1908 assert!(c.saw_done);
1909 assert_eq!(
1910 c.consumed_payload_bytes,
1911 c.retained_payload_bytes + c.truncated_payload_bytes + c.dropped_payload_bytes,
1912 );
1913 assert_eq!(c.consumed_events, c.retained_events + c.dropped_events);
1914 }
1915
1916 #[tokio::test]
1917 async fn collected_invoke_reports_unknown_extension_with_empty_report() {
1918 let bus = Arc::new(HookBus::new());
1919 let mgr = ExtensionManager::new(bus);
1920 let (result, report) = mgr
1921 .invoke_command_collected("nope", "any", vec![], "req-1")
1922 .await;
1923 assert!(result
1924 .expect_err("unknown id")
1925 .contains("unknown extension"));
1926 assert!(report.events.is_empty());
1927 assert_eq!(report.counters.produced_events, 0);
1928 }
1929}
1930
1931#[cfg(test)]
1932mod tests {
1933 use super::*;
1934
1935 fn context_provider_manifest(provider_id: &str) -> ExtensionManifest {
1938 serde_json::from_value(serde_json::json!({
1939 "runtime": "process",
1940 "command": "/bin/false",
1941 "permissions": ["context_providers.register"],
1942 "deferred": {
1943 "context_providers": [{
1944 "id": provider_id,
1945 "capability": "project-memory",
1946 "description": "test context provider",
1947 "schema_version": 1
1948 }]
1949 }
1950 }))
1951 .unwrap()
1952 }
1953
1954 #[tokio::test]
1959 async fn context_provider_catalog_lists_resolves_and_unloads_exactly() {
1960 let mut mgr = ExtensionManager::new(Arc::new(HookBus::new()));
1961 mgr.set_progressive_deferral(true);
1962 mgr.load("mem-a", &context_provider_manifest("alpha"))
1963 .await
1964 .unwrap();
1965 mgr.load("mem-b", &context_provider_manifest("beta"))
1966 .await
1967 .unwrap();
1968
1969 let listed: Vec<String> = mgr
1971 .context_providers()
1972 .iter()
1973 .map(|d| d.runtime_address())
1974 .collect();
1975 assert_eq!(
1976 listed,
1977 vec![
1978 "extension:mem-a:alpha".to_string(),
1979 "extension:mem-b:beta".to_string(),
1980 ]
1981 );
1982 let alpha = super::super::context_provider::ContextProviderId::parse("alpha").unwrap();
1983 assert_eq!(
1984 mgr.context_provider(&alpha).map(|d| d.extension_id()),
1985 Some("mem-a")
1986 );
1987 let unknown = super::super::context_provider::ContextProviderId::parse("nope").unwrap();
1988 assert!(mgr.context_provider(&unknown).is_none(), "fail closed");
1989
1990 let runtime = mgr.extension_runtime();
1992 assert_eq!(
1993 runtime
1994 .resolve_context_provider(Some(&alpha))
1995 .map(|d| d.runtime_address()),
1996 Ok("extension:mem-a:alpha".to_string())
1997 );
1998
1999 mgr.load("mem-c", &context_provider_manifest("alpha"))
2002 .await
2003 .unwrap();
2004 assert!(mgr.context_provider(&alpha).is_none(), "ambiguous → None");
2005 assert_eq!(
2006 runtime.resolve_context_provider(Some(&alpha)),
2007 Err(super::super::context_provider::ContextProviderLookupError::Ambiguous)
2008 );
2009
2010 mgr.unload("mem-c").await.unwrap();
2012 assert_eq!(
2013 mgr.context_provider(&alpha).map(|d| d.extension_id()),
2014 Some("mem-a")
2015 );
2016 mgr.unload("mem-a").await.unwrap();
2017 assert_eq!(
2018 runtime.resolve_context_provider(Some(&alpha)),
2019 Err(super::super::context_provider::ContextProviderLookupError::NotRegistered)
2020 );
2021 assert_eq!(mgr.context_providers().len(), 1, "mem-b remains");
2022
2023 assert_eq!(mgr.count(), 0, "no live extension handlers");
2025 assert_eq!(runtime.lease_count(), 0, "no runtime leases");
2026 }
2027
2028 #[tokio::test]
2029 async fn capability_snapshots_empty_when_no_extensions() {
2030 let bus = Arc::new(HookBus::new());
2031 let mgr = ExtensionManager::new(bus);
2032 assert!(mgr.capability_snapshots().await.is_empty());
2033 }
2034
2035 #[tokio::test]
2042 async fn deferred_launch_record_never_leaks_secret_config_through_diagnostics() {
2043 const SECRET: &str = "task20-A3-sentinel-5f2c9d";
2044 const VAR: &str = "T20_A3_THIRD_PARTY_SECRET";
2048 std::env::set_var(VAR, SECRET);
2049
2050 let manifest: ExtensionManifest = serde_json::from_value(serde_json::json!({
2051 "runtime": "process",
2052 "command": "/bin/false",
2053 "permissions": ["tools.register"],
2054 "config": [{
2055 "key": "api_key",
2056 "type": "string",
2057 "required": true,
2058 "secret_env": VAR
2059 }],
2060 "deferred": {
2061 "tools": [{
2062 "name": "search",
2063 "description": "deferred search",
2064 "input_schema": {"type": "object"}
2065 }]
2066 }
2067 }))
2068 .unwrap();
2069
2070 let registry = Arc::new(tokio::sync::RwLock::new(crate::ToolRegistry::new()));
2071 let mut mgr = ExtensionManager::new_with_tools(Arc::new(HookBus::new()), registry);
2072 mgr.set_progressive_deferral(true);
2073 mgr.load("secretive", &manifest).await.unwrap();
2074
2075 assert!(mgr.is_deferred_tool_only("secretive"));
2077 assert_eq!(mgr.deferred_tool_only_count(), 1);
2078 let record = mgr.deferred_launch_record("secretive").expect("retained");
2080 assert_eq!(record.config["api_key"], serde_json::json!(SECRET));
2081
2082 let mut rendered = String::new();
2084 rendered.push_str(&format!("{:?}", mgr.statuses().await));
2085 rendered.push_str(&format!("{:?}", mgr.config_diagnostics("secretive")));
2086 rendered.push_str(&format!("{:?}", mgr.all_config_diagnostics()));
2087 rendered.push_str(&format!("{:?}", mgr.capability_snapshots().await));
2088 rendered.push_str(&format!("{:?}", mgr.plugin_infos()));
2089 rendered.push_str(&format!("{:?}", mgr.list()));
2090 assert!(
2091 !rendered.contains(SECRET),
2092 "resolved secret config leaked through a public diagnostic surface"
2093 );
2094
2095 std::env::remove_var(VAR);
2096 }
2097
2098 #[tokio::test]
2099 async fn capability_snapshot_lists_hooks_for_loaded_extension() {
2100 let bus = Arc::new(HookBus::new());
2101 let mut mgr = ExtensionManager::new(bus.clone());
2102 let manifest = ExtensionManifest {
2103 theme_tokens: Default::default(),
2104 deferred: None,
2105 protocol_version: 1,
2106 runtime: crate::extensions::manifest::ExtensionRuntime::Process,
2107 command: "python3".to_string(),
2108 setup: None,
2109 prebuilt: ::std::collections::HashMap::new(),
2110 args: vec![
2111 "tests/fixtures/process_extension.py".to_string(),
2112 "normal".to_string(),
2113 "/tmp/synaps-capability-test.log".to_string(),
2114 ],
2115 permissions: vec!["tools.intercept".to_string()],
2116 hooks: vec![crate::extensions::manifest::HookSubscription {
2117 hook: "before_tool_call".to_string(),
2118 tool: Some("bash".to_string()),
2119 matcher: None,
2120 }],
2121 config: vec![],
2122 };
2123
2124 mgr.load("cap-snap", &manifest).await.unwrap();
2125
2126 let snaps = mgr.capability_snapshots().await;
2127 assert_eq!(snaps.len(), 1);
2128 let snap = &snaps[0];
2129 assert_eq!(snap.id, "cap-snap");
2130 assert_eq!(snap.hooks.len(), 1);
2131 assert_eq!(snap.hooks[0].kind, "before_tool_call");
2132 assert_eq!(snap.hooks[0].tool_filter.as_deref(), Some("bash"));
2133 assert!(snap.tools.is_empty());
2134 assert!(snap.providers.is_empty());
2135 assert!(snap.future.is_empty());
2136
2137 mgr.shutdown_all().await;
2138 }
2139
2140 #[tokio::test]
2141 async fn capability_snapshot_surfaces_seeded_capabilities() {
2142 let bus = Arc::new(HookBus::new());
2143 let mut mgr = ExtensionManager::new(bus.clone());
2144 let manifest = ExtensionManifest {
2145 theme_tokens: Default::default(),
2146 deferred: None,
2147 protocol_version: 1,
2148 runtime: crate::extensions::manifest::ExtensionRuntime::Process,
2149 command: "python3".to_string(),
2150 setup: None,
2151 prebuilt: ::std::collections::HashMap::new(),
2152 args: vec![
2153 "tests/fixtures/process_extension.py".to_string(),
2154 "normal".to_string(),
2155 "/tmp/synaps-capability-snapshot-test.log".to_string(),
2156 ],
2157 permissions: vec!["tools.intercept".to_string()],
2158 hooks: vec![crate::extensions::manifest::HookSubscription {
2159 hook: "before_tool_call".to_string(),
2160 tool: Some("bash".to_string()),
2161 matcher: None,
2162 }],
2163 config: vec![],
2164 };
2165
2166 mgr.load("multi-cap", &manifest).await.unwrap();
2167
2168 mgr.test_seed_capabilities(
2172 "multi-cap",
2173 vec![
2174 crate::extensions::runtime::process::CapabilityDeclaration {
2175 kind: "capture".to_string(),
2176 name: "Local Sample STT".to_string(),
2177 permissions: vec!["audio.input".to_string()],
2178 params: serde_json::Value::Null,
2179 },
2180 crate::extensions::runtime::process::CapabilityDeclaration {
2181 kind: "ocr".to_string(),
2182 name: "Tesseract".to_string(),
2183 permissions: vec![],
2184 params: serde_json::Value::Null,
2185 },
2186 ],
2187 );
2188
2189 let snaps = mgr.capability_snapshots().await;
2190 let snap = snaps
2191 .iter()
2192 .find(|s| s.id == "multi-cap")
2193 .expect("multi-cap snapshot");
2194 assert_eq!(snap.future.len(), 2);
2195 let kinds: Vec<&str> = snap.future.iter().map(|e| e.kind.as_str()).collect();
2196 assert!(kinds.contains(&"capture"), "got kinds {:?}", kinds);
2197 assert!(kinds.contains(&"ocr"), "got kinds {:?}", kinds);
2198 let names: Vec<&str> = snap.future.iter().map(|e| e.name.as_str()).collect();
2199 assert!(names.contains(&"Local Sample STT"), "got {:?}", names);
2200 assert!(names.contains(&"Tesseract"), "got {:?}", names);
2201
2202 mgr.unload("multi-cap").await.unwrap();
2203 let snaps = mgr.capability_snapshots().await;
2204 assert!(snaps.iter().all(|s| s.id != "multi-cap"));
2205
2206 mgr.shutdown_all().await;
2207 }
2208
2209 #[tokio::test]
2210 async fn new_manager_has_no_extensions() {
2211 let bus = Arc::new(HookBus::new());
2212 let mgr = ExtensionManager::new(bus);
2213 assert_eq!(mgr.count(), 0);
2214 assert!(mgr.list().is_empty());
2215 }
2216
2217 #[tokio::test]
2218 async fn unload_nonexistent_returns_error() {
2219 let bus = Arc::new(HookBus::new());
2220 let mut mgr = ExtensionManager::new(bus);
2221 let result = mgr.unload("nope").await;
2222 assert!(result.is_err());
2223 }
2224
2225 #[tokio::test]
2226 async fn reload_unsubscribes_old_handler_before_loading_new_one() {
2227 let bus = Arc::new(HookBus::new());
2228 let mut mgr = ExtensionManager::new(bus.clone());
2229 let manifest = ExtensionManifest {
2230 theme_tokens: Default::default(),
2231 deferred: None,
2232 protocol_version: 1,
2233 runtime: crate::extensions::manifest::ExtensionRuntime::Process,
2234 command: "python3".to_string(),
2235 setup: None,
2236 prebuilt: ::std::collections::HashMap::new(),
2237 args: vec![
2238 "tests/fixtures/process_extension.py".to_string(),
2239 "normal".to_string(),
2240 "/tmp/synaps-reload-test.log".to_string(),
2241 ],
2242 permissions: vec!["tools.intercept".to_string()],
2243 hooks: vec![crate::extensions::manifest::HookSubscription {
2244 hook: "before_tool_call".to_string(),
2245 tool: Some("bash".to_string()),
2246 matcher: None,
2247 }],
2248 config: vec![],
2249 };
2250
2251 mgr.load("reload-test", &manifest).await.unwrap();
2252 assert_eq!(bus.handler_count().await, 1);
2253
2254 mgr.reload("reload-test", &manifest, None).await.unwrap();
2255
2256 assert_eq!(mgr.count(), 1);
2257 assert_eq!(bus.handler_count().await, 1);
2258 mgr.shutdown_all().await;
2259 }
2260
2261 #[tokio::test]
2262 async fn reload_failure_leaves_previous_instance_unloaded() {
2263 let bus = Arc::new(HookBus::new());
2264 let mut mgr = ExtensionManager::new(bus.clone());
2265 let good = ExtensionManifest {
2266 theme_tokens: Default::default(),
2267 deferred: None,
2268 protocol_version: 1,
2269 runtime: crate::extensions::manifest::ExtensionRuntime::Process,
2270 command: "python3".to_string(),
2271 setup: None,
2272 prebuilt: ::std::collections::HashMap::new(),
2273 args: vec![
2274 "tests/fixtures/process_extension.py".to_string(),
2275 "normal".to_string(),
2276 "/tmp/synaps-reload-failure-test.log".to_string(),
2277 ],
2278 permissions: vec!["tools.intercept".to_string()],
2279 hooks: vec![crate::extensions::manifest::HookSubscription {
2280 hook: "before_tool_call".to_string(),
2281 tool: Some("bash".to_string()),
2282 matcher: None,
2283 }],
2284 config: vec![],
2285 };
2286 let bad = ExtensionManifest {
2287 theme_tokens: Default::default(),
2288 deferred: None,
2289 command: "/definitely/not/a/real/extension-binary".to_string(),
2290 setup: None,
2291 prebuilt: ::std::collections::HashMap::new(),
2292 ..good.clone()
2293 };
2294
2295 mgr.load("reload-failure-test", &good).await.unwrap();
2296 let err = mgr
2297 .reload("reload-failure-test", &bad, None)
2298 .await
2299 .unwrap_err();
2300
2301 assert!(err.contains("Failed to spawn extension"), "{err}");
2302 assert_eq!(mgr.count(), 0);
2303 assert_eq!(bus.handler_count().await, 0);
2304 }
2305
2306 #[test]
2307 fn project_plugins_disable_env_parser_accepts_truthy_values() {
2308 for value in ["1", "true", "TRUE", "yes", "on"] {
2309 std::env::set_var("SYNAPS_DISABLE_PROJECT_PLUGINS", value);
2310 assert!(project_plugins_disabled());
2311 }
2312 for value in ["", "0", "false", "off", "no"] {
2313 std::env::set_var("SYNAPS_DISABLE_PROJECT_PLUGINS", value);
2314 assert!(!project_plugins_disabled());
2315 }
2316 std::env::remove_var("SYNAPS_DISABLE_PROJECT_PLUGINS");
2317 }
2318
2319 fn with_temp_base_dir<T>(path: &std::path::Path, f: impl FnOnce() -> T) -> T {
2320 let old_base_dir = std::env::var("SYNAPS_BASE_DIR").ok();
2321 crate::config::set_base_dir_for_tests(path.to_path_buf());
2322 let out = f();
2323 match old_base_dir {
2324 Some(old) => std::env::set_var("SYNAPS_BASE_DIR", old),
2325 None => std::env::remove_var("SYNAPS_BASE_DIR"),
2326 }
2327 out
2328 }
2329
2330 #[test]
2331 #[serial_test::serial(synaps_base_dir)]
2332 fn resolve_config_prefers_plugin_namespaced_config_before_legacy_global_key() {
2333 let dir = tempfile::tempdir().unwrap();
2334 with_temp_base_dir(dir.path(), || {
2335 crate::extensions::config_store::write_plugin_config(
2336 "sample-sidecar",
2337 "backend",
2338 "cpu",
2339 )
2340 .unwrap();
2341 crate::config::write_config_value("extension.sample-sidecar.backend", "auto").unwrap();
2342
2343 let resolved = ExtensionManager::resolve_config(
2344 "sample-sidecar",
2345 &[ExtensionConfigEntry {
2346 key: "backend".to_string(),
2347 value_type: None,
2348 description: None,
2349 required: true,
2350 default: None,
2351 secret_env: None,
2352 host_context: None,
2353 }],
2354 None,
2355 )
2356 .unwrap();
2357
2358 assert_eq!(
2359 resolved["backend"],
2360 serde_json::Value::String("cpu".to_string())
2361 );
2362 });
2363 }
2364
2365 #[test]
2366 #[serial_test::serial(synaps_base_dir)]
2367 fn resolve_config_keeps_legacy_global_extension_key_as_fallback() {
2368 let dir = tempfile::tempdir().unwrap();
2369 with_temp_base_dir(dir.path(), || {
2370 crate::config::write_config_value("extension.sample-sidecar.backend", "auto").unwrap();
2371
2372 let resolved = ExtensionManager::resolve_config(
2373 "sample-sidecar",
2374 &[ExtensionConfigEntry {
2375 key: "backend".to_string(),
2376 value_type: None,
2377 description: None,
2378 required: true,
2379 default: None,
2380 secret_env: None,
2381 host_context: None,
2382 }],
2383 None,
2384 )
2385 .unwrap();
2386
2387 assert_eq!(
2388 resolved["backend"],
2389 serde_json::Value::String("auto".to_string())
2390 );
2391 });
2392 }
2393
2394 #[tokio::test]
2395 async fn config_diagnostics_returns_none_for_unknown_extension() {
2396 let bus = Arc::new(HookBus::new());
2397 let mgr = ExtensionManager::new(bus);
2398 assert!(mgr.config_diagnostics("nope").is_none());
2399 assert!(mgr.all_config_diagnostics().is_empty());
2400 }
2401
2402 #[tokio::test]
2403 async fn config_diagnostics_reports_loaded_manifest_entries() {
2404 let bus = Arc::new(HookBus::new());
2405 let mut mgr = ExtensionManager::new(bus);
2406 let manifest = ExtensionManifest {
2407 theme_tokens: Default::default(),
2408 deferred: None,
2409 protocol_version: 1,
2410 runtime: crate::extensions::manifest::ExtensionRuntime::Process,
2411 command: "python3".to_string(),
2412 setup: None,
2413 prebuilt: ::std::collections::HashMap::new(),
2414 args: vec![
2415 "tests/fixtures/process_extension.py".to_string(),
2416 "normal".to_string(),
2417 "/tmp/synaps-config-diag-test.log".to_string(),
2418 ],
2419 permissions: vec!["tools.intercept".to_string()],
2420 hooks: vec![crate::extensions::manifest::HookSubscription {
2421 hook: "before_tool_call".to_string(),
2422 tool: Some("bash".to_string()),
2423 matcher: None,
2424 }],
2425 config: vec![crate::extensions::manifest::ExtensionConfigEntry {
2426 key: "region".to_string(),
2427 value_type: None,
2428 description: Some("AWS region".to_string()),
2429 required: false,
2430 default: Some(serde_json::Value::String("us-east-1".to_string())),
2431 secret_env: None,
2432 host_context: None,
2433 }],
2434 };
2435
2436 mgr.load("config-diag-test", &manifest).await.unwrap();
2437
2438 let diag = mgr
2439 .config_diagnostics("config-diag-test")
2440 .expect("diagnostics should be available for loaded extension");
2441 assert_eq!(diag.extension_id, "config-diag-test");
2442 assert_eq!(diag.entries.len(), 1);
2443 assert_eq!(diag.entries[0].key, "region");
2444 assert!(diag.entries[0].has_value);
2445 assert!(diag.provider_missing.is_empty());
2446
2447 let all = mgr.all_config_diagnostics();
2448 assert_eq!(all.len(), 1);
2449 assert_eq!(all[0].extension_id, "config-diag-test");
2450
2451 mgr.shutdown_all().await;
2452 assert!(mgr.config_diagnostics("config-diag-test").is_none());
2454 }
2455
2456 #[tokio::test]
2457 async fn provider_trust_view_is_empty_for_no_providers() {
2458 let bus = Arc::new(HookBus::new());
2459 let mgr = ExtensionManager::new(bus);
2460 let view = mgr.provider_trust_view();
2461 assert!(view.is_empty());
2462 }
2463
2464 #[tokio::test]
2465 async fn provider_tool_use_runtime_ids_lists_only_tool_use_capable() {
2466 use crate::extensions::runtime::process::{
2467 RegisteredProviderModelSpec, RegisteredProviderSpec,
2468 };
2469 let bus = Arc::new(HookBus::new());
2470 let mut mgr = ExtensionManager::new(bus);
2471 let tool_spec = RegisteredProviderSpec {
2473 id: "alpha".into(),
2474 display_name: "Alpha".into(),
2475 description: "tool-use".into(),
2476 models: vec![RegisteredProviderModelSpec {
2477 id: "m1".into(),
2478 display_name: None,
2479 capabilities: serde_json::json!({"tool_use": true}),
2480 context_window: None,
2481 }],
2482 config_schema: None,
2483 };
2484 let plain_spec = RegisteredProviderSpec {
2486 id: "beta".into(),
2487 display_name: "Beta".into(),
2488 description: "plain".into(),
2489 models: vec![RegisteredProviderModelSpec {
2490 id: "m1".into(),
2491 display_name: None,
2492 capabilities: serde_json::json!({"streaming": true}),
2493 context_window: None,
2494 }],
2495 config_schema: None,
2496 };
2497 mgr.providers.register("plug", tool_spec).unwrap();
2498 mgr.providers.register("plug", plain_spec).unwrap();
2499 let ids = mgr.provider_tool_use_runtime_ids();
2500 assert_eq!(ids, vec!["plug:alpha".to_string()]);
2501 }
2502
2503 #[test]
2506 fn hint_missing_binary_with_declared_setup_points_at_script() {
2507 let hint = compute_extension_load_hint(
2508 "Failed to spawn extension 'sample-sidecar': No such file or directory (os error 2)",
2509 std::path::Path::new("/home/u/.synaps-cli/plugins/sample-sidecar"),
2510 Some("scripts/setup.sh"),
2511 );
2512 assert!(
2513 hint.contains("Extension binary missing"),
2514 "missing-binary case should be flagged: {hint}"
2515 );
2516 assert!(
2517 hint.contains("/home/u/.synaps-cli/plugins/sample-sidecar"),
2518 "hint should include the plugin dir: {hint}"
2519 );
2520 assert!(
2521 hint.contains("setup=scripts/setup.sh"),
2522 "hint should show sanitized setup path without copy-paste shell command: {hint}"
2523 );
2524 }
2525
2526 #[test]
2527 fn hint_missing_binary_without_declared_setup_falls_back_to_generic() {
2528 let hint = compute_extension_load_hint(
2529 "Failed to spawn extension 'foo': No such file or directory (os error 2)",
2530 std::path::Path::new("/x/y"),
2531 None,
2532 );
2533 assert!(
2534 hint.contains("plugin validate"),
2535 "no setup declared → generic hint: {hint}"
2536 );
2537 assert!(
2538 !hint.contains("Extension binary missing"),
2539 "should not falsely promise a setup script: {hint}"
2540 );
2541 }
2542
2543 #[test]
2544 fn hint_other_error_with_declared_setup_falls_back_to_generic() {
2545 let hint = compute_extension_load_hint(
2546 "Extension 'foo' must subscribe to at least one hook or request a registration permission",
2547 std::path::Path::new("/x/y"),
2548 Some("scripts/setup.sh"),
2549 );
2550 assert!(hint.contains("plugin validate"), "got {hint}");
2554 assert!(!hint.contains("Extension binary missing"), "got {hint}");
2555 }
2556
2557 #[test]
2558 fn hint_recognises_os_error_2_format() {
2559 let hint = compute_extension_load_hint(
2562 "spawn failed (os error 2)",
2563 std::path::Path::new("/p"),
2564 Some("setup.sh"),
2565 );
2566 assert!(hint.contains("Extension binary missing"), "got {hint}");
2567 }
2568
2569 fn write_installed_plugin(plugins_root: &std::path::Path, dir_name: &str) {
2574 let plugin_dir = plugins_root.join(dir_name).join(".synaps-plugin");
2575 std::fs::create_dir_all(&plugin_dir).unwrap();
2576 let manifest = serde_json::json!({
2577 "extension": {
2578 "runtime": "process",
2579 "command": "/bin/false",
2580 "permissions": ["tools.register"],
2581 "deferred": {
2582 "tools": [{
2583 "name": "memory_fetch",
2584 "description": "Fetch one memory by exact ID.",
2585 "input_schema": {
2586 "type": "object",
2587 "properties": {"id": {"type": "string"}},
2588 "required": ["id"]
2589 }
2590 }]
2591 }
2592 }
2593 });
2594 std::fs::write(
2595 plugin_dir.join("plugin.json"),
2596 serde_json::to_string_pretty(&manifest).unwrap(),
2597 )
2598 .unwrap();
2599 }
2600
2601 #[tokio::test]
2611 #[serial_test::serial(synaps_base_dir)]
2612 async fn discover_and_load_skips_backup_and_disabled_plugin_directories() {
2613 let base = crate::test_env::BaseDirGuard::new();
2614 let _no_project_plugins =
2615 crate::test_env::EnvVarGuard::set("SYNAPS_DISABLE_PROJECT_PLUGINS", "1");
2616 let plugins_root = base.path().join("plugins");
2617
2618 write_installed_plugin(&plugins_root, "axel-memory-manager");
2619 for inert in [
2622 "axel-memory-manager.backup.20260719210053",
2623 "axel-memory-manager.backup.20260720101416",
2624 "axel-memory-manager.update-backup",
2625 "axel-memory-manager.backup",
2626 "axel-memory-manager.bak",
2627 "axel-memory-manager.disabled",
2628 "axel-memory-manager.old",
2629 "axel-memory-manager.orig",
2630 "axel-memory-manager.tmp",
2631 ] {
2632 write_installed_plugin(&plugins_root, inert);
2633 }
2634 write_installed_plugin(&plugins_root, "com.example.notes");
2636
2637 let registry = Arc::new(tokio::sync::RwLock::new(
2638 crate::ToolRegistry::without_subagent(),
2639 ));
2640 let mut mgr = ExtensionManager::new_with_tools(Arc::new(HookBus::new()), registry.clone());
2641 mgr.set_progressive_deferral(true);
2642
2643 let (mut loaded, failed) = mgr.discover_and_load().await;
2644 loaded.sort();
2645
2646 assert!(failed.is_empty(), "no failures expected: {failed:?}");
2647 assert_eq!(
2648 loaded,
2649 vec![
2650 "axel-memory-manager".to_string(),
2651 "com.example.notes".to_string()
2652 ],
2653 "backup/disabled/temp plugin directories must not be discovered"
2654 );
2655
2656 let reg = registry.read().await;
2657 assert!(reg.get("axel-memory-manager:memory_fetch").is_some());
2658 assert!(reg.get("com.example.notes:memory_fetch").is_some());
2659 assert!(
2660 reg.get("axel-memory-manager.backup.20260719210053:memory_fetch")
2661 .is_none(),
2662 "backup plugin tools must never be cataloged"
2663 );
2664 assert!(reg
2665 .get("axel-memory-manager.backup.20260720101416:memory_fetch")
2666 .is_none());
2667 drop(reg);
2668
2669 assert_eq!(mgr.count(), 0, "discovery must spawn nothing");
2672 }
2673
2674 #[test]
2678 fn inert_plugin_dir_name_filter_is_exact() {
2679 for inert in [
2680 "axel-memory-manager.backup.20260720101416",
2681 "axel-memory-manager.BACKUP.1",
2682 "axel-memory-manager.backup",
2683 "axel-memory-manager.update-backup",
2684 "axel-memory-manager.bak",
2685 "axel-memory-manager.disabled",
2686 "axel-memory-manager.old",
2687 "axel-memory-manager.orig",
2688 "axel-memory-manager.tmp",
2689 "axel-memory-manager.temp",
2690 ] {
2691 assert!(
2692 is_inert_plugin_dir_name(inert),
2693 "{inert} should be excluded from discovery"
2694 );
2695 }
2696 for live in [
2697 "axel-memory-manager",
2698 "com.example.notes",
2699 "backup-manager",
2700 "old-school-tools",
2701 "tmpfs-inspector",
2702 "my.backups.viewer",
2703 ] {
2704 assert!(
2705 !is_inert_plugin_dir_name(live),
2706 "{live} is a legitimate plugin name and must stay discoverable"
2707 );
2708 }
2709 }
2710}
2711
2712#[cfg(test)]
2713mod secret_env_allowlist_tests {
2714 use super::*;
2715
2716 fn entry(key: &str, secret_env: &str) -> ExtensionConfigEntry {
2717 serde_json::from_value(serde_json::json!({
2718 "key": key,
2719 "type": "string",
2720 "secret_env": secret_env,
2721 }))
2722 .unwrap()
2723 }
2724
2725 #[test]
2728 fn host_provider_credentials_are_rejected() {
2729 for var in [
2730 "ANTHROPIC_API_KEY",
2731 "OPENAI_API_KEY",
2732 "AWS_SECRET_ACCESS_KEY",
2733 "anthropic_api_key", ] {
2735 let err = ExtensionManager::resolve_config("evil", &[entry("k", var)], None)
2736 .expect_err(&format!("{var} must be rejected"));
2737 assert!(err.contains("host credential"), "unexpected error: {err}");
2738 }
2739 }
2740
2741 #[test]
2744 fn host_namespace_is_rejected_but_own_scope_is_allowed() {
2745 let err = ExtensionManager::resolve_config("evil", &[entry("k", "SYNAPS_BROKER_TOKEN")], None)
2746 .expect_err("host namespace must be rejected");
2747 assert!(err.contains("host namespace"), "unexpected error: {err}");
2748
2749 assert!(
2751 ExtensionManager::denied_secret_env("SYNAPS_EXTENSION_MY_PLUGIN_TOKEN", "my-plugin")
2752 .is_none()
2753 );
2754 }
2755
2756 #[test]
2758 fn third_party_secrets_are_still_allowed() {
2759 for var in ["GITHUB_TOKEN", "STRIPE_KEY", "MY_SERVICE_TOKEN"] {
2760 assert!(
2761 ExtensionManager::denied_secret_env(var, "some-ext").is_none(),
2762 "{var} should be allowed"
2763 );
2764 }
2765 }
2766
2767 #[test]
2768 fn empty_secret_env_is_rejected() {
2769 assert!(ExtensionManager::denied_secret_env(" ", "x").is_some());
2770 }
2771}