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 plugin_roots() -> Vec<PathBuf> {
78 let mut roots = vec![crate::config::base_dir().join("plugins")];
79 if !project_plugins_disabled() {
80 if let Ok(cwd) = std::env::current_dir() {
81 let project_plugins = cwd.join(".synaps").join("plugins");
82 if project_plugins != roots[0] {
83 roots.push(project_plugins);
84 }
85 }
86 }
87 roots
88}
89
90fn kept_entries(registry: &crate::ToolRegistry, names: &[String]) -> Vec<(String, Arc<dyn crate::Tool>)> {
92 names
93 .iter()
94 .filter_map(|n| registry.get(n).map(|t| (n.clone(), Arc::clone(t))))
95 .collect()
96}
97
98fn same_tool(a: &Arc<dyn crate::Tool>, b: &Arc<dyn crate::Tool>) -> bool {
100 std::ptr::eq(Arc::as_ptr(a) as *const (), Arc::as_ptr(b) as *const ())
101}
102
103fn locate_plugin_dir(name: &str) -> Option<PathBuf> {
106 plugin_roots()
107 .into_iter()
108 .rev()
109 .map(|root| root.join(name))
110 .find(|dir| dir.is_dir())
111}
112
113fn read_extension_manifest(
119 plugin_name: &str,
120 plugin_dir: &std::path::Path,
121) -> Result<Option<(ExtensionManifest, PathBuf, Value)>, ExtensionLoadFailure> {
122 let manifest_path = plugin_dir.join(".synaps-plugin").join("plugin.json");
123 if !manifest_path.exists() {
124 return Ok(None);
125 }
126
127 let content = match std::fs::read_to_string(&manifest_path) {
128 Ok(c) => c,
129 Err(e) => {
130 let reason = format!("Failed to read plugin manifest: {e}");
131 tracing::warn!(plugin = %plugin_name, manifest = %manifest_path.display(), error = %e, "Failed to read plugin manifest");
132 return Err(ExtensionLoadFailure::new(
133 plugin_name,
134 Some(manifest_path),
135 reason,
136 "Check manifest file permissions",
137 ));
138 }
139 };
140
141 let json: serde_json::Value = match serde_json::from_str(&content) {
142 Ok(v) => v,
143 Err(e) => {
144 let reason = format!("Invalid plugin manifest JSON: {e}");
145 tracing::warn!(plugin = %plugin_name, manifest = %manifest_path.display(), error = %e, "Invalid plugin manifest JSON");
146 return Err(ExtensionLoadFailure::new(
147 plugin_name,
148 Some(manifest_path),
149 reason,
150 "Fix the JSON syntax in .synaps-plugin/plugin.json",
151 ));
152 }
153 };
154
155 let ext_value = match json.get("extension") {
156 Some(v) => v.clone(),
157 None => return Ok(None),
158 };
159
160 let ext_manifest: ExtensionManifest = match serde_json::from_value(ext_value) {
161 Ok(m) => m,
162 Err(e) => {
163 let reason = format!("Failed to parse extension manifest: {e}");
164 tracing::warn!(plugin = %plugin_name, manifest = %manifest_path.display(), error = %e, "Failed to parse extension manifest");
165 return Err(ExtensionLoadFailure::new(
166 plugin_name,
167 Some(manifest_path),
168 reason,
169 "Check the `extension` block shape against docs/extensions/contract.json",
170 ));
171 }
172 };
173
174 #[allow(clippy::if_same_then_else)]
175 let command = if std::path::Path::new(&ext_manifest.command).is_absolute() {
176 ext_manifest.command.clone()
177 } else if !ext_manifest.command.contains(std::path::MAIN_SEPARATOR)
178 && !ext_manifest.command.contains('/')
179 {
180 ext_manifest.command.clone()
181 } else {
182 plugin_dir
183 .join(&ext_manifest.command)
184 .to_string_lossy()
185 .to_string()
186 };
187
188 let args: Vec<String> = ext_manifest
189 .args
190 .iter()
191 .map(|arg| {
192 let arg_path = plugin_dir.join(arg);
193 if arg_path.exists() {
194 if let (Ok(canonical), Ok(plugin_canonical)) =
195 (arg_path.canonicalize(), plugin_dir.canonicalize())
196 {
197 if canonical.starts_with(&plugin_canonical) {
198 return canonical.to_string_lossy().to_string();
199 }
200 }
201 }
202 arg.clone()
203 })
204 .collect();
205
206 let resolved = ExtensionManifest {
207 theme_tokens: Default::default(),
208 command,
214 args,
215 ..ext_manifest
216 };
217 Ok(Some((resolved, manifest_path, json)))
218}
219
220#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
223pub struct ExtensionReloadReport {
224 pub extension: String,
226 pub was_loaded: bool,
229 pub deferred: bool,
231 pub tools: Vec<String>,
233 #[serde(default)]
235 pub removed_tools: Vec<String>,
236 pub plugin_dir: PathBuf,
238}
239
240fn sanitize_hint_fragment(input: &str) -> String {
241 input
242 .chars()
243 .map(|ch| if ch.is_control() { '?' } else { ch })
244 .collect::<String>()
245}
246
247#[derive(Debug, Clone, PartialEq, Eq)]
249pub struct ExtensionLoadFailure {
250 pub plugin: String,
251 pub manifest_path: Option<PathBuf>,
252 pub reason: String,
253 pub hint: String,
254}
255
256impl ExtensionLoadFailure {
257 fn new(
258 plugin: impl Into<String>,
259 manifest_path: Option<PathBuf>,
260 reason: impl Into<String>,
261 hint: impl Into<String>,
262 ) -> Self {
263 Self {
264 plugin: plugin.into(),
265 manifest_path,
266 reason: reason.into(),
267 hint: hint.into(),
268 }
269 }
270
271 pub fn concise_message(&self) -> String {
272 match &self.manifest_path {
273 Some(path) => format!(
274 "{} (manifest: {}; hint: {})",
275 self.reason,
276 path.display(),
277 self.hint
278 ),
279 None => format!("{} (hint: {})", self.reason, self.hint),
280 }
281 }
282}
283
284#[derive(Debug, Clone, PartialEq, Eq)]
286pub struct ExtensionStatus {
287 pub id: String,
288 pub health: ExtensionHealth,
289 pub restart_count: usize,
290}
291
292pub fn compute_extension_load_hint(
307 error: &str,
308 plugin_dir: &std::path::Path,
309 declared_setup: Option<&str>,
310) -> String {
311 if error == "independent Axel plugin is disabled by the host-selected memory backend" {
312 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();
313 }
314 let missing_binary =
315 error.contains("No such file or directory") || error.contains("os error 2");
316 match (missing_binary, declared_setup) {
317 (true, Some(setup)) => format!(
318 "Extension binary missing — this plugin ships source only. Run the setup script from the plugin directory, then reload. plugin_dir={}, setup={}",
319 sanitize_hint_fragment(&plugin_dir.display().to_string()),
320 sanitize_hint_fragment(setup),
321 ),
322 _ => "Check the `extension` block in .synaps-plugin/plugin.json against docs/extensions/contract.json and confirm the extension command is installed"
323 .to_string(),
324 }
325}
326
327#[derive(Debug, Clone, PartialEq, Eq, Default)]
333pub enum DiscoveryState {
334 #[default]
335 NotStarted,
336 Running,
337 Done {
338 loaded: Vec<String>,
339 failed: Vec<ExtensionLoadFailure>,
340 },
341}
342
343pub struct ExtensionManager {
345 discovery: DiscoveryState,
347 exclusive_memory: bool,
348 hook_bus: Arc<HookBus>,
350 tools: Option<Arc<tokio::sync::RwLock<crate::ToolRegistry>>>,
352 providers: ProviderRegistry,
354 extensions: HashMap<String, Arc<dyn ExtensionHandler>>,
356 eager_permissions: HashMap<String, PermissionSet>,
359 manifest_configs: HashMap<String, Vec<ExtensionConfigEntry>>,
362 capabilities: HashMap<String, Vec<crate::extensions::runtime::process::CapabilityDeclaration>>,
366 plugin_info: HashMap<String, PluginInfo>,
368 manifest_theme_tokens: HashMap<String, std::collections::BTreeMap<String, String>>,
373 progressive_deferral: bool,
378 deferred_tool_only: SharedDeferredRecords,
384 reload_replaces: Vec<String>,
389 extension_runtime: std::sync::OnceLock<Arc<crate::extensions::lease::ExtensionRuntimeManager>>,
393 context_providers: Vec<RegisteredContextProviderDescriptor>,
401 context_provider_catalog: SharedContextProviderCatalog,
408 host_project_root: std::sync::OnceLock<Option<String>>,
415}
416
417pub(crate) type SharedDeferredRecords =
420 Arc<std::sync::Mutex<HashMap<String, DeferredExtensionRecord>>>;
421
422#[derive(Clone)]
431pub(crate) struct DeferredExtensionRecord {
432 #[allow(dead_code)] pub(crate) exclusive_memory: bool,
434 pub(crate) manifest: ExtensionManifest,
435 pub(crate) cwd: Option<std::path::PathBuf>,
436 pub(crate) config: Value,
437}
438
439impl ExtensionManager {
440 pub fn new(hook_bus: Arc<HookBus>) -> Self {
442 Self {
443 discovery: DiscoveryState::NotStarted,
444 exclusive_memory: crate::memory_backend::MemoryBinding::configured_current()
446 .exclusive(),
447 hook_bus,
448 tools: None,
449 providers: ProviderRegistry::new(),
450 extensions: HashMap::new(),
451 eager_permissions: HashMap::new(),
452 manifest_configs: HashMap::new(),
453 capabilities: HashMap::new(),
454 plugin_info: HashMap::new(),
455 manifest_theme_tokens: HashMap::new(),
456 progressive_deferral: false,
457 deferred_tool_only: Arc::new(std::sync::Mutex::new(HashMap::new())),
458 reload_replaces: Vec::new(),
459 extension_runtime: std::sync::OnceLock::new(),
460 context_providers: Vec::new(),
461 context_provider_catalog: Arc::new(std::sync::Mutex::new(Vec::new())),
462 host_project_root: std::sync::OnceLock::new(),
463 }
464 }
465
466 pub fn new_with_tools(
468 hook_bus: Arc<HookBus>,
469 tools: Arc<tokio::sync::RwLock<crate::ToolRegistry>>,
470 ) -> Self {
471 Self {
472 discovery: DiscoveryState::NotStarted,
473 exclusive_memory: crate::memory_backend::MemoryBinding::configured_current()
475 .exclusive(),
476 hook_bus,
477 tools: Some(tools),
478 providers: ProviderRegistry::new(),
479 extensions: HashMap::new(),
480 eager_permissions: HashMap::new(),
481 manifest_configs: HashMap::new(),
482 capabilities: HashMap::new(),
483 plugin_info: HashMap::new(),
484 manifest_theme_tokens: HashMap::new(),
485 progressive_deferral: false,
486 deferred_tool_only: Arc::new(std::sync::Mutex::new(HashMap::new())),
487 reload_replaces: Vec::new(),
488 extension_runtime: std::sync::OnceLock::new(),
489 context_providers: Vec::new(),
490 context_provider_catalog: Arc::new(std::sync::Mutex::new(Vec::new())),
491 host_project_root: std::sync::OnceLock::new(),
492 }
493 }
494
495 pub fn bind_memory_backend(&mut self, exclusive: bool) {
497 self.exclusive_memory = exclusive;
498 }
499
500 pub async fn load(&mut self, id: &str, manifest: &ExtensionManifest) -> Result<(), String> {
502 self.load_with_cwd(id, manifest, None).await
503 }
504
505 pub async fn load_with_cwd(
507 &mut self,
508 id: &str,
509 manifest: &ExtensionManifest,
510 cwd: Option<std::path::PathBuf>,
511 ) -> Result<(), String> {
512 let deferred: Vec<String> = self
513 .deferred_records()
514 .lock()
515 .unwrap_or_else(std::sync::PoisonError::into_inner)
516 .keys()
517 .cloned()
518 .collect();
519 let loaded = self
520 .extensions
521 .keys()
522 .map(String::as_str)
523 .chain(deferred.iter().map(String::as_str));
524 if let Some(other) = Self::folded_id_collision(id, loaded) {
525 return Err(format!(
526 "Extension '{id}' collides with loaded extension '{other}': ids that differ only in '-'/'_' or case share SYNAPS_EXTENSION_* override variables"
527 ));
528 }
529 let config = Self::resolve_config(id, &manifest.config, self.host_project_root())?;
530 self.load_with_cwd_and_config(id, manifest, cwd, config)
531 .await
532 }
533
534 pub fn set_progressive_deferral(&mut self, enabled: bool) {
538 self.progressive_deferral = enabled;
539 }
540
541 pub fn host_project_root(&self) -> Option<&str> {
548 self.host_project_root
549 .get_or_init(|| {
550 let cwd = std::env::current_dir().ok()?;
551 agent_core::memory::store::ProjectScope::discover(&cwd)
552 .ok()
553 .map(|scope| scope.root().display().to_string())
554 })
555 .as_deref()
556 }
557
558 pub fn is_deferred(&self, id: &str) -> bool {
564 self.deferred_records()
565 .lock()
566 .unwrap_or_else(std::sync::PoisonError::into_inner)
567 .contains_key(id)
568 }
569
570 pub fn is_deferred_tool_only(&self, id: &str) -> bool {
572 self.is_deferred(id)
573 }
574
575 pub fn deferred_tool_only_count(&self) -> usize {
578 self.deferred_records()
579 .lock()
580 .unwrap_or_else(std::sync::PoisonError::into_inner)
581 .len()
582 }
583
584 fn deferred_records(&self) -> &SharedDeferredRecords {
585 &self.deferred_tool_only
586 }
587
588 fn publish_context_providers(&self) {
594 let mut published = self
595 .context_provider_catalog
596 .lock()
597 .unwrap_or_else(std::sync::PoisonError::into_inner);
598 *published = self.context_providers.clone();
599 }
600
601 fn catalog_context_providers(&mut self, id: &str, manifest: &ExtensionManifest) {
607 let descriptors = dormant_context_provider_descriptors(id, manifest);
608 if descriptors.is_empty() {
609 return;
610 }
611 self.context_providers.extend(descriptors);
612 self.publish_context_providers();
613 }
614
615 fn uncatalog_context_providers(&mut self, id: &str) {
618 let before = self.context_providers.len();
619 self.context_providers
620 .retain(|descriptor| descriptor.extension_id() != id);
621 if self.context_providers.len() != before {
622 self.publish_context_providers();
623 }
624 }
625
626 pub fn extension_runtime(&self) -> Arc<crate::extensions::lease::ExtensionRuntimeManager> {
634 self.extension_runtime_with_idle(crate::extensions::lease::DEFAULT_IDLE_MAX)
635 }
636
637 pub fn extension_runtime_with_idle(
640 &self,
641 idle_max: std::time::Duration,
642 ) -> Arc<crate::extensions::lease::ExtensionRuntimeManager> {
643 Arc::clone(self.extension_runtime.get_or_init(|| {
644 Arc::new(crate::extensions::lease::ExtensionRuntimeManager::new(
645 Arc::clone(&self.deferred_tool_only),
646 Arc::clone(&self.context_provider_catalog),
647 idle_max,
648 ))
649 }))
650 }
651
652 #[cfg(test)]
655 pub(crate) fn deferred_launch_record(&self, id: &str) -> Option<DeferredExtensionRecord> {
656 self.deferred_records()
657 .lock()
658 .unwrap_or_else(std::sync::PoisonError::into_inner)
659 .get(id)
660 .cloned()
661 }
662
663 #[doc(hidden)]
668 pub fn tamper_deferred_manifest_permissions_for_tests(
669 &self,
670 id: &str,
671 permissions: Vec<String>,
672 ) -> bool {
673 let mut map = self
674 .deferred_tool_only
675 .lock()
676 .unwrap_or_else(std::sync::PoisonError::into_inner);
677 match map.get_mut(id) {
678 Some(record) => {
679 record.manifest.permissions = permissions;
680 true
681 }
682 None => false,
683 }
684 }
685
686 #[doc(hidden)]
690 pub fn tamper_deferred_config_for_tests(&self, id: &str, config: Value) -> bool {
691 let mut map = self
692 .deferred_tool_only
693 .lock()
694 .unwrap_or_else(std::sync::PoisonError::into_inner);
695 match map.get_mut(id) {
696 Some(record) => {
697 record.config = config;
698 true
699 }
700 None => false,
701 }
702 }
703
704 async fn load_with_cwd_and_config(
705 &mut self,
706 id: &str,
707 manifest: &ExtensionManifest,
708 cwd: Option<std::path::PathBuf>,
709 config: Value,
710 ) -> Result<(), String> {
711 if self.extensions.contains_key(id) || self.is_deferred_tool_only(id) {
713 return Err(format!("Extension '{}' is already loaded", id));
714 }
715
716 let validated = manifest.validate(id)?;
720 let permissions = validated.permissions;
721 let subscriptions = validated.subscriptions;
722
723 let class = crate::extensions::lifecycle::classify(manifest);
739 if self.progressive_deferral
740 && class != crate::extensions::lifecycle::ExtensionClass::LegacyEager
741 {
742 let declared_tool_names: Vec<String> = manifest
743 .deferred
744 .as_ref()
745 .map(|d| d.tools.iter().map(|t| format!("{id}:{}", t.name)).collect())
746 .unwrap_or_default();
747 let dormant = crate::extensions::lifecycle::dormant_extension_tools(id, manifest);
748 let tool_count = dormant.len();
749 if !dormant.is_empty() {
750 let Some(tools) = &self.tools else {
751 return Err(format!(
752 "Extension '{}' declares deferred tools but no tool registry is available",
753 id
754 ));
755 };
756 let replacing = std::mem::take(&mut self.reload_replaces);
759 let mut registry = tools.write().await;
760 registry
761 .try_disable(&replacing)
762 .and_then(|()| registry.try_register_batch(dormant))
763 .map_err(|e| {
764 format!("Extension '{}' deferred tool catalog rejected: {}", id, e)
765 })?;
766 }
767 self.deferred_tool_only
770 .lock()
771 .unwrap_or_else(std::sync::PoisonError::into_inner)
772 .insert(
773 id.to_string(),
774 DeferredExtensionRecord {
775 exclusive_memory: self.exclusive_memory,
776 manifest: manifest.clone(),
777 cwd,
778 config: config.clone(),
779 },
780 );
781 macro_rules! unwind_deferred {
784 () => {{
785 self.deferred_tool_only
786 .lock()
787 .unwrap_or_else(std::sync::PoisonError::into_inner)
788 .remove(id);
789 self.providers.unregister_plugin(id);
790 if let Some(tools) = &self.tools {
791 let _ = tools.write().await.try_disable(&declared_tool_names);
792 }
793 self.hook_bus.unsubscribe_all(id).await;
794 }};
795 }
796 let lazy: Option<Arc<dyn ExtensionHandler>> = {
797 let needs_lazy = manifest
801 .deferred
802 .as_ref()
803 .map(|d| !d.providers.is_empty())
804 .unwrap_or(false)
805 || !manifest.hooks.is_empty();
806 needs_lazy.then(|| {
807 Arc::new(crate::extensions::lease::LazyExtensionHandler::new(
808 id,
809 self.extension_runtime(),
810 )) as Arc<dyn ExtensionHandler>
811 })
812 };
813 let declared_providers: Vec<_> = manifest
817 .deferred
818 .as_ref()
819 .map(|d| d.providers.clone())
820 .unwrap_or_default();
821 let mut registered_provider_ids = Vec::new();
822 for declared in &declared_providers {
823 let spec = declared.to_registered_spec();
824 if let Err(error) = Self::validate_provider_config_requirements(id, &spec, &config)
825 {
826 unwind_deferred!();
827 return Err(error);
828 }
829 let handler = lazy.clone().expect("providers imply a lazy handler");
830 match self
831 .providers
832 .register_with_handler(id, spec, Some(handler))
833 {
834 Ok(runtime_id) => registered_provider_ids.push(runtime_id),
835 Err(error) => {
836 unwind_deferred!();
837 return Err(error);
838 }
839 }
840 }
841 for runtime_id in ®istered_provider_ids {
843 if let Some(provider) = self.providers.get(runtime_id) {
844 let tool_use = provider.spec.models.iter().any(|m| {
845 m.capabilities
846 .get("tool_use")
847 .and_then(|v| v.as_bool())
848 .unwrap_or(false)
849 });
850 if tool_use {
851 tracing::warn!(
852 "Provider '{}' is tool-use capable: it can request Synaps tools through provider mediation. Use `/extensions trust disable {}` to block routing.",
853 runtime_id,
854 runtime_id,
855 );
856 }
857 }
858 }
859 for (kind, tool_filter, matcher) in subscriptions {
862 let handler = lazy.clone().expect("subscriptions imply a lazy handler");
863 if let Err(error) = self
864 .hook_bus
865 .subscribe(kind, handler, tool_filter, matcher, permissions.clone())
866 .await
867 {
868 unwind_deferred!();
869 return Err(error);
870 }
871 }
872 self.manifest_configs
873 .insert(id.to_string(), manifest.config.clone());
874 if !manifest.theme_tokens.is_empty() {
875 self.manifest_theme_tokens
876 .insert(id.to_string(), manifest.theme_tokens.clone());
877 }
878 self.catalog_context_providers(id, manifest);
883 tracing::info!(extension = %id, class = ?class, tools = tool_count,
884 providers = registered_provider_ids.len(), hooks = manifest.hooks.len(),
885 context_providers = self.context_providers.iter().filter(|d| d.extension_id() == id).count(),
886 "Extension deferred: passive surfaces registered, no process started");
887 return Ok(());
888 }
889
890 let process = ProcessExtension::spawn_with_memory_policy(
892 id,
893 &manifest.command,
894 &manifest.args,
895 cwd.clone(),
896 self.exclusive_memory,
897 )
898 .await?;
899 process.set_permissions(permissions.clone()).await;
902 let capabilities = match process.initialize(cwd.clone(), config.clone()).await {
903 Ok(capabilities) => capabilities,
904 Err(error) => {
905 process.shutdown().await;
906 return Err(error);
907 }
908 };
909 let registered_tools = capabilities.tools;
910 let registered_providers = capabilities.providers;
911 let capability_declarations = capabilities.capabilities;
912 let should_probe_info = !registered_tools.is_empty()
913 || !registered_providers.is_empty()
914 || !capability_declarations.is_empty();
915 let handler: Arc<dyn ExtensionHandler> = Arc::new(process);
916 if !registered_tools.is_empty()
917 && !permissions.has(crate::extensions::permissions::Permission::ToolsRegister)
918 {
919 handler.shutdown().await;
920 return Err(format!(
921 "Extension '{}' registered tools but lacks permission 'tools.register'",
922 id
923 ));
924 }
925 if !registered_providers.is_empty()
926 && !permissions.has(crate::extensions::permissions::Permission::ProvidersRegister)
927 {
928 handler.shutdown().await;
929 return Err(format!(
930 "Extension '{}' registered providers but lacks permission 'providers.register'",
931 id
932 ));
933 }
934 for decl in &capability_declarations {
935 if let Err(err) =
936 crate::extensions::runtime::process::validate_capability(decl, &permissions)
937 {
938 handler.shutdown().await;
939 return Err(format!(
940 "Extension '{}' capability '{}' invalid: {}",
941 id, decl.kind, err
942 ));
943 }
944 }
945 if !registered_providers.is_empty() {
946 let mut registered_ids = Vec::new();
947 for provider in registered_providers {
948 if let Err(error) =
949 Self::validate_provider_config_requirements(id, &provider, &config)
950 {
951 self.providers.unregister_plugin(id);
952 handler.shutdown().await;
953 return Err(error);
954 }
955 match self
956 .providers
957 .register_with_handler(id, provider, Some(handler.clone()))
958 {
959 Ok(runtime_id) => registered_ids.push(runtime_id),
960 Err(error) => {
961 self.providers.unregister_plugin(id);
962 handler.shutdown().await;
963 return Err(error);
964 }
965 }
966 }
967 tracing::info!(extension = %id, providers = ?registered_ids, "Extension provider metadata registered");
968 for runtime_id in ®istered_ids {
970 if let Some(provider) = self.providers.get(runtime_id) {
971 let tool_use = provider.spec.models.iter().any(|m| {
972 m.capabilities
973 .get("tool_use")
974 .and_then(|v| v.as_bool())
975 .unwrap_or(false)
976 });
977 if tool_use {
978 tracing::warn!(
979 "Provider '{}' is tool-use capable: it can request Synaps tools through provider mediation. Use `/extensions trust disable {}` to block routing.",
980 runtime_id,
981 runtime_id,
982 );
983 }
984 }
985 }
986 }
987 if !registered_tools.is_empty() {
988 let Some(tools) = &self.tools else {
989 handler.shutdown().await;
990 return Err(format!(
991 "Extension '{}' registered tools but no tool registry is available",
992 id
993 ));
994 };
995 let mut registry = tools.write().await;
996 for spec in registered_tools {
997 let tool_name = spec.name.clone();
998 if let Err(e) = registry.try_register(Arc::new(crate::tools::ExtensionTool::new(
999 id,
1000 spec,
1001 handler.clone(),
1002 ))) {
1003 tracing::warn!(
1004 extension = %id,
1005 tool = %tool_name,
1006 error = %e,
1007 "Refusing to expose extension tool the capability catalog could not record"
1008 );
1009 }
1010 }
1011 }
1012
1013 let info = if should_probe_info {
1018 match handler.get_info().await {
1019 Ok(info) => Some(info),
1020 Err(error) => {
1021 if error.contains("method not found") || error.contains("unknown method") {
1022 tracing::debug!(
1023 extension = %id,
1024 error = %error,
1025 "Extension did not provide optional info.get metadata",
1026 );
1027 None
1028 } else {
1029 tracing::warn!(
1030 extension = %id,
1031 error = %error,
1032 "Ignoring invalid optional info.get metadata",
1033 );
1034 None
1035 }
1036 }
1037 }
1038 } else {
1039 None
1040 };
1041
1042 for (kind, tool_filter, matcher) in subscriptions {
1044 self.hook_bus
1045 .subscribe(
1046 kind,
1047 handler.clone(),
1048 tool_filter,
1049 matcher,
1050 permissions.clone(),
1051 )
1052 .await?;
1053 }
1054
1055 self.extensions.insert(id.to_string(), handler);
1056 self.eager_permissions.insert(id.to_string(), permissions);
1057 self.manifest_configs
1058 .insert(id.to_string(), manifest.config.clone());
1059 if !manifest.theme_tokens.is_empty() {
1060 self.manifest_theme_tokens
1061 .insert(id.to_string(), manifest.theme_tokens.clone());
1062 }
1063 if !capability_declarations.is_empty() {
1064 self.capabilities
1065 .insert(id.to_string(), capability_declarations);
1066 }
1067 if let Some(info) = info {
1068 self.plugin_info.insert(id.to_string(), info);
1069 }
1070 self.catalog_context_providers(id, manifest);
1076 tracing::info!(extension = %id, hooks = manifest.hooks.len(), "Extension loaded");
1077 Ok(())
1078 }
1079
1080 fn validate_provider_config_requirements(
1081 id: &str,
1082 provider: &crate::extensions::runtime::process::RegisteredProviderSpec,
1083 config: &Value,
1084 ) -> Result<(), String> {
1085 let Some(required) = provider
1086 .config_schema
1087 .as_ref()
1088 .and_then(|schema| schema.get("required"))
1089 .and_then(Value::as_array)
1090 else {
1091 return Ok(());
1092 };
1093 for key in required {
1094 let Some(key) = key.as_str() else {
1095 return Err(format!(
1096 "Extension '{}' provider '{}' config_schema.required must contain only strings",
1097 id, provider.id,
1098 ));
1099 };
1100 let present = config
1101 .as_object()
1102 .map(|map| map.contains_key(key))
1103 .unwrap_or(false);
1104 if !present {
1105 return Err(format!(
1106 "Extension '{}' provider '{}' missing required provider config '{}'",
1107 id, provider.id, key,
1108 ));
1109 }
1110 }
1111 Ok(())
1112 }
1113
1114 pub(crate) fn denied_secret_env(
1140 var: &str,
1141 id: &str,
1142 own_keys: &[&str],
1143 ) -> Option<&'static str> {
1144 if var.starts_with("SYNAPS_")
1145 && !own_keys
1146 .iter()
1147 .any(|k| Self::extension_override_var(id, k) == var)
1148 {
1149 return Some(
1150 "SYNAPS_* is the host namespace; only this extension's own SYNAPS_EXTENSION_<ID>_<KEY> for a declared key is allowed",
1151 );
1152 }
1153 if Self::is_host_credential_env(var) {
1154 return Some(
1155 "that variable holds a host credential; extensions are never handed the host's own provider keys",
1156 );
1157 }
1158 None
1159 }
1160
1161 pub(crate) fn secret_env_name(raw: &str) -> Option<&str> {
1165 let name = raw.trim();
1166 let mut chars = name.chars();
1167 let valid = chars
1168 .next()
1169 .is_some_and(|c| c.is_ascii_uppercase() || c == '_')
1170 && chars.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_');
1171 valid.then_some(name)
1172 }
1173
1174 fn extension_override_var(id: &str, key: &str) -> String {
1177 format!(
1178 "SYNAPS_EXTENSION_{}_{}",
1179 id.replace('-', "_").to_ascii_uppercase(),
1180 key.replace('-', "_").to_ascii_uppercase()
1181 )
1182 }
1183
1184 fn folded_id_collision<'a>(id: &str, loaded: impl Iterator<Item = &'a str>) -> Option<&'a str> {
1187 let fold = |s: &str| s.replace('-', "_").to_ascii_uppercase();
1188 let mine = fold(id);
1189 loaded
1190 .filter(|other| *other != id)
1191 .find(|other| fold(other) == mine)
1192 }
1193
1194 fn is_host_credential_env(up: &str) -> bool {
1196 const FLOOR: &[&str] = &[
1198 "ANTHROPIC_API_KEY",
1199 "OPENAI_API_KEY",
1200 "GEMINI_API_KEY",
1201 "GOOGLE_API_KEY",
1202 "XAI_API_KEY",
1203 "GROQ_API_KEY",
1204 "OPENROUTER_API_KEY",
1205 "DEEPSEEK_API_KEY",
1206 "MISTRAL_API_KEY",
1207 "AWS_SECRET_ACCESS_KEY",
1208 "AWS_SESSION_TOKEN",
1209 ];
1210 const OTHER_SOURCES: &[&str] = &[
1214 agent_core::auth::muse::API_KEY_ENV,
1215 "LOCAL_API_KEY",
1216 "OPENAI_CODEX_ACCESS_TOKEN",
1217 "AWS_ACCESS_KEY_ID",
1218 "SYNAPS_MACHINE_TOKEN",
1219 "SYNAPS_BROKER_TOKEN",
1220 "HTTPS_PROXY",
1223 "HTTP_PROXY",
1224 "ALL_PROXY",
1225 "NO_PROXY",
1226 ];
1227 FLOOR.contains(&up)
1228 || OTHER_SOURCES.contains(&up)
1229 || agent_core::auth::static_providers::STATIC_PROVIDERS
1230 .iter()
1231 .any(|s| s.env_vars.contains(&up))
1232 || crate::runtime::openai::registry::providers()
1233 .iter()
1234 .any(|s| s.env_vars.contains(&up))
1235 }
1236
1237 fn resolve_config(
1238 id: &str,
1239 entries: &[ExtensionConfigEntry],
1240 host_project_root: Option<&str>,
1241 ) -> Result<Value, String> {
1242 Self::resolve_config_with_env(id, entries, host_project_root, &|name| {
1243 std::env::var(name).ok()
1244 })
1245 }
1246
1247 fn resolve_config_with_env(
1250 id: &str,
1251 entries: &[ExtensionConfigEntry],
1252 host_project_root: Option<&str>,
1253 env: &dyn Fn(&str) -> Option<String>,
1254 ) -> Result<Value, String> {
1255 let mut out = Map::new();
1256 let own_keys: Vec<&str> = entries.iter().map(|e| e.key.trim()).collect();
1257 for entry in entries {
1258 let key = entry.key.trim();
1259 if key.is_empty() {
1260 return Err(format!("Extension '{}' declares config with empty key", id));
1261 }
1262 if key.contains('.') || key.contains('/') || key.contains(' ') {
1263 return Err(format!(
1264 "Extension '{}' config key '{}' must not contain dots, slashes, or spaces",
1265 id, key,
1266 ));
1267 }
1268 if let Some(host_key) = entry.host_context {
1274 if entry.secret_env.is_some() {
1275 return Err(format!(
1276 "Extension '{}' config key '{}' declares both host_context and secret_env; host-context values never carry secrets",
1277 id, key,
1278 ));
1279 }
1280 match host_key {
1281 crate::extensions::manifest::HostContextKey::ProjectRoot => {
1282 if let Some(root) = host_project_root {
1283 out.insert(key.to_string(), Value::String(root.to_string()));
1284 } else if let Some(default) = &entry.default {
1285 out.insert(key.to_string(), default.clone());
1286 } else if entry.required {
1287 return Err(format!(
1288 "Extension '{}' requires host context 'project_root' but the host could not resolve a trusted project root",
1289 id,
1290 ));
1291 }
1292 }
1293 }
1294 continue;
1295 }
1296 let config_key = format!("extension.{}.{}", id, key);
1297 let secret_env: Option<&str> = match entry.secret_env.as_deref() {
1303 None => None,
1304 Some(raw) => match Self::secret_env_name(raw) {
1305 None => {
1306 let shown: String = raw.chars().take(64).collect();
1307 tracing::warn!(
1308 extension = %id,
1309 key = %key,
1310 secret_env = ?shown,
1311 "ignoring secret_env: not an upper-case variable name matching [A-Z_][A-Z0-9_]*"
1312 );
1313 None
1314 }
1315 Some(name) => {
1316 if let Some(reason) = Self::denied_secret_env(name, id, &own_keys) {
1317 return Err(format!(
1318 "Extension '{}' config key '{}' declares secret_env '{}': {}",
1319 id, key, name, reason
1320 ));
1321 }
1322 Some(name)
1323 }
1324 },
1325 };
1326 if let Some(value) = env(&Self::extension_override_var(id, key)) {
1327 out.insert(key.to_string(), Value::String(value));
1328 continue;
1329 }
1330 if let Some(secret_env) = secret_env {
1331 if let Some(value) = env(secret_env) {
1332 out.insert(key.to_string(), Value::String(value));
1333 continue;
1334 }
1335 }
1336 if let Some(value) = crate::extensions::config_store::read_plugin_config(id, key) {
1337 out.insert(key.to_string(), Value::String(value));
1338 continue;
1339 }
1340 if let Some(value) = crate::config::read_config_value(&config_key) {
1341 out.insert(key.to_string(), Value::String(value));
1342 continue;
1343 }
1344 if let Some(default) = &entry.default {
1345 out.insert(key.to_string(), default.clone());
1346 continue;
1347 }
1348 if entry.required {
1349 let hint = if let Some(secret_env) = secret_env {
1350 format!(
1351 "set environment variable '{}' or config key '{}'",
1352 secret_env, config_key
1353 )
1354 } else {
1355 format!("set config key '{}'", config_key)
1356 };
1357 return Err(format!(
1358 "Extension '{}' missing required config '{}': {}",
1359 id, key, hint
1360 ));
1361 }
1362 }
1363 Ok(Value::Object(out))
1364 }
1365
1366 #[cfg(test)]
1370 pub(crate) fn test_seed_capabilities(
1371 &mut self,
1372 id: &str,
1373 decls: Vec<crate::extensions::runtime::process::CapabilityDeclaration>,
1374 ) {
1375 self.capabilities.insert(id.to_string(), decls);
1376 }
1377
1378 pub async fn unload(&mut self, id: &str) -> Result<(), String> {
1386 self.unload_inner(id, false).await.map(|_| ())
1387 }
1388
1389 async fn unload_inner(
1395 &mut self,
1396 id: &str,
1397 keep_tools: bool,
1398 ) -> Result<Vec<(String, Arc<dyn crate::Tool>)>, String> {
1399 let deferred = self
1400 .deferred_tool_only
1401 .lock()
1402 .unwrap_or_else(std::sync::PoisonError::into_inner)
1403 .remove(id);
1404 if let Some(record) = deferred {
1405 self.uncatalog_context_providers(id);
1411 if let Some(runtime) = self.extension_runtime.get() {
1412 let revoked = runtime.revoke_plugin_all_sessions(id);
1413 if revoked > 0 {
1414 tracing::info!(extension = %id, leases = revoked,
1415 "Terminated live runtime leases of unloaded deferred extension");
1416 }
1417 }
1418 self.hook_bus.unsubscribe_all(id).await;
1419 self.providers.unregister_plugin(id);
1420 let mut kept = Vec::new();
1421 if let Some(tools) = &self.tools {
1422 let names: Vec<String> = record
1423 .manifest
1424 .deferred
1425 .as_ref()
1426 .map(|d| {
1427 d.tools
1428 .iter()
1429 .map(|t| format!("{}:{}", id, t.name))
1430 .collect()
1431 })
1432 .unwrap_or_default();
1433 if keep_tools {
1434 let registry = tools.read().await;
1435 kept = kept_entries(®istry, &names);
1436 self.reload_replaces = names.clone();
1437 } else if let Err(error) = tools.write().await.try_disable(&names) {
1438 return Err(format!(
1442 "Extension '{}' unloaded (record removed, leases revoked) but its dormant catalog batch could not be deregistered: {}",
1443 id, error
1444 ));
1445 }
1446 }
1447 self.manifest_configs.remove(id);
1448 self.manifest_theme_tokens.remove(id);
1449 tracing::info!(extension = %id, "Deferred extension unloaded");
1450 return Ok(kept);
1451 }
1452 let handler = self
1453 .extensions
1454 .remove(id)
1455 .ok_or_else(|| format!("Extension '{}' not found", id))?;
1456 self.uncatalog_context_providers(id);
1459
1460 self.hook_bus.unsubscribe_all(id).await;
1461 self.providers.unregister_plugin(id);
1462 let mut kept = Vec::new();
1466 let tool_error = match &self.tools {
1467 Some(tools) if keep_tools => {
1468 let registry = tools.read().await;
1469 let names = registry.tool_names_for_extension(id);
1470 kept = kept_entries(®istry, &names);
1471 self.reload_replaces = names;
1473 None
1474 }
1475 Some(tools) => {
1476 let mut registry = tools.write().await;
1477 let names = registry.tool_names_for_extension(id);
1478 registry.try_disable(&names).err()
1479 }
1480 None => None,
1481 };
1482 self.eager_permissions.remove(id);
1483 self.manifest_configs.remove(id);
1484 self.capabilities.remove(id);
1485 self.plugin_info.remove(id);
1486 self.manifest_theme_tokens.remove(id);
1487 handler.shutdown().await;
1488
1489 if let Some(error) = tool_error {
1490 return Err(format!(
1493 "Extension '{}' unloaded but its tools could not be deregistered: {}",
1494 id, error
1495 ));
1496 }
1497 tracing::info!(extension = %id, "Extension unloaded");
1498 Ok(kept)
1499 }
1500
1501 async fn settle_replaced_tools(&mut self, id: &str, kept: Vec<(String, Arc<dyn crate::Tool>)>) -> Result<(), String> {
1507 self.reload_replaces.clear();
1508 let Some(tools) = &self.tools else { return Ok(()) };
1509 if kept.is_empty() {
1510 return Ok(());
1511 }
1512 let mut registry = tools.write().await;
1513 let stale: Vec<String> = kept
1514 .into_iter()
1515 .filter(|(name, old)| registry.get(name).is_some_and(|now| same_tool(now, old)))
1516 .map(|(name, _)| name)
1517 .collect();
1518 registry
1519 .try_disable(&stale)
1520 .map_err(|e| format!("Extension '{id}' reloaded but its stale tools could not be deregistered: {e}"))
1521 }
1522
1523 pub async fn reload(
1536 &mut self,
1537 id: &str,
1538 manifest: &ExtensionManifest,
1539 cwd: Option<std::path::PathBuf>,
1540 ) -> Result<(), String> {
1541 let kept = if self.extensions.contains_key(id) || self.is_deferred_tool_only(id) {
1542 self.unload_inner(id, true).await?
1543 } else {
1544 Vec::new()
1545 };
1546 let loaded = self.load_with_cwd(id, manifest, cwd).await;
1547 let settled = self.settle_replaced_tools(id, kept).await;
1548 loaded.and(settled)
1549 }
1550
1551 pub async fn reload_from_disk(
1569 &mut self,
1570 id: &str,
1571 ) -> Result<ExtensionReloadReport, ExtensionLoadFailure> {
1572 let refuse = |reason: String, hint: &str| ExtensionLoadFailure::new(id, None, reason, hint);
1573 if id.is_empty()
1574 || id == "."
1575 || id == ".."
1576 || id.contains('/')
1577 || id.contains('\\')
1578 || id.contains(':')
1579 || id.chars().any(char::is_control)
1580 {
1581 return Err(refuse(
1582 format!("'{id}' is not a plugin name"),
1583 "Give the plugin's directory name, as `/extensions` lists it",
1584 ));
1585 }
1586 if is_inert_plugin_dir_name(id) {
1587 return Err(refuse(
1588 format!("'{id}' is a backup, disabled or temp directory name; discovery never loads it"),
1589 "Give the plugin's directory name without the suffix",
1590 ));
1591 }
1592 if !matches!(self.discovery, DiscoveryState::Done { .. }) {
1593 return Err(refuse(
1594 "extensions have not finished loading yet".into(),
1595 "Retry in a moment",
1596 ));
1597 }
1598 let was_loaded = self.is_loaded(id);
1599 let Some(plugin_dir) = locate_plugin_dir(id) else {
1600 let roots = plugin_roots()
1601 .iter()
1602 .map(|r| r.display().to_string())
1603 .collect::<Vec<_>>()
1604 .join(", ");
1605 return Err(refuse(
1606 format!("no plugin directory named '{id}' in {roots}"),
1607 "Check the name against `ls ~/.synaps-cli/plugins` or `/extensions`",
1608 ));
1609 };
1610 if crate::config::load_config()
1611 .disabled_plugins
1612 .iter()
1613 .any(|d| d == id)
1614 {
1615 return Err(refuse(
1616 format!("'{id}' is disabled (disabled_plugins in config)"),
1617 "Remove it from disabled_plugins first",
1618 ));
1619 }
1620 let plugins_state = {
1621 let state_path = crate::skills::state::PluginsState::default_path();
1622 crate::skills::state::PluginsState::load_from(&state_path).unwrap_or_default()
1623 };
1624 if let Some(message) = installed_plugin_setup_failure_in(&plugins_state, id) {
1625 return Err(refuse(
1626 format!("Post-install setup failed: {message}"),
1627 "Open /plugins, reinstall or update the plugin after fixing setup",
1628 ));
1629 }
1630 let (resolved, manifest_path, json) = match read_extension_manifest(id, &plugin_dir)? {
1631 Some(read) => read,
1632 None => {
1633 return Err(ExtensionLoadFailure::new(
1634 id,
1635 Some(plugin_dir.join(".synaps-plugin").join("plugin.json")),
1636 format!("'{id}' has no extension (no plugin.json, or no `extension` block in it)"),
1637 "Only plugins with an `extension` block run as extensions",
1638 ));
1639 }
1640 };
1641 if let Err(e) = resolved.validate(id) {
1642 return Err(ExtensionLoadFailure::new(
1643 id,
1644 Some(manifest_path),
1645 e,
1646 "Fix the manifest; the running instance was not touched",
1647 ));
1648 }
1649
1650 let prefix = format!("{id}:");
1652 let owned_tools = |registry: &crate::ToolRegistry| -> Vec<String> {
1653 registry
1654 .iter_tools_sorted()
1655 .into_iter()
1656 .map(|t| t.name().to_string())
1657 .filter(|n| n.starts_with(&prefix))
1658 .collect()
1659 };
1660 let before = match &self.tools {
1661 Some(tools) => owned_tools(&*tools.read().await),
1662 None => Vec::new(),
1663 };
1664 let outcome = self.reload(id, &resolved, Some(plugin_dir.clone())).await;
1665 let failure = outcome.as_ref().err().map(|e| {
1666 let setup_script = json
1667 .pointer("/extension/setup")
1668 .and_then(|v| v.as_str())
1669 .or_else(|| json.pointer("/provides/sidecar/setup").and_then(|v| v.as_str()));
1670 let hint = compute_extension_load_hint(e, &plugin_dir, setup_script);
1671 let reason = if was_loaded {
1672 format!("{e} (the previous instance was stopped, so '{id}' is not loaded now)")
1673 } else {
1674 e.clone()
1675 };
1676 ExtensionLoadFailure::new(id, Some(manifest_path.clone()), reason, hint)
1677 });
1678 if let DiscoveryState::Done { loaded, failed } = &mut self.discovery {
1679 loaded.retain(|l| l != id);
1680 failed.retain(|f| f.plugin != id);
1681 match &failure {
1682 None => loaded.push(id.to_string()),
1683 Some(f) => failed.push(f.clone()),
1684 }
1685 }
1686 if let Some(failure) = failure {
1687 tracing::warn!(extension = %id, error = %failure.reason, "Extension reload failed");
1688 return Err(failure);
1689 }
1690 let tools = match &self.tools {
1691 Some(tools) => owned_tools(&*tools.read().await),
1692 None => Vec::new(),
1693 };
1694 let removed_tools = before.into_iter().filter(|n| !tools.contains(n)).collect();
1695 let deferred = self.is_deferred_tool_only(id);
1696 tracing::info!(extension = %id, was_loaded, deferred, tools = tools.len(), path = %plugin_dir.display(), "Extension reloaded");
1697 Ok(ExtensionReloadReport {
1698 extension: id.to_string(),
1699 was_loaded,
1700 deferred,
1701 tools,
1702 removed_tools,
1703 plugin_dir,
1704 })
1705 }
1706
1707 pub fn is_loaded(&self, id: &str) -> bool {
1709 self.extensions.contains_key(id) || self.is_deferred_tool_only(id)
1710 }
1711
1712 pub fn discovery_state(&self) -> &DiscoveryState {
1714 &self.discovery
1715 }
1716
1717 pub fn discovery_done(&self) -> Option<(Vec<String>, Vec<ExtensionLoadFailure>)> {
1721 match &self.discovery {
1722 DiscoveryState::Done { loaded, failed } => Some((loaded.clone(), failed.clone())),
1723 _ => None,
1724 }
1725 }
1726
1727 pub async fn shutdown_all(&mut self) {
1730 self.discovery = DiscoveryState::NotStarted;
1731 let ids: Vec<String> = self.extensions.keys().cloned().collect();
1732 for id in ids {
1733 let _ = self.unload(&id).await;
1734 }
1735 let deferred_ids: Vec<String> = {
1738 let map = self
1739 .deferred_tool_only
1740 .lock()
1741 .unwrap_or_else(std::sync::PoisonError::into_inner);
1742 map.keys().cloned().collect()
1743 };
1744 for id in deferred_ids {
1745 let _ = self.unload(&id).await;
1746 }
1747 if let Some(runtime) = self.extension_runtime.get() {
1748 runtime.terminate_all();
1749 }
1750 }
1751
1752 pub fn shutdown_all_detached(
1758 manager: Arc<tokio::sync::RwLock<Self>>,
1759 ) -> tokio::task::JoinHandle<()> {
1760 tokio::spawn(async move {
1761 manager.write().await.shutdown_all().await;
1762 })
1763 }
1764
1765 pub fn list(&self) -> Vec<&str> {
1767 self.extensions.keys().map(|s| s.as_str()).collect()
1768 }
1769
1770 pub fn handlers(&self) -> Vec<(String, Arc<dyn super::runtime::ExtensionHandler>)> {
1774 let mut out: Vec<_> = self
1775 .extensions
1776 .iter()
1777 .map(|(id, h)| (id.clone(), Arc::clone(h)))
1778 .collect();
1779 out.sort_by(|a, b| a.0.cmp(&b.0));
1780 out
1781 }
1782
1783 pub fn count(&self) -> usize {
1785 self.extensions.len()
1786 }
1787
1788 pub async fn statuses(&self) -> Vec<ExtensionStatus> {
1790 let mut handlers: Vec<(String, Arc<dyn ExtensionHandler>)> = self
1791 .extensions
1792 .iter()
1793 .map(|(id, handler)| (id.clone(), handler.clone()))
1794 .collect();
1795 handlers.sort_by(|a, b| a.0.cmp(&b.0));
1796
1797 let mut statuses = Vec::with_capacity(handlers.len());
1798 for (id, handler) in handlers {
1799 statuses.push(ExtensionStatus {
1800 id,
1801 health: handler.health().await,
1802 restart_count: handler.restart_count().await,
1803 });
1804 }
1805 statuses
1806 }
1807
1808 pub fn providers(&self) -> Vec<&RegisteredProvider> {
1810 self.providers.list()
1811 }
1812
1813 pub fn provider(&self, runtime_id: &str) -> Option<&RegisteredProvider> {
1815 self.providers.get(runtime_id)
1816 }
1817
1818 pub fn context_providers(&self) -> Vec<&RegisteredContextProviderDescriptor> {
1824 let mut out: Vec<&RegisteredContextProviderDescriptor> =
1825 self.context_providers.iter().collect();
1826 out.sort_by_key(|descriptor| descriptor.runtime_address());
1827 out
1828 }
1829
1830 pub fn context_provider(
1837 &self,
1838 provider_id: &ContextProviderId,
1839 ) -> Option<&RegisteredContextProviderDescriptor> {
1840 resolve_context_provider(&self.context_providers, Some(provider_id)).ok()
1841 }
1842
1843 pub fn plugin_info(&self, id: &str) -> Option<&PluginInfo> {
1845 self.plugin_info.get(id)
1846 }
1847
1848 pub fn theme_tokens(&self) -> Vec<(String, std::collections::BTreeMap<String, String>)> {
1853 let mut out: Vec<_> = self
1854 .manifest_theme_tokens
1855 .iter()
1856 .map(|(id, tokens)| (id.clone(), tokens.clone()))
1857 .collect();
1858 out.sort_by(|a, b| a.0.cmp(&b.0));
1859 out
1860 }
1861
1862 pub fn user_action_handler(
1873 &self,
1874 id: &str,
1875 ) -> Result<Arc<dyn super::runtime::ExtensionHandler>, String> {
1876 if let Some(handler) = self.extensions.get(id) {
1877 return Ok(handler.clone());
1878 }
1879 if self.is_deferred(id) {
1880 return Ok(Arc::new(
1881 crate::extensions::lease::LazyExtensionHandler::new(id, self.extension_runtime()),
1882 ));
1883 }
1884 Err(format!("unknown extension '{}'", id))
1885 }
1886
1887 pub fn session_driver_handler(&self, id: &str) -> Result<Arc<dyn ExtensionHandler>, String> {
1893 if self.is_deferred(id) {
1894 return Err("session drivers require an eagerly loaded extension; deferred drivers are unsupported".into());
1895 }
1896 let handler = self
1897 .extensions
1898 .get(id)
1899 .ok_or_else(|| "session driver extension is not loaded".to_string())?;
1900 if !self
1901 .eager_permissions
1902 .get(id)
1903 .is_some_and(|permissions| permissions.has(Permission::SessionDrive))
1904 {
1905 return Err("session driver extension lacks validated session.drive permission".into());
1906 }
1907 Ok(handler.clone())
1908 }
1909
1910 pub fn has_session_drive(&self, id: &str) -> bool {
1916 self.eager_permissions
1917 .get(id)
1918 .is_some_and(|permissions| permissions.has(Permission::SessionDrive))
1919 }
1920
1921 pub async fn sidecar_spawn_args(
1922 &self,
1923 id: &str,
1924 ) -> Result<crate::sidecar::spawn::SidecarSpawnArgs, String> {
1925 let handler = self.user_action_handler(id)?;
1926 handler.sidecar_spawn_args().await
1927 }
1928
1929 pub async fn invoke_command(
1940 &self,
1941 id: &str,
1942 command: &str,
1943 args: Vec<String>,
1944 request_id: &str,
1945 sink: crate::extensions::invoke_output::InvokeEventSink,
1946 ) -> Result<serde_json::Value, String> {
1947 let handler = self.user_action_handler(id)?;
1948 handler
1949 .invoke_command(command, args, request_id, sink)
1950 .await
1951 }
1952
1953 pub async fn invoke_command_collected(
1965 &self,
1966 id: &str,
1967 command: &str,
1968 args: Vec<String>,
1969 request_id: &str,
1970 ) -> (
1971 Result<serde_json::Value, String>,
1972 crate::extensions::invoke_output::InvokeOutputReport,
1973 ) {
1974 let (sink, collector) = crate::extensions::invoke_output::invoke_event_channel(
1975 crate::extensions::invoke_output::InvokeOutputBudget::default(),
1976 );
1977 let handler = match self.user_action_handler(id) {
1978 Ok(handler) => handler,
1979 Err(error) => {
1980 drop(sink);
1981 return (Err(error), collector.collect().await);
1982 }
1983 };
1984 let invoke = handler.invoke_command(command, args, request_id, sink);
1985 let (result, report) = tokio::join!(invoke, collector.collect());
1986 (result, report)
1987 }
1988
1989 pub async fn settings_editor_open(
1990 &self,
1991 id: &str,
1992 category: &str,
1993 field: &str,
1994 ) -> Result<serde_json::Value, String> {
1995 let handler = self.user_action_handler(id)?;
1996 handler.settings_editor_open(category, field).await
1997 }
1998
1999 pub async fn settings_editor_key(
2000 &self,
2001 id: &str,
2002 category: &str,
2003 field: &str,
2004 key: &str,
2005 ) -> Result<serde_json::Value, String> {
2006 let handler = self.user_action_handler(id)?;
2007 handler.settings_editor_key(category, field, key).await
2008 }
2009
2010 pub async fn settings_editor_commit(
2011 &self,
2012 id: &str,
2013 category: &str,
2014 field: &str,
2015 value: serde_json::Value,
2016 ) -> Result<serde_json::Value, String> {
2017 let handler = self.user_action_handler(id)?;
2018 handler.settings_editor_commit(category, field, value).await
2019 }
2020
2021 pub fn plugin_infos(&self) -> Vec<(&str, &PluginInfo)> {
2023 let mut entries: Vec<_> = self
2024 .plugin_info
2025 .iter()
2026 .map(|(id, info)| (id.as_str(), info))
2027 .collect();
2028 entries.sort_by(|a, b| a.0.cmp(b.0));
2029 entries
2030 }
2031
2032 pub fn provider_summaries(&self) -> Vec<RegisteredProviderSummary> {
2034 self.providers.summaries()
2035 }
2036
2037 pub async fn capability_snapshots(&self) -> Vec<ExtensionCapabilitySnapshot> {
2043 let mut handlers: Vec<(String, Arc<dyn ExtensionHandler>)> = self
2044 .extensions
2045 .iter()
2046 .map(|(id, handler)| (id.clone(), handler.clone()))
2047 .collect();
2048 handlers.sort_by(|a, b| a.0.cmp(&b.0));
2049
2050 let provider_summaries = self.providers.summaries();
2051 let plugin_id_lookup: std::collections::HashMap<String, String> = self
2052 .providers
2053 .list()
2054 .into_iter()
2055 .map(|p| (p.runtime_id.clone(), p.plugin_id.clone()))
2056 .collect();
2057
2058 let mut out = Vec::with_capacity(handlers.len());
2059 for (id, handler) in handlers {
2060 let health = handler.health().await;
2061 let restart_count = handler.restart_count().await;
2062
2063 let hook_pairs = self.hook_bus.subscriptions_for(&id).await;
2064 let hooks: Vec<HookCapabilityEntry> = hook_pairs
2065 .into_iter()
2066 .map(|(kind, tool_filter)| HookCapabilityEntry {
2067 kind: kind.as_str().to_string(),
2068 tool_filter,
2069 })
2070 .collect();
2071
2072 let tools: Vec<ToolCapabilityEntry> = if let Some(tools) = &self.tools {
2073 let registry = tools.read().await;
2074 registry
2075 .tool_names_for_extension(&id)
2076 .into_iter()
2077 .map(|name| ToolCapabilityEntry { name })
2078 .collect()
2079 } else {
2080 Vec::new()
2081 };
2082
2083 let providers: Vec<RegisteredProviderSummary> = provider_summaries
2084 .iter()
2085 .filter(|summary| {
2086 plugin_id_lookup
2087 .get(&summary.runtime_id)
2088 .map(|p| p == &id)
2089 .unwrap_or(false)
2090 })
2091 .cloned()
2092 .collect();
2093
2094 let future: Vec<FutureCapabilityEntry> = self
2095 .capabilities
2096 .get(&id)
2097 .map(|decls| {
2098 decls
2099 .iter()
2100 .map(|d| FutureCapabilityEntry {
2101 kind: d.kind.clone(),
2102 name: d.name.clone(),
2103 })
2104 .collect()
2105 })
2106 .unwrap_or_default();
2107
2108 out.push(ExtensionCapabilitySnapshot {
2109 id,
2110 health,
2111 restart_count,
2112 hooks,
2113 tools,
2114 providers,
2115 future,
2116 });
2117 }
2118 out
2119 }
2120
2121 pub fn provider_tool_use_runtime_ids(&self) -> Vec<String> {
2124 let mut ids: Vec<String> = self
2125 .providers
2126 .list()
2127 .into_iter()
2128 .filter(|p| {
2129 p.spec.models.iter().any(|m| {
2130 m.capabilities
2131 .get("tool_use")
2132 .and_then(|v| v.as_bool())
2133 .unwrap_or(false)
2134 })
2135 })
2136 .map(|p| p.runtime_id.clone())
2137 .collect();
2138 ids.sort();
2139 ids
2140 }
2141
2142 pub fn provider_trust_view(&self) -> std::collections::BTreeMap<String, bool> {
2148 let trust = match crate::extensions::trust::load_trust_state() {
2149 Ok(t) => t,
2150 Err(e) => {
2151 tracing::warn!("trust.json corrupt or unreadable, failing closed (all providers disabled): {e}");
2152 return self
2154 .providers
2155 .list()
2156 .into_iter()
2157 .map(|p| (p.runtime_id.clone(), false))
2158 .collect();
2159 }
2160 };
2161 self.providers
2162 .list()
2163 .into_iter()
2164 .map(|p| {
2165 let enabled = crate::extensions::trust::is_provider_enabled(&trust, &p.runtime_id);
2166 (p.runtime_id.clone(), enabled)
2167 })
2168 .collect()
2169 }
2170
2171 pub fn config_diagnostics(&self, id: &str) -> Option<ExtensionConfigDiagnostics> {
2174 let manifest_config = self.manifest_configs.get(id)?;
2175
2176 let mut provider_required: Vec<(String, Vec<String>)> = Vec::new();
2178 for provider in self.providers.list() {
2179 if provider.plugin_id != id {
2180 continue;
2181 }
2182 let required: Vec<String> = provider
2183 .spec
2184 .config_schema
2185 .as_ref()
2186 .and_then(|schema| schema.get("required"))
2187 .and_then(Value::as_array)
2188 .map(|arr| {
2189 arr.iter()
2190 .filter_map(|v| v.as_str().map(|s| s.to_string()))
2191 .collect()
2192 })
2193 .unwrap_or_default();
2194 provider_required.push((provider.provider_id.clone(), required));
2195 }
2196 provider_required.sort_by(|a, b| a.0.cmp(&b.0));
2197
2198 let env_lookup = |name: &str| std::env::var(name).ok();
2199 let plugin_config_lookup =
2200 |key: &str| crate::extensions::config_store::read_plugin_config(id, key);
2201 let legacy_config_lookup = |key: &str| crate::config::read_config_value(key);
2202
2203 Some(diagnose_extension_config(
2204 id,
2205 manifest_config,
2206 &provider_required,
2207 &env_lookup,
2208 &plugin_config_lookup,
2209 &legacy_config_lookup,
2210 ))
2211 }
2212
2213 pub fn all_config_diagnostics(&self) -> Vec<ExtensionConfigDiagnostics> {
2215 let mut ids: Vec<&String> = self.manifest_configs.keys().collect();
2216 ids.sort();
2217 ids.into_iter()
2218 .filter_map(|id| self.config_diagnostics(id))
2219 .collect()
2220 }
2221
2222 pub fn hook_bus(&self) -> &Arc<HookBus> {
2224 &self.hook_bus
2225 }
2226
2227 pub fn tools_shared(&self) -> Option<Arc<tokio::sync::RwLock<crate::ToolRegistry>>> {
2229 self.tools.clone()
2230 }
2231
2232 #[cfg(test)]
2235 pub(crate) fn register_provider_handler_for_tests(
2236 &mut self,
2237 plugin_id: &str,
2238 spec: crate::extensions::runtime::process::RegisteredProviderSpec,
2239 handler: Arc<dyn ExtensionHandler>,
2240 ) -> Result<String, String> {
2241 self.providers
2242 .register_with_handler(plugin_id, spec, Some(handler))
2243 }
2244
2245 pub async fn discover_and_load(&mut self) -> (Vec<String>, Vec<ExtensionLoadFailure>) {
2252 self.discover_and_load_with_progress(|_| {}).await
2253 }
2254
2255 pub async fn discover_and_load_with_progress<F>(
2259 &mut self,
2260 mut progress: F,
2261 ) -> (Vec<String>, Vec<ExtensionLoadFailure>)
2262 where
2263 F: FnMut(crate::extensions::loader::ExtensionLoaderEvent),
2264 {
2265 if let Some(done) = self.discovery_done() {
2268 tracing::debug!(
2269 loaded = done.0.len(),
2270 failed = done.1.len(),
2271 "Extension discovery already done; replaying recorded result"
2272 );
2273 return done;
2274 }
2275 self.discovery = DiscoveryState::Running;
2276 let (loaded, failed) = self.discover_and_load_uncached(&mut progress).await;
2277 self.discovery = DiscoveryState::Done {
2278 loaded: loaded.clone(),
2279 failed: failed.clone(),
2280 };
2281 (loaded, failed)
2282 }
2283
2284 async fn discover_and_load_uncached<F>(
2285 &mut self,
2286 progress: &mut F,
2287 ) -> (Vec<String>, Vec<ExtensionLoadFailure>)
2288 where
2289 F: FnMut(crate::extensions::loader::ExtensionLoaderEvent),
2290 {
2291 let mut plugin_dirs: HashMap<String, PathBuf> = HashMap::new();
2292 let mut failed: Vec<ExtensionLoadFailure> = Vec::new();
2293
2294 for plugins_dir in plugin_roots() {
2295 if !plugins_dir.exists() {
2296 continue;
2297 }
2298
2299 let entries = match std::fs::read_dir(&plugins_dir) {
2300 Ok(e) => e,
2301 Err(e) => {
2302 tracing::warn!(path = %plugins_dir.display(), error = %e, "Failed to read plugins directory");
2303 failed.push(ExtensionLoadFailure::new(
2304 "plugins",
2305 Some(plugins_dir.clone()),
2306 format!("Failed to read plugins directory: {e}"),
2307 "Check directory permissions and retry",
2308 ));
2309 continue;
2310 }
2311 };
2312
2313 for entry in entries.flatten() {
2314 let plugin_name = entry.file_name().to_string_lossy().to_string();
2315 if is_inert_plugin_dir_name(&plugin_name) {
2316 tracing::debug!(
2317 plugin = %plugin_name,
2318 path = %entry.path().display(),
2319 "Skipping backup/disabled/temp plugin directory"
2320 );
2321 continue;
2322 }
2323 plugin_dirs.insert(plugin_name, entry.path());
2324 }
2325 }
2326
2327 let mut plugin_dirs: Vec<(String, PathBuf)> = plugin_dirs.into_iter().collect();
2328 plugin_dirs.sort_by(|a, b| a.0.cmp(&b.0));
2329
2330 let mut loaded = Vec::new();
2331 let disabled_plugins = crate::config::load_config().disabled_plugins;
2332 let plugins_state = {
2335 let state_path = crate::skills::state::PluginsState::default_path();
2336 crate::skills::state::PluginsState::load_from(&state_path).unwrap_or_default()
2337 };
2338 for (plugin_name, plugin_dir) in plugin_dirs {
2339 if disabled_plugins.iter().any(|d| d == &plugin_name) {
2340 tracing::debug!(plugin = %plugin_name, "Extension disabled via disabled_plugins config");
2341 continue;
2342 }
2343 if let Some(message) = installed_plugin_setup_failure_in(&plugins_state, &plugin_name) {
2344 tracing::warn!(plugin = %plugin_name, error = %message, "Skipping extension with failed post-install setup");
2345 failed.push(ExtensionLoadFailure::new(
2346 plugin_name,
2347 None,
2348 format!("Post-install setup failed: {message}"),
2349 "Open /plugins, reinstall or update the plugin after fixing setup; extension load is disabled until setup succeeds",
2350 ));
2351 continue;
2352 }
2353 let (resolved, manifest_path, json) =
2354 match read_extension_manifest(&plugin_name, &plugin_dir) {
2355 Ok(Some(read)) => read,
2356 Ok(None) => continue,
2357 Err(failure) => {
2358 failed.push(failure);
2359 continue;
2360 }
2361 };
2362
2363 match self
2364 .load_with_cwd(&plugin_name, &resolved, Some(plugin_dir.clone()))
2365 .await
2366 {
2367 Ok(()) => {
2368 tracing::info!(plugin = %plugin_name, path = %plugin_dir.display(), "Extension loaded from plugins/");
2369 loaded.push(plugin_name.clone());
2370 progress(crate::extensions::loader::ExtensionLoaderEvent::Loaded {
2371 plugin: plugin_name,
2372 loaded: loaded.len(),
2373 failed: failed.len(),
2374 });
2375 }
2376 Err(e) => {
2377 tracing::warn!(plugin = %plugin_name, manifest = %manifest_path.display(), error = %e, "Failed to load extension");
2378 let setup_script = json
2379 .pointer("/extension/setup")
2380 .and_then(|v| v.as_str())
2381 .or_else(|| {
2382 json.pointer("/provides/sidecar/setup")
2383 .and_then(|v| v.as_str())
2384 });
2385 let hint = compute_extension_load_hint(&e, &plugin_dir, setup_script);
2386 let failure =
2387 ExtensionLoadFailure::new(plugin_name, Some(manifest_path), e, hint);
2388 failed.push(failure.clone());
2389 progress(crate::extensions::loader::ExtensionLoaderEvent::Failed {
2390 failure,
2391 loaded: loaded.len(),
2392 failed: failed.len(),
2393 });
2394 }
2395 }
2396 }
2397
2398 (loaded, failed)
2399 }
2400}
2401
2402#[cfg(test)]
2403mod invoke_command_collected_tests {
2404 use super::*;
2410 use crate::extensions::commands::CommandOutputEvent;
2411 use crate::extensions::invoke_output::INVOKE_RETAINED_BYTE_BUDGET;
2412 use crate::extensions::runtime::InvokeCommandEvent;
2413
2414 struct FloodingHandler;
2415
2416 #[async_trait::async_trait]
2417 impl super::ExtensionHandler for FloodingHandler {
2418 fn id(&self) -> &str {
2419 "flooding-fake"
2420 }
2421 async fn handle(
2422 &self,
2423 _event: &crate::extensions::hooks::events::HookEvent,
2424 ) -> crate::extensions::hooks::events::HookResult {
2425 Default::default()
2426 }
2427 async fn shutdown(&self) {}
2428
2429 async fn invoke_command(
2430 &self,
2431 _command: &str,
2432 _args: Vec<String>,
2433 _request_id: &str,
2434 sink: crate::extensions::invoke_output::InvokeEventSink,
2435 ) -> Result<Value, String> {
2436 for _ in 0..2048 {
2440 let event = InvokeCommandEvent::Output(CommandOutputEvent::Text {
2441 content: "f".repeat(4096),
2442 });
2443 if sink.send(event).await.is_err() {
2444 return Err("collector vanished".to_string());
2445 }
2446 }
2447 let _ = sink
2448 .send(InvokeCommandEvent::Output(CommandOutputEvent::Done))
2449 .await;
2450 Ok(serde_json::json!({"status": "flooded"}))
2451 }
2452 }
2453
2454 #[tokio::test]
2455 async fn collected_invoke_bounds_in_process_flood_without_deadlock() {
2456 let bus = Arc::new(HookBus::new());
2457 let mut mgr = ExtensionManager::new(bus);
2458 mgr.extensions
2459 .insert("flooding-fake".to_string(), Arc::new(FloodingHandler));
2460
2461 let (result, report) = tokio::time::timeout(
2462 std::time::Duration::from_secs(30),
2463 mgr.invoke_command_collected("flooding-fake", "any", vec![], "req-1"),
2464 )
2465 .await
2466 .expect("bounded sink + concurrent collector must not deadlock");
2467
2468 assert_eq!(result.expect("invoke ok")["status"], "flooded");
2469 let c = &report.counters;
2470 assert_eq!(c.produced_events, 2049);
2471 assert_eq!(c.produced_payload_bytes, 2048 * 4096);
2472 assert_eq!(c.consumed_events, c.produced_events);
2473 assert_eq!(c.consumed_payload_bytes, c.produced_payload_bytes);
2474 assert_eq!(c.retained_payload_bytes, INVOKE_RETAINED_BYTE_BUDGET as u64);
2475 assert!(c.saw_done);
2476 assert_eq!(
2477 c.consumed_payload_bytes,
2478 c.retained_payload_bytes + c.truncated_payload_bytes + c.dropped_payload_bytes,
2479 );
2480 assert_eq!(c.consumed_events, c.retained_events + c.dropped_events);
2481 }
2482
2483 #[tokio::test]
2484 async fn collected_invoke_reports_unknown_extension_with_empty_report() {
2485 let bus = Arc::new(HookBus::new());
2486 let mgr = ExtensionManager::new(bus);
2487 let (result, report) = mgr
2488 .invoke_command_collected("nope", "any", vec![], "req-1")
2489 .await;
2490 assert!(result
2491 .expect_err("unknown id")
2492 .contains("unknown extension"));
2493 assert!(report.events.is_empty());
2494 assert_eq!(report.counters.produced_events, 0);
2495 }
2496}
2497
2498#[cfg(test)]
2499mod tests {
2500 use super::*;
2501
2502 fn context_provider_manifest(provider_id: &str) -> ExtensionManifest {
2505 serde_json::from_value(serde_json::json!({
2506 "runtime": "process",
2507 "command": "/bin/false",
2508 "permissions": ["context_providers.register"],
2509 "deferred": {
2510 "context_providers": [{
2511 "id": provider_id,
2512 "capability": "project-memory",
2513 "description": "test context provider",
2514 "schema_version": 1
2515 }]
2516 }
2517 }))
2518 .unwrap()
2519 }
2520
2521 #[tokio::test]
2526 async fn context_provider_catalog_lists_resolves_and_unloads_exactly() {
2527 let mut mgr = ExtensionManager::new(Arc::new(HookBus::new()));
2528 mgr.set_progressive_deferral(true);
2529 mgr.load("mem-a", &context_provider_manifest("alpha"))
2530 .await
2531 .unwrap();
2532 mgr.load("mem-b", &context_provider_manifest("beta"))
2533 .await
2534 .unwrap();
2535
2536 let listed: Vec<String> = mgr
2538 .context_providers()
2539 .iter()
2540 .map(|d| d.runtime_address())
2541 .collect();
2542 assert_eq!(
2543 listed,
2544 vec![
2545 "extension:mem-a:alpha".to_string(),
2546 "extension:mem-b:beta".to_string(),
2547 ]
2548 );
2549 let alpha = super::super::context_provider::ContextProviderId::parse("alpha").unwrap();
2550 assert_eq!(
2551 mgr.context_provider(&alpha).map(|d| d.extension_id()),
2552 Some("mem-a")
2553 );
2554 let unknown = super::super::context_provider::ContextProviderId::parse("nope").unwrap();
2555 assert!(mgr.context_provider(&unknown).is_none(), "fail closed");
2556
2557 let runtime = mgr.extension_runtime();
2559 assert_eq!(
2560 runtime
2561 .resolve_context_provider(Some(&alpha))
2562 .map(|d| d.runtime_address()),
2563 Ok("extension:mem-a:alpha".to_string())
2564 );
2565
2566 mgr.load("mem-c", &context_provider_manifest("alpha"))
2569 .await
2570 .unwrap();
2571 assert!(mgr.context_provider(&alpha).is_none(), "ambiguous → None");
2572 assert_eq!(
2573 runtime.resolve_context_provider(Some(&alpha)),
2574 Err(super::super::context_provider::ContextProviderLookupError::Ambiguous)
2575 );
2576
2577 mgr.unload("mem-c").await.unwrap();
2579 assert_eq!(
2580 mgr.context_provider(&alpha).map(|d| d.extension_id()),
2581 Some("mem-a")
2582 );
2583 mgr.unload("mem-a").await.unwrap();
2584 assert_eq!(
2585 runtime.resolve_context_provider(Some(&alpha)),
2586 Err(super::super::context_provider::ContextProviderLookupError::NotRegistered)
2587 );
2588 assert_eq!(mgr.context_providers().len(), 1, "mem-b remains");
2589
2590 assert_eq!(mgr.count(), 0, "no live extension handlers");
2592 assert_eq!(runtime.lease_count(), 0, "no runtime leases");
2593 }
2594
2595 #[tokio::test]
2596 async fn capability_snapshots_empty_when_no_extensions() {
2597 let bus = Arc::new(HookBus::new());
2598 let mgr = ExtensionManager::new(bus);
2599 assert!(mgr.capability_snapshots().await.is_empty());
2600 }
2601
2602 #[tokio::test]
2609 async fn deferred_launch_record_never_leaks_secret_config_through_diagnostics() {
2610 const SECRET: &str = "task20-A3-sentinel-5f2c9d";
2611 const VAR: &str = "T20_A3_THIRD_PARTY_SECRET";
2615 std::env::set_var(VAR, SECRET);
2616
2617 let manifest: ExtensionManifest = serde_json::from_value(serde_json::json!({
2618 "runtime": "process",
2619 "command": "/bin/false",
2620 "permissions": ["tools.register"],
2621 "config": [{
2622 "key": "api_key",
2623 "type": "string",
2624 "required": true,
2625 "secret_env": VAR
2626 }],
2627 "deferred": {
2628 "tools": [{
2629 "name": "search",
2630 "description": "deferred search",
2631 "input_schema": {"type": "object"}
2632 }]
2633 }
2634 }))
2635 .unwrap();
2636
2637 let registry = Arc::new(tokio::sync::RwLock::new(crate::ToolRegistry::new()));
2638 let mut mgr = ExtensionManager::new_with_tools(Arc::new(HookBus::new()), registry);
2639 mgr.set_progressive_deferral(true);
2640 mgr.load("secretive", &manifest).await.unwrap();
2641
2642 assert!(mgr.is_deferred_tool_only("secretive"));
2644 assert_eq!(mgr.deferred_tool_only_count(), 1);
2645 let record = mgr.deferred_launch_record("secretive").expect("retained");
2647 assert_eq!(record.config["api_key"], serde_json::json!(SECRET));
2648
2649 let mut rendered = String::new();
2651 rendered.push_str(&format!("{:?}", mgr.statuses().await));
2652 rendered.push_str(&format!("{:?}", mgr.config_diagnostics("secretive")));
2653 rendered.push_str(&format!("{:?}", mgr.all_config_diagnostics()));
2654 rendered.push_str(&format!("{:?}", mgr.capability_snapshots().await));
2655 rendered.push_str(&format!("{:?}", mgr.plugin_infos()));
2656 rendered.push_str(&format!("{:?}", mgr.list()));
2657 assert!(
2658 !rendered.contains(SECRET),
2659 "resolved secret config leaked through a public diagnostic surface"
2660 );
2661
2662 std::env::remove_var(VAR);
2663 }
2664
2665 #[tokio::test]
2666 async fn capability_snapshot_lists_hooks_for_loaded_extension() {
2667 let bus = Arc::new(HookBus::new());
2668 let mut mgr = ExtensionManager::new(bus.clone());
2669 let manifest = ExtensionManifest {
2670 theme_tokens: Default::default(),
2671 deferred: None,
2672 protocol_version: 1,
2673 runtime: crate::extensions::manifest::ExtensionRuntime::Process,
2674 command: "python3".to_string(),
2675 setup: None,
2676 prebuilt: ::std::collections::HashMap::new(),
2677 args: vec![
2678 "tests/fixtures/process_extension.py".to_string(),
2679 "normal".to_string(),
2680 "/tmp/synaps-capability-test.log".to_string(),
2681 ],
2682 permissions: vec!["tools.intercept".to_string()],
2683 hooks: vec![crate::extensions::manifest::HookSubscription {
2684 hook: "before_tool_call".to_string(),
2685 tool: Some("bash".to_string()),
2686 matcher: None,
2687 }],
2688 config: vec![],
2689 };
2690
2691 mgr.load("cap-snap", &manifest).await.unwrap();
2692
2693 let snaps = mgr.capability_snapshots().await;
2694 assert_eq!(snaps.len(), 1);
2695 let snap = &snaps[0];
2696 assert_eq!(snap.id, "cap-snap");
2697 assert_eq!(snap.hooks.len(), 1);
2698 assert_eq!(snap.hooks[0].kind, "before_tool_call");
2699 assert_eq!(snap.hooks[0].tool_filter.as_deref(), Some("bash"));
2700 assert!(snap.tools.is_empty());
2701 assert!(snap.providers.is_empty());
2702 assert!(snap.future.is_empty());
2703
2704 mgr.shutdown_all().await;
2705 }
2706
2707 #[tokio::test]
2708 async fn capability_snapshot_surfaces_seeded_capabilities() {
2709 let bus = Arc::new(HookBus::new());
2710 let mut mgr = ExtensionManager::new(bus.clone());
2711 let manifest = ExtensionManifest {
2712 theme_tokens: Default::default(),
2713 deferred: None,
2714 protocol_version: 1,
2715 runtime: crate::extensions::manifest::ExtensionRuntime::Process,
2716 command: "python3".to_string(),
2717 setup: None,
2718 prebuilt: ::std::collections::HashMap::new(),
2719 args: vec![
2720 "tests/fixtures/process_extension.py".to_string(),
2721 "normal".to_string(),
2722 "/tmp/synaps-capability-snapshot-test.log".to_string(),
2723 ],
2724 permissions: vec!["tools.intercept".to_string()],
2725 hooks: vec![crate::extensions::manifest::HookSubscription {
2726 hook: "before_tool_call".to_string(),
2727 tool: Some("bash".to_string()),
2728 matcher: None,
2729 }],
2730 config: vec![],
2731 };
2732
2733 mgr.load("multi-cap", &manifest).await.unwrap();
2734
2735 mgr.test_seed_capabilities(
2739 "multi-cap",
2740 vec![
2741 crate::extensions::runtime::process::CapabilityDeclaration {
2742 kind: "capture".to_string(),
2743 name: "Local Sample STT".to_string(),
2744 permissions: vec!["audio.input".to_string()],
2745 params: serde_json::Value::Null,
2746 },
2747 crate::extensions::runtime::process::CapabilityDeclaration {
2748 kind: "ocr".to_string(),
2749 name: "Tesseract".to_string(),
2750 permissions: vec![],
2751 params: serde_json::Value::Null,
2752 },
2753 ],
2754 );
2755
2756 let snaps = mgr.capability_snapshots().await;
2757 let snap = snaps
2758 .iter()
2759 .find(|s| s.id == "multi-cap")
2760 .expect("multi-cap snapshot");
2761 assert_eq!(snap.future.len(), 2);
2762 let kinds: Vec<&str> = snap.future.iter().map(|e| e.kind.as_str()).collect();
2763 assert!(kinds.contains(&"capture"), "got kinds {:?}", kinds);
2764 assert!(kinds.contains(&"ocr"), "got kinds {:?}", kinds);
2765 let names: Vec<&str> = snap.future.iter().map(|e| e.name.as_str()).collect();
2766 assert!(names.contains(&"Local Sample STT"), "got {:?}", names);
2767 assert!(names.contains(&"Tesseract"), "got {:?}", names);
2768
2769 mgr.unload("multi-cap").await.unwrap();
2770 let snaps = mgr.capability_snapshots().await;
2771 assert!(snaps.iter().all(|s| s.id != "multi-cap"));
2772
2773 mgr.shutdown_all().await;
2774 }
2775
2776 #[tokio::test]
2777 async fn new_manager_has_no_extensions() {
2778 let bus = Arc::new(HookBus::new());
2779 let mgr = ExtensionManager::new(bus);
2780 assert_eq!(mgr.count(), 0);
2781 assert!(mgr.list().is_empty());
2782 }
2783
2784 #[tokio::test]
2785 async fn unload_nonexistent_returns_error() {
2786 let bus = Arc::new(HookBus::new());
2787 let mut mgr = ExtensionManager::new(bus);
2788 let result = mgr.unload("nope").await;
2789 assert!(result.is_err());
2790 }
2791
2792 #[tokio::test]
2793 async fn reload_unsubscribes_old_handler_before_loading_new_one() {
2794 let bus = Arc::new(HookBus::new());
2795 let mut mgr = ExtensionManager::new(bus.clone());
2796 let manifest = ExtensionManifest {
2797 theme_tokens: Default::default(),
2798 deferred: None,
2799 protocol_version: 1,
2800 runtime: crate::extensions::manifest::ExtensionRuntime::Process,
2801 command: "python3".to_string(),
2802 setup: None,
2803 prebuilt: ::std::collections::HashMap::new(),
2804 args: vec![
2805 "tests/fixtures/process_extension.py".to_string(),
2806 "normal".to_string(),
2807 "/tmp/synaps-reload-test.log".to_string(),
2808 ],
2809 permissions: vec!["tools.intercept".to_string()],
2810 hooks: vec![crate::extensions::manifest::HookSubscription {
2811 hook: "before_tool_call".to_string(),
2812 tool: Some("bash".to_string()),
2813 matcher: None,
2814 }],
2815 config: vec![],
2816 };
2817
2818 mgr.load("reload-test", &manifest).await.unwrap();
2819 assert_eq!(bus.handler_count().await, 1);
2820
2821 mgr.reload("reload-test", &manifest, None).await.unwrap();
2822
2823 assert_eq!(mgr.count(), 1);
2824 assert_eq!(bus.handler_count().await, 1);
2825 mgr.shutdown_all().await;
2826 }
2827
2828 #[tokio::test]
2829 async fn reload_failure_leaves_previous_instance_unloaded() {
2830 let bus = Arc::new(HookBus::new());
2831 let mut mgr = ExtensionManager::new(bus.clone());
2832 let good = ExtensionManifest {
2833 theme_tokens: Default::default(),
2834 deferred: None,
2835 protocol_version: 1,
2836 runtime: crate::extensions::manifest::ExtensionRuntime::Process,
2837 command: "python3".to_string(),
2838 setup: None,
2839 prebuilt: ::std::collections::HashMap::new(),
2840 args: vec![
2841 "tests/fixtures/process_extension.py".to_string(),
2842 "normal".to_string(),
2843 "/tmp/synaps-reload-failure-test.log".to_string(),
2844 ],
2845 permissions: vec!["tools.intercept".to_string()],
2846 hooks: vec![crate::extensions::manifest::HookSubscription {
2847 hook: "before_tool_call".to_string(),
2848 tool: Some("bash".to_string()),
2849 matcher: None,
2850 }],
2851 config: vec![],
2852 };
2853 let bad = ExtensionManifest {
2854 theme_tokens: Default::default(),
2855 deferred: None,
2856 command: "/definitely/not/a/real/extension-binary".to_string(),
2857 setup: None,
2858 prebuilt: ::std::collections::HashMap::new(),
2859 ..good.clone()
2860 };
2861
2862 mgr.load("reload-failure-test", &good).await.unwrap();
2863 let err = mgr
2864 .reload("reload-failure-test", &bad, None)
2865 .await
2866 .unwrap_err();
2867
2868 assert!(err.contains("Failed to spawn extension"), "{err}");
2869 assert_eq!(mgr.count(), 0);
2870 assert_eq!(bus.handler_count().await, 0);
2871 }
2872
2873 #[test]
2874 fn project_plugins_disable_env_parser_accepts_truthy_values() {
2875 for value in ["1", "true", "TRUE", "yes", "on"] {
2876 std::env::set_var("SYNAPS_DISABLE_PROJECT_PLUGINS", value);
2877 assert!(project_plugins_disabled());
2878 }
2879 for value in ["", "0", "false", "off", "no"] {
2880 std::env::set_var("SYNAPS_DISABLE_PROJECT_PLUGINS", value);
2881 assert!(!project_plugins_disabled());
2882 }
2883 std::env::remove_var("SYNAPS_DISABLE_PROJECT_PLUGINS");
2884 }
2885
2886 fn with_temp_base_dir<T>(path: &std::path::Path, f: impl FnOnce() -> T) -> T {
2887 let old_base_dir = std::env::var("SYNAPS_BASE_DIR").ok();
2888 crate::config::set_base_dir_for_tests(path.to_path_buf());
2889 let out = f();
2890 match old_base_dir {
2891 Some(old) => std::env::set_var("SYNAPS_BASE_DIR", old),
2892 None => std::env::remove_var("SYNAPS_BASE_DIR"),
2893 }
2894 out
2895 }
2896
2897 #[test]
2898 #[serial_test::serial(synaps_base_dir)]
2899 fn resolve_config_prefers_plugin_namespaced_config_before_legacy_global_key() {
2900 let dir = tempfile::tempdir().unwrap();
2901 with_temp_base_dir(dir.path(), || {
2902 crate::extensions::config_store::write_plugin_config(
2903 "sample-sidecar",
2904 "backend",
2905 "cpu",
2906 )
2907 .unwrap();
2908 crate::config::write_config_value("extension.sample-sidecar.backend", "auto").unwrap();
2909
2910 let resolved = ExtensionManager::resolve_config(
2911 "sample-sidecar",
2912 &[ExtensionConfigEntry {
2913 key: "backend".to_string(),
2914 value_type: None,
2915 description: None,
2916 required: true,
2917 default: None,
2918 secret_env: None,
2919 host_context: None,
2920 }],
2921 None,
2922 )
2923 .unwrap();
2924
2925 assert_eq!(
2926 resolved["backend"],
2927 serde_json::Value::String("cpu".to_string())
2928 );
2929 });
2930 }
2931
2932 #[test]
2933 #[serial_test::serial(synaps_base_dir)]
2934 fn resolve_config_keeps_legacy_global_extension_key_as_fallback() {
2935 let dir = tempfile::tempdir().unwrap();
2936 with_temp_base_dir(dir.path(), || {
2937 crate::config::write_config_value("extension.sample-sidecar.backend", "auto").unwrap();
2938
2939 let resolved = ExtensionManager::resolve_config(
2940 "sample-sidecar",
2941 &[ExtensionConfigEntry {
2942 key: "backend".to_string(),
2943 value_type: None,
2944 description: None,
2945 required: true,
2946 default: None,
2947 secret_env: None,
2948 host_context: None,
2949 }],
2950 None,
2951 )
2952 .unwrap();
2953
2954 assert_eq!(
2955 resolved["backend"],
2956 serde_json::Value::String("auto".to_string())
2957 );
2958 });
2959 }
2960
2961 #[tokio::test]
2962 async fn config_diagnostics_returns_none_for_unknown_extension() {
2963 let bus = Arc::new(HookBus::new());
2964 let mgr = ExtensionManager::new(bus);
2965 assert!(mgr.config_diagnostics("nope").is_none());
2966 assert!(mgr.all_config_diagnostics().is_empty());
2967 }
2968
2969 #[tokio::test]
2970 async fn config_diagnostics_reports_loaded_manifest_entries() {
2971 let bus = Arc::new(HookBus::new());
2972 let mut mgr = ExtensionManager::new(bus);
2973 let manifest = ExtensionManifest {
2974 theme_tokens: Default::default(),
2975 deferred: None,
2976 protocol_version: 1,
2977 runtime: crate::extensions::manifest::ExtensionRuntime::Process,
2978 command: "python3".to_string(),
2979 setup: None,
2980 prebuilt: ::std::collections::HashMap::new(),
2981 args: vec![
2982 "tests/fixtures/process_extension.py".to_string(),
2983 "normal".to_string(),
2984 "/tmp/synaps-config-diag-test.log".to_string(),
2985 ],
2986 permissions: vec!["tools.intercept".to_string()],
2987 hooks: vec![crate::extensions::manifest::HookSubscription {
2988 hook: "before_tool_call".to_string(),
2989 tool: Some("bash".to_string()),
2990 matcher: None,
2991 }],
2992 config: vec![crate::extensions::manifest::ExtensionConfigEntry {
2993 key: "region".to_string(),
2994 value_type: None,
2995 description: Some("AWS region".to_string()),
2996 required: false,
2997 default: Some(serde_json::Value::String("us-east-1".to_string())),
2998 secret_env: None,
2999 host_context: None,
3000 }],
3001 };
3002
3003 mgr.load("config-diag-test", &manifest).await.unwrap();
3004
3005 let diag = mgr
3006 .config_diagnostics("config-diag-test")
3007 .expect("diagnostics should be available for loaded extension");
3008 assert_eq!(diag.extension_id, "config-diag-test");
3009 assert_eq!(diag.entries.len(), 1);
3010 assert_eq!(diag.entries[0].key, "region");
3011 assert!(diag.entries[0].has_value);
3012 assert!(diag.provider_missing.is_empty());
3013
3014 let all = mgr.all_config_diagnostics();
3015 assert_eq!(all.len(), 1);
3016 assert_eq!(all[0].extension_id, "config-diag-test");
3017
3018 mgr.shutdown_all().await;
3019 assert!(mgr.config_diagnostics("config-diag-test").is_none());
3021 }
3022
3023 #[tokio::test]
3024 async fn provider_trust_view_is_empty_for_no_providers() {
3025 let bus = Arc::new(HookBus::new());
3026 let mgr = ExtensionManager::new(bus);
3027 let view = mgr.provider_trust_view();
3028 assert!(view.is_empty());
3029 }
3030
3031 #[tokio::test]
3032 async fn provider_tool_use_runtime_ids_lists_only_tool_use_capable() {
3033 use crate::extensions::runtime::process::{
3034 RegisteredProviderModelSpec, RegisteredProviderSpec,
3035 };
3036 let bus = Arc::new(HookBus::new());
3037 let mut mgr = ExtensionManager::new(bus);
3038 let tool_spec = RegisteredProviderSpec {
3040 id: "alpha".into(),
3041 display_name: "Alpha".into(),
3042 description: "tool-use".into(),
3043 models: vec![RegisteredProviderModelSpec {
3044 id: "m1".into(),
3045 display_name: None,
3046 capabilities: serde_json::json!({"tool_use": true}),
3047 context_window: None,
3048 }],
3049 config_schema: None,
3050 };
3051 let plain_spec = RegisteredProviderSpec {
3053 id: "beta".into(),
3054 display_name: "Beta".into(),
3055 description: "plain".into(),
3056 models: vec![RegisteredProviderModelSpec {
3057 id: "m1".into(),
3058 display_name: None,
3059 capabilities: serde_json::json!({"streaming": true}),
3060 context_window: None,
3061 }],
3062 config_schema: None,
3063 };
3064 mgr.providers.register("plug", tool_spec).unwrap();
3065 mgr.providers.register("plug", plain_spec).unwrap();
3066 let ids = mgr.provider_tool_use_runtime_ids();
3067 assert_eq!(ids, vec!["plug:alpha".to_string()]);
3068 }
3069
3070 #[test]
3073 fn hint_missing_binary_with_declared_setup_points_at_script() {
3074 let hint = compute_extension_load_hint(
3075 "Failed to spawn extension 'sample-sidecar': No such file or directory (os error 2)",
3076 std::path::Path::new("/home/u/.synaps-cli/plugins/sample-sidecar"),
3077 Some("scripts/setup.sh"),
3078 );
3079 assert!(
3080 hint.contains("Extension binary missing"),
3081 "missing-binary case should be flagged: {hint}"
3082 );
3083 assert!(
3084 hint.contains("/home/u/.synaps-cli/plugins/sample-sidecar"),
3085 "hint should include the plugin dir: {hint}"
3086 );
3087 assert!(
3088 hint.contains("setup=scripts/setup.sh"),
3089 "hint should show sanitized setup path without copy-paste shell command: {hint}"
3090 );
3091 }
3092
3093 #[test]
3094 fn hint_missing_binary_without_declared_setup_falls_back_to_generic() {
3095 let hint = compute_extension_load_hint(
3096 "Failed to spawn extension 'foo': No such file or directory (os error 2)",
3097 std::path::Path::new("/x/y"),
3098 None,
3099 );
3100 assert!(
3101 hint.contains("contract.json"),
3102 "no setup declared → generic hint: {hint}"
3103 );
3104 assert!(
3105 !hint.contains("Extension binary missing"),
3106 "should not falsely promise a setup script: {hint}"
3107 );
3108 }
3109
3110 #[test]
3111 fn hint_other_error_with_declared_setup_falls_back_to_generic() {
3112 let hint = compute_extension_load_hint(
3113 "Extension 'foo' must subscribe to at least one hook or request a registration permission",
3114 std::path::Path::new("/x/y"),
3115 Some("scripts/setup.sh"),
3116 );
3117 assert!(hint.contains("contract.json"), "got {hint}");
3121 assert!(!hint.contains("Extension binary missing"), "got {hint}");
3122 }
3123
3124 #[test]
3125 fn hint_recognises_os_error_2_format() {
3126 let hint = compute_extension_load_hint(
3129 "spawn failed (os error 2)",
3130 std::path::Path::new("/p"),
3131 Some("setup.sh"),
3132 );
3133 assert!(hint.contains("Extension binary missing"), "got {hint}");
3134 }
3135
3136 #[test]
3137 fn hint_exclusive_axel_is_expected_not_missing_binary() {
3138 let hint = compute_extension_load_hint(
3139 "independent Axel plugin is disabled by the host-selected memory backend",
3140 std::path::Path::new("/synthetic/plugin"),
3141 None,
3142 );
3143 assert!(hint.contains("Expected: integrated Axel"));
3144 assert!(!hint.contains("contract.json"));
3145 }
3146
3147 fn write_installed_plugin(plugins_root: &std::path::Path, dir_name: &str) {
3152 let plugin_dir = plugins_root.join(dir_name).join(".synaps-plugin");
3153 std::fs::create_dir_all(&plugin_dir).unwrap();
3154 let manifest = serde_json::json!({
3155 "extension": {
3156 "runtime": "process",
3157 "command": "/bin/false",
3158 "permissions": ["tools.register"],
3159 "deferred": {
3160 "tools": [{
3161 "name": "memory_fetch",
3162 "description": "Fetch one memory by exact ID.",
3163 "input_schema": {
3164 "type": "object",
3165 "properties": {"id": {"type": "string"}},
3166 "required": ["id"]
3167 }
3168 }]
3169 }
3170 }
3171 });
3172 std::fs::write(
3173 plugin_dir.join("plugin.json"),
3174 serde_json::to_string_pretty(&manifest).unwrap(),
3175 )
3176 .unwrap();
3177 }
3178
3179 #[tokio::test]
3189 #[serial_test::serial(synaps_base_dir)]
3190 async fn discover_and_load_skips_backup_and_disabled_plugin_directories() {
3191 let base = crate::test_env::BaseDirGuard::new();
3192 let _no_project_plugins =
3193 crate::test_env::EnvVarGuard::set("SYNAPS_DISABLE_PROJECT_PLUGINS", "1");
3194 let plugins_root = base.path().join("plugins");
3195
3196 write_installed_plugin(&plugins_root, "axel-memory-manager");
3197 for inert in [
3200 "axel-memory-manager.backup.20260719210053",
3201 "axel-memory-manager.backup.20260720101416",
3202 "axel-memory-manager.update-backup",
3203 "axel-memory-manager.backup",
3204 "axel-memory-manager.bak",
3205 "axel-memory-manager.disabled",
3206 "axel-memory-manager.old",
3207 "axel-memory-manager.orig",
3208 "axel-memory-manager.tmp",
3209 ] {
3210 write_installed_plugin(&plugins_root, inert);
3211 }
3212 write_installed_plugin(&plugins_root, "com.example.notes");
3214
3215 let registry = Arc::new(tokio::sync::RwLock::new(
3216 crate::ToolRegistry::without_subagent(),
3217 ));
3218 let mut mgr = ExtensionManager::new_with_tools(Arc::new(HookBus::new()), registry.clone());
3219 mgr.set_progressive_deferral(true);
3220
3221 let (mut loaded, failed) = mgr.discover_and_load().await;
3222 loaded.sort();
3223
3224 assert!(failed.is_empty(), "no failures expected: {failed:?}");
3225 assert_eq!(
3226 loaded,
3227 vec![
3228 "axel-memory-manager".to_string(),
3229 "com.example.notes".to_string()
3230 ],
3231 "backup/disabled/temp plugin directories must not be discovered"
3232 );
3233
3234 let reg = registry.read().await;
3235 assert!(reg.get("axel-memory-manager:memory_fetch").is_some());
3236 assert!(reg.get("com.example.notes:memory_fetch").is_some());
3237 assert!(
3238 reg.get("axel-memory-manager.backup.20260719210053:memory_fetch")
3239 .is_none(),
3240 "backup plugin tools must never be cataloged"
3241 );
3242 assert!(reg
3243 .get("axel-memory-manager.backup.20260720101416:memory_fetch")
3244 .is_none());
3245 drop(reg);
3246
3247 assert_eq!(mgr.count(), 0, "discovery must spawn nothing");
3250 }
3251
3252 #[test]
3256 fn inert_plugin_dir_name_filter_is_exact() {
3257 for inert in [
3258 "axel-memory-manager.backup.20260720101416",
3259 "axel-memory-manager.BACKUP.1",
3260 "axel-memory-manager.backup",
3261 "axel-memory-manager.update-backup",
3262 "axel-memory-manager.bak",
3263 "axel-memory-manager.disabled",
3264 "axel-memory-manager.old",
3265 "axel-memory-manager.orig",
3266 "axel-memory-manager.tmp",
3267 "axel-memory-manager.temp",
3268 ] {
3269 assert!(
3270 is_inert_plugin_dir_name(inert),
3271 "{inert} should be excluded from discovery"
3272 );
3273 }
3274 for live in [
3275 "axel-memory-manager",
3276 "com.example.notes",
3277 "backup-manager",
3278 "old-school-tools",
3279 "tmpfs-inspector",
3280 "my.backups.viewer",
3281 ] {
3282 assert!(
3283 !is_inert_plugin_dir_name(live),
3284 "{live} is a legitimate plugin name and must stay discoverable"
3285 );
3286 }
3287 }
3288}
3289
3290#[cfg(test)]
3291mod secret_env_allowlist_tests {
3292 use super::*;
3293
3294 fn entry(key: &str, secret_env: &str) -> ExtensionConfigEntry {
3295 serde_json::from_value(serde_json::json!({
3296 "key": key,
3297 "type": "string",
3298 "secret_env": secret_env,
3299 }))
3300 .unwrap()
3301 }
3302
3303 #[test]
3306 fn host_provider_credentials_are_rejected() {
3307 for var in [
3308 "ANTHROPIC_API_KEY",
3309 "OPENAI_API_KEY",
3310 "AWS_SECRET_ACCESS_KEY",
3311 ] {
3312 let err = ExtensionManager::resolve_config("evil", &[entry("k", var)], None)
3313 .expect_err(&format!("{var} must be rejected"));
3314 assert!(err.contains("host credential"), "unexpected error: {err}");
3315 }
3316 let entries = [entry("k", " ANTHROPIC_API_KEY ")];
3318 let err = ExtensionManager::resolve_config("evil", &entries, None)
3319 .expect_err("padded host credential must be rejected");
3320 assert!(err.contains("host credential"), "unexpected error: {err}");
3321 let entries = [entry("k", "anthropic_api_key")];
3323 let out = ExtensionManager::resolve_config("evil", &entries, None)
3324 .expect("malformed name is dropped, not fatal");
3325 assert!(out.get("k").is_none(), "{out}");
3326 }
3327
3328 #[test]
3331 fn host_namespace_is_rejected_but_own_scope_is_allowed() {
3332 let err =
3333 ExtensionManager::resolve_config("evil", &[entry("k", "SYNAPS_BROKER_TOKEN")], None)
3334 .expect_err("host namespace must be rejected");
3335 assert!(err.contains("host namespace"), "unexpected error: {err}");
3336
3337 assert!(ExtensionManager::denied_secret_env(
3339 "SYNAPS_EXTENSION_MY_PLUGIN_TOKEN",
3340 "my-plugin",
3341 &["token"]
3342 )
3343 .is_none());
3344 }
3345
3346 #[test]
3348 fn third_party_secrets_are_still_allowed() {
3349 for var in ["GITHUB_TOKEN", "STRIPE_KEY", "MY_SERVICE_TOKEN"] {
3350 assert!(
3351 ExtensionManager::denied_secret_env(var, "some-ext", &[]).is_none(),
3352 "{var} should be allowed"
3353 );
3354 }
3355 }
3356
3357 #[test]
3360 fn every_registered_provider_env_var_is_denied() {
3361 let mut vars: Vec<&str> = Vec::new();
3362 for spec in agent_core::auth::static_providers::STATIC_PROVIDERS {
3363 vars.extend(spec.env_vars.iter().copied());
3364 }
3365 for spec in crate::runtime::openai::registry::providers() {
3366 vars.extend(spec.env_vars.iter().copied());
3367 }
3368 vars.extend([
3369 agent_core::auth::muse::API_KEY_ENV,
3370 "META_API_KEY",
3371 "KIMI_API_KEY",
3372 "MOONSHOT_API_KEY",
3373 "OPENAI_CODEX_ACCESS_TOKEN",
3374 "LOCAL_API_KEY",
3375 "TOGETHER_API_KEY",
3376 "CEREBRAS_API_KEY",
3377 "AWS_ACCESS_KEY_ID",
3378 ]);
3379 assert!(vars.len() > 20, "registry walk found too few vars");
3380 for var in vars {
3381 let reason = ExtensionManager::denied_secret_env(var, "some-ext", &[])
3382 .unwrap_or_else(|| panic!("{var} must be denied as secret_env"));
3383 assert!(reason.contains("host credential"), "{var}: {reason}");
3384 }
3385 }
3386
3387 fn literal_args(src: &str, pat: &str) -> Vec<String> {
3390 let mut out = Vec::new();
3391 let mut rest = src;
3392 while let Some(i) = rest.find(pat) {
3393 rest = &rest[i + pat.len()..];
3394 let arg = rest.trim_start();
3395 if let Some(lit) = arg.strip_prefix('"') {
3396 if let Some(end) = lit.find('"') {
3397 out.push(lit[..end].to_string());
3398 }
3399 }
3400 }
3401 out
3402 }
3403
3404 #[test]
3411 fn credential_env_reads_in_workspace_sources_are_denied() {
3412 fn walk(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
3413 for e in std::fs::read_dir(dir).unwrap() {
3414 let p = e.unwrap().path();
3415 if p.is_dir() {
3416 walk(&p, out);
3417 } else if p.extension().is_some_and(|x| x == "rs") {
3418 out.push(p);
3419 }
3420 }
3421 }
3422 let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
3423 let mut files = Vec::new();
3424 walk(&root.join("src"), &mut files);
3425 for e in std::fs::read_dir(root.join("crates")).unwrap() {
3426 let src = e.unwrap().path().join("src");
3427 if src.is_dir() {
3428 walk(&src, &mut files);
3429 }
3430 }
3431 for must in [
3433 "crates/agent-core/src/core/auth/muse.rs",
3434 "crates/agent-core/src/core/config.rs",
3435 "crates/agent-engine/src/runtime/google_gemini/runtime.rs",
3436 "crates/agent-engine/src/session/types.rs",
3437 "crates/agent-engine/src/daemon/mod.rs",
3438 "crates/agent-tui/src/lib.rs",
3439 "src/cmd/auth_broker.rs",
3440 "src/cmd/login.rs",
3441 ] {
3442 assert!(
3443 files.iter().any(|f| f.ends_with(must)),
3444 "scan does not cover {must}"
3445 );
3446 }
3447
3448 let mut seen = 0;
3449 for f in &files {
3450 let src = std::fs::read_to_string(f).unwrap();
3451 let mut names = literal_args(&src, "env::var(");
3452 names.extend(literal_args(&src, "env::var_os("));
3453 names.extend(literal_args(&src, "_ENV: &str ="));
3454 for name in names {
3455 let up = name.to_ascii_uppercase();
3456 let looks_secret = ["KEY", "TOKEN", "SECRET", "PASSWORD"]
3457 .iter()
3458 .any(|w| up.contains(w));
3459 if !looks_secret {
3460 continue;
3461 }
3462 seen += 1;
3463 assert!(
3464 ExtensionManager::secret_env_name(&name).is_none()
3465 || ExtensionManager::denied_secret_env(&name, "some-ext", &[]).is_some(),
3466 "{} reads credential env var {name}, which extensions may still request via secret_env",
3467 f.display()
3468 );
3469 }
3470 }
3471 assert!(
3472 seen > 3,
3473 "source scan matched almost nothing; the patterns rotted"
3474 );
3475 }
3476
3477 #[test]
3478 fn literal_scan_handles_rustfmt_split_calls() {
3479 let src = "let a = std::env::var(\n \"SPLIT_TOKEN\",\n );\nenv::var(\"ONE\")";
3482 assert_eq!(literal_args(src, "env::var("), ["SPLIT_TOKEN", "ONE"]);
3483 }
3484
3485 #[test]
3487 fn proxy_variables_are_denied() {
3488 for var in ["HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY", "NO_PROXY"] {
3489 assert!(
3490 ExtensionManager::denied_secret_env(var, "some-ext", &[]).is_some(),
3491 "{var} must be denied"
3492 );
3493 let lower = var.to_ascii_lowercase();
3497 assert!(
3498 ExtensionManager::secret_env_name(&lower).is_none(),
3499 "{lower} must never be read"
3500 );
3501 }
3502 }
3503
3504 #[test]
3507 fn own_scope_is_limited_to_declared_keys() {
3508 assert!(ExtensionManager::denied_secret_env(
3509 "SYNAPS_EXTENSION_FOO_BAR_TOKEN",
3510 "foo",
3511 &["api_key"]
3512 )
3513 .is_some());
3514 assert!(ExtensionManager::denied_secret_env(
3515 "SYNAPS_EXTENSION_FOO_API_KEY",
3516 "foo",
3517 &["api_key"]
3518 )
3519 .is_none());
3520 assert!(ExtensionManager::denied_secret_env(
3521 "SYNAPS_EXTENSION_FOO_BAR_API_KEY",
3522 "foo-bar",
3523 &["api-key"]
3524 )
3525 .is_none());
3526 let err = ExtensionManager::resolve_config(
3528 "foo",
3529 &[entry("k", "SYNAPS_EXTENSION_FOO_BAR_TOKEN")],
3530 None,
3531 )
3532 .expect_err("cross-extension override must be rejected");
3533 assert!(err.contains("host namespace"), "{err}");
3534 }
3535
3536 #[test]
3537 fn ids_differing_only_in_separators_collide() {
3538 assert_eq!(
3539 ExtensionManager::folded_id_collision("foo_bar", ["foo-bar", "x"].into_iter()),
3540 Some("foo-bar")
3541 );
3542 assert_eq!(
3543 ExtensionManager::folded_id_collision("Foo-Bar", ["foo-bar"].into_iter()),
3544 Some("foo-bar")
3545 );
3546 assert_eq!(
3548 ExtensionManager::folded_id_collision("foo-bar", ["foo-bar", "foo"].into_iter()),
3549 None
3550 );
3551 }
3552
3553 #[test]
3554 fn secret_env_name_format() {
3555 for bad in [
3556 "",
3557 " ",
3558 "lower_case",
3559 "HAS SPACE",
3560 "A=B",
3561 "A-B",
3562 "1ABC",
3563 "A\0B",
3564 "ÄPI_KEY",
3565 ] {
3566 assert_eq!(ExtensionManager::secret_env_name(bad), None, "{bad:?}");
3567 }
3568 for good in ["GITHUB_TOKEN", "_X", "A1"] {
3569 assert_eq!(ExtensionManager::secret_env_name(good), Some(good));
3570 assert!(ExtensionManager::denied_secret_env(good, "some-ext", &[]).is_none());
3571 }
3572 assert_eq!(
3574 ExtensionManager::secret_env_name(" PADDED "),
3575 Some("PADDED")
3576 );
3577 }
3578
3579 #[test]
3583 fn malformed_secret_env_is_never_read_even_when_set() {
3584 let entry: ExtensionConfigEntry = serde_json::from_value(serde_json::json!({
3585 "key": "k",
3586 "secret_env": "lower_token",
3587 "default": "fallback",
3588 }))
3589 .unwrap();
3590 let asked = std::sync::Mutex::new(Vec::<String>::new());
3591 let env = |name: &str| {
3592 asked.lock().unwrap().push(name.to_string());
3593 (name == "lower_token").then(|| "LEAK".to_string())
3594 };
3595 let out = ExtensionManager::resolve_config_with_env("ext", &[entry], None, &env)
3596 .expect("malformed secret_env must not fail the load");
3597 assert_eq!(out["k"], "fallback", "{out}");
3598 assert!(
3599 !asked.lock().unwrap().iter().any(|n| n == "lower_token"),
3600 "malformed name was looked up: {:?}",
3601 asked.lock().unwrap()
3602 );
3603
3604 let good: ExtensionConfigEntry = serde_json::from_value(serde_json::json!({
3606 "key": "k",
3607 "secret_env": " GOOD_TOKEN ",
3608 "default": "fallback",
3609 }))
3610 .unwrap();
3611 let env = |name: &str| (name == "GOOD_TOKEN").then(|| "value".to_string());
3612 let out = ExtensionManager::resolve_config_with_env("ext", &[good], None, &env).unwrap();
3613 assert_eq!(out["k"], "value");
3614 }
3615
3616 #[test]
3620 fn malformed_secret_env_is_dropped_not_fatal() {
3621 let with_default: ExtensionConfigEntry = serde_json::from_value(serde_json::json!({
3622 "key": "k",
3623 "secret_env": "my-token",
3624 "default": "fallback",
3625 }))
3626 .unwrap();
3627 let out = ExtensionManager::resolve_config("ext", &[with_default], None)
3628 .expect("malformed secret_env must not fail the load");
3629 assert_eq!(out["k"], "fallback");
3630
3631 let required: ExtensionConfigEntry = serde_json::from_value(serde_json::json!({
3632 "key": "k",
3633 "secret_env": "lower_case",
3634 "required": true,
3635 }))
3636 .unwrap();
3637 let err = ExtensionManager::resolve_config("ext", &[required], None)
3638 .expect_err("required entry with no value still fails");
3639 assert!(err.contains("missing required config"), "{err}");
3640 assert!(
3641 !err.contains("lower_case"),
3642 "dropped name is not suggested: {err}"
3643 );
3644 }
3645}