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::hooks::HookBus;
12use super::info::PluginInfo;
13use super::manifest::{ExtensionConfigEntry, ExtensionManifest};
14use super::providers::{ProviderRegistry, RegisteredProvider, RegisteredProviderSummary};
15use super::permissions::{Permission, PermissionSet};
16use super::runtime::process::ProcessExtension;
17use super::runtime::{ExtensionHandler, ExtensionHealth};
18use serde_json::{Map, Value};
19
20fn project_plugins_disabled() -> bool {
21 std::env::var("SYNAPS_DISABLE_PROJECT_PLUGINS")
22 .map(|value| {
23 let normalized = value.trim().to_ascii_lowercase();
24 matches!(normalized.as_str(), "1" | "true" | "yes" | "on")
25 })
26 .unwrap_or(false)
27}
28
29fn is_inert_plugin_dir_name(name: &str) -> bool {
41 let lower = name.to_ascii_lowercase();
42 if lower.contains(".backup.") {
44 return true;
45 }
46 const INERT_SUFFIXES: &[&str] = &[
47 ".backup",
48 ".update-backup", ".bak",
50 ".disabled",
51 ".old",
52 ".orig",
53 ".tmp",
54 ".temp",
55 ];
56 INERT_SUFFIXES.iter().any(|s| lower.ends_with(s))
57}
58
59fn installed_plugin_setup_failure_in(
60 state: &crate::skills::state::PluginsState,
61 plugin_name: &str,
62) -> Option<String> {
63 let plugin = state.installed.iter().find(|p| p.name == plugin_name)?;
64 match &plugin.setup_status {
65 crate::skills::state::SetupStatus::Failed { message, .. } => Some(message.clone()),
66 _ => None,
67 }
68}
69
70fn plugin_roots() -> Vec<PathBuf> {
74 let mut roots = vec![crate::config::base_dir().join("plugins")];
75 if !project_plugins_disabled() {
76 if let Ok(cwd) = std::env::current_dir() {
77 let project_plugins = cwd.join(".synaps").join("plugins");
78 if project_plugins != roots[0] {
79 roots.push(project_plugins);
80 }
81 }
82 }
83 roots
84}
85
86fn kept_entries(registry: &crate::ToolRegistry, names: &[String]) -> Vec<(String, Arc<dyn crate::Tool>)> {
88 names
89 .iter()
90 .filter_map(|n| registry.get(n).map(|t| (n.clone(), Arc::clone(t))))
91 .collect()
92}
93
94fn same_tool(a: &Arc<dyn crate::Tool>, b: &Arc<dyn crate::Tool>) -> bool {
96 std::ptr::eq(Arc::as_ptr(a) as *const (), Arc::as_ptr(b) as *const ())
97}
98
99fn locate_plugin_dir(name: &str) -> Option<PathBuf> {
102 plugin_roots()
103 .into_iter()
104 .rev()
105 .map(|root| root.join(name))
106 .find(|dir| dir.is_dir())
107}
108
109fn read_extension_manifest(
115 plugin_name: &str,
116 plugin_dir: &std::path::Path,
117) -> Result<Option<(ExtensionManifest, PathBuf, Value)>, ExtensionLoadFailure> {
118 let manifest_path = plugin_dir.join(".synaps-plugin").join("plugin.json");
119 if !manifest_path.exists() {
120 return Ok(None);
121 }
122
123 let content = match std::fs::read_to_string(&manifest_path) {
124 Ok(c) => c,
125 Err(e) => {
126 let reason = format!("Failed to read plugin manifest: {e}");
127 tracing::warn!(plugin = %plugin_name, manifest = %manifest_path.display(), error = %e, "Failed to read plugin manifest");
128 return Err(ExtensionLoadFailure::new(
129 plugin_name,
130 Some(manifest_path),
131 reason,
132 "Check manifest file permissions",
133 ));
134 }
135 };
136
137 let json: serde_json::Value = match serde_json::from_str(&content) {
138 Ok(v) => v,
139 Err(e) => {
140 let reason = format!("Invalid plugin manifest JSON: {e}");
141 tracing::warn!(plugin = %plugin_name, manifest = %manifest_path.display(), error = %e, "Invalid plugin manifest JSON");
142 return Err(ExtensionLoadFailure::new(
143 plugin_name,
144 Some(manifest_path),
145 reason,
146 "Fix the JSON syntax in .synaps-plugin/plugin.json",
147 ));
148 }
149 };
150
151 let ext_value = match json.get("extension") {
152 Some(v) => v.clone(),
153 None => return Ok(None),
154 };
155
156 let ext_manifest: ExtensionManifest = match serde_json::from_value(ext_value) {
157 Ok(m) => m,
158 Err(e) => {
159 let reason = format!("Failed to parse extension manifest: {e}");
160 tracing::warn!(plugin = %plugin_name, manifest = %manifest_path.display(), error = %e, "Failed to parse extension manifest");
161 return Err(ExtensionLoadFailure::new(
162 plugin_name,
163 Some(manifest_path),
164 reason,
165 "Check the `extension` block shape against docs/extensions/contract.json",
166 ));
167 }
168 };
169
170 #[allow(clippy::if_same_then_else)]
171 let command = if std::path::Path::new(&ext_manifest.command).is_absolute() {
172 ext_manifest.command.clone()
173 } else if !ext_manifest.command.contains(std::path::MAIN_SEPARATOR)
174 && !ext_manifest.command.contains('/')
175 {
176 ext_manifest.command.clone()
177 } else {
178 plugin_dir
179 .join(&ext_manifest.command)
180 .to_string_lossy()
181 .to_string()
182 };
183
184 let args: Vec<String> = ext_manifest
185 .args
186 .iter()
187 .map(|arg| {
188 let arg_path = plugin_dir.join(arg);
189 if arg_path.exists() {
190 if let (Ok(canonical), Ok(plugin_canonical)) =
191 (arg_path.canonicalize(), plugin_dir.canonicalize())
192 {
193 if canonical.starts_with(&plugin_canonical) {
194 return canonical.to_string_lossy().to_string();
195 }
196 }
197 }
198 arg.clone()
199 })
200 .collect();
201
202 let resolved = ExtensionManifest {
203 theme_tokens: Default::default(),
204 command,
210 args,
211 ..ext_manifest
212 };
213 Ok(Some((resolved, manifest_path, json)))
214}
215
216#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
219pub struct ExtensionReloadReport {
220 pub extension: String,
222 pub was_loaded: bool,
225 pub deferred: bool,
227 pub tools: Vec<String>,
229 #[serde(default)]
231 pub removed_tools: Vec<String>,
232 pub plugin_dir: PathBuf,
234}
235
236fn sanitize_hint_fragment(input: &str) -> String {
237 input
238 .chars()
239 .map(|ch| if ch.is_control() { '?' } else { ch })
240 .collect::<String>()
241}
242
243#[derive(Debug, Clone, PartialEq, Eq)]
245pub struct ExtensionLoadFailure {
246 pub plugin: String,
247 pub manifest_path: Option<PathBuf>,
248 pub reason: String,
249 pub hint: String,
250}
251
252impl ExtensionLoadFailure {
253 fn new(
254 plugin: impl Into<String>,
255 manifest_path: Option<PathBuf>,
256 reason: impl Into<String>,
257 hint: impl Into<String>,
258 ) -> Self {
259 Self {
260 plugin: plugin.into(),
261 manifest_path,
262 reason: reason.into(),
263 hint: hint.into(),
264 }
265 }
266
267 pub fn concise_message(&self) -> String {
268 match &self.manifest_path {
269 Some(path) => format!(
270 "{} (manifest: {}; hint: {})",
271 self.reason,
272 path.display(),
273 self.hint
274 ),
275 None => format!("{} (hint: {})", self.reason, self.hint),
276 }
277 }
278}
279
280#[derive(Debug, Clone, PartialEq, Eq)]
282pub struct ExtensionStatus {
283 pub id: String,
284 pub health: ExtensionHealth,
285 pub restart_count: usize,
286}
287
288pub fn compute_extension_load_hint(
303 error: &str,
304 plugin_dir: &std::path::Path,
305 declared_setup: Option<&str>,
306) -> String {
307 let missing_binary =
308 error.contains("No such file or directory") || error.contains("os error 2");
309 match (missing_binary, declared_setup) {
310 (true, Some(setup)) => format!(
311 "Extension binary missing — this plugin ships source only. Run the setup script from the plugin directory, then reload. plugin_dir={}, setup={}",
312 sanitize_hint_fragment(&plugin_dir.display().to_string()),
313 sanitize_hint_fragment(setup),
314 ),
315 _ => "Check the `extension` block in .synaps-plugin/plugin.json against docs/extensions/contract.json and confirm the extension command is installed"
316 .to_string(),
317 }
318}
319
320#[derive(Debug, Clone, PartialEq, Eq, Default)]
326pub enum DiscoveryState {
327 #[default]
328 NotStarted,
329 Running,
330 Done {
331 loaded: Vec<String>,
332 failed: Vec<ExtensionLoadFailure>,
333 },
334}
335
336pub struct ExtensionManager {
338 discovery: DiscoveryState,
340 hook_bus: Arc<HookBus>,
342 tools: Option<Arc<tokio::sync::RwLock<crate::ToolRegistry>>>,
344 providers: ProviderRegistry,
346 extensions: HashMap<String, Arc<dyn ExtensionHandler>>,
348 eager_permissions: HashMap<String, PermissionSet>,
351 manifest_configs: HashMap<String, Vec<ExtensionConfigEntry>>,
354 capabilities: HashMap<String, Vec<crate::extensions::runtime::process::CapabilityDeclaration>>,
358 plugin_info: HashMap<String, PluginInfo>,
360 manifest_theme_tokens: HashMap<String, std::collections::BTreeMap<String, String>>,
365 progressive_deferral: bool,
370 deferred_tool_only: SharedDeferredRecords,
376 reload_replaces: Vec<String>,
381 extension_runtime: std::sync::OnceLock<Arc<crate::extensions::lease::ExtensionRuntimeManager>>,
385 host_project_root: std::sync::OnceLock<Option<String>>,
392}
393
394pub(crate) type SharedDeferredRecords =
397 Arc<std::sync::Mutex<HashMap<String, DeferredExtensionRecord>>>;
398
399#[derive(Clone)]
408pub(crate) struct DeferredExtensionRecord {
409 pub(crate) manifest: ExtensionManifest,
410 pub(crate) cwd: Option<std::path::PathBuf>,
411 pub(crate) config: Value,
412}
413
414impl ExtensionManager {
415 pub fn new(hook_bus: Arc<HookBus>) -> Self {
417 Self {
418 discovery: DiscoveryState::NotStarted,
419 hook_bus,
420 tools: None,
421 providers: ProviderRegistry::new(),
422 extensions: HashMap::new(),
423 eager_permissions: HashMap::new(),
424 manifest_configs: HashMap::new(),
425 capabilities: HashMap::new(),
426 plugin_info: HashMap::new(),
427 manifest_theme_tokens: HashMap::new(),
428 progressive_deferral: false,
429 deferred_tool_only: Arc::new(std::sync::Mutex::new(HashMap::new())),
430 reload_replaces: Vec::new(),
431 extension_runtime: std::sync::OnceLock::new(),
432 host_project_root: std::sync::OnceLock::new(),
433 }
434 }
435
436 pub fn new_with_tools(
438 hook_bus: Arc<HookBus>,
439 tools: Arc<tokio::sync::RwLock<crate::ToolRegistry>>,
440 ) -> Self {
441 Self {
442 discovery: DiscoveryState::NotStarted,
443 hook_bus,
444 tools: Some(tools),
445 providers: ProviderRegistry::new(),
446 extensions: HashMap::new(),
447 eager_permissions: HashMap::new(),
448 manifest_configs: HashMap::new(),
449 capabilities: HashMap::new(),
450 plugin_info: HashMap::new(),
451 manifest_theme_tokens: HashMap::new(),
452 progressive_deferral: false,
453 deferred_tool_only: Arc::new(std::sync::Mutex::new(HashMap::new())),
454 reload_replaces: Vec::new(),
455 extension_runtime: std::sync::OnceLock::new(),
456 host_project_root: std::sync::OnceLock::new(),
457 }
458 }
459
460 pub async fn load(&mut self, id: &str, manifest: &ExtensionManifest) -> Result<(), String> {
462 self.load_with_cwd(id, manifest, None).await
463 }
464
465 pub async fn load_with_cwd(
467 &mut self,
468 id: &str,
469 manifest: &ExtensionManifest,
470 cwd: Option<std::path::PathBuf>,
471 ) -> Result<(), String> {
472 let deferred: Vec<String> = self
473 .deferred_records()
474 .lock()
475 .unwrap_or_else(std::sync::PoisonError::into_inner)
476 .keys()
477 .cloned()
478 .collect();
479 let loaded = self
480 .extensions
481 .keys()
482 .map(String::as_str)
483 .chain(deferred.iter().map(String::as_str));
484 if let Some(other) = Self::folded_id_collision(id, loaded) {
485 return Err(format!(
486 "Extension '{id}' collides with loaded extension '{other}': ids that differ only in '-'/'_' or case share SYNAPS_EXTENSION_* override variables"
487 ));
488 }
489 let config = Self::resolve_config(id, &manifest.config, self.host_project_root())?;
490 self.load_with_cwd_and_config(id, manifest, cwd, config)
491 .await
492 }
493
494 pub fn set_progressive_deferral(&mut self, enabled: bool) {
498 self.progressive_deferral = enabled;
499 }
500
501 pub fn host_project_root(&self) -> Option<&str> {
508 self.host_project_root
509 .get_or_init(|| {
510 let cwd = std::env::current_dir().ok()?;
511 agent_core::memory::store::ProjectScope::discover(&cwd)
512 .ok()
513 .map(|scope| scope.root().display().to_string())
514 })
515 .as_deref()
516 }
517
518 pub fn is_deferred(&self, id: &str) -> bool {
524 self.deferred_records()
525 .lock()
526 .unwrap_or_else(std::sync::PoisonError::into_inner)
527 .contains_key(id)
528 }
529
530 pub fn is_deferred_tool_only(&self, id: &str) -> bool {
532 self.is_deferred(id)
533 }
534
535 pub fn deferred_tool_only_count(&self) -> usize {
538 self.deferred_records()
539 .lock()
540 .unwrap_or_else(std::sync::PoisonError::into_inner)
541 .len()
542 }
543
544 fn deferred_records(&self) -> &SharedDeferredRecords {
545 &self.deferred_tool_only
546 }
547
548 pub fn extension_runtime(&self) -> Arc<crate::extensions::lease::ExtensionRuntimeManager> {
556 self.extension_runtime_with_idle(crate::extensions::lease::DEFAULT_IDLE_MAX)
557 }
558
559 pub fn extension_runtime_with_idle(
562 &self,
563 idle_max: std::time::Duration,
564 ) -> Arc<crate::extensions::lease::ExtensionRuntimeManager> {
565 Arc::clone(self.extension_runtime.get_or_init(|| {
566 Arc::new(crate::extensions::lease::ExtensionRuntimeManager::new(
567 Arc::clone(&self.deferred_tool_only),
568 idle_max,
569 ))
570 }))
571 }
572
573 #[cfg(test)]
576 pub(crate) fn deferred_launch_record(&self, id: &str) -> Option<DeferredExtensionRecord> {
577 self.deferred_records()
578 .lock()
579 .unwrap_or_else(std::sync::PoisonError::into_inner)
580 .get(id)
581 .cloned()
582 }
583
584 #[doc(hidden)]
589 pub fn tamper_deferred_manifest_permissions_for_tests(
590 &self,
591 id: &str,
592 permissions: Vec<String>,
593 ) -> bool {
594 let mut map = self
595 .deferred_tool_only
596 .lock()
597 .unwrap_or_else(std::sync::PoisonError::into_inner);
598 match map.get_mut(id) {
599 Some(record) => {
600 record.manifest.permissions = permissions;
601 true
602 }
603 None => false,
604 }
605 }
606
607 #[doc(hidden)]
611 pub fn tamper_deferred_config_for_tests(&self, id: &str, config: Value) -> bool {
612 let mut map = self
613 .deferred_tool_only
614 .lock()
615 .unwrap_or_else(std::sync::PoisonError::into_inner);
616 match map.get_mut(id) {
617 Some(record) => {
618 record.config = config;
619 true
620 }
621 None => false,
622 }
623 }
624
625 async fn load_with_cwd_and_config(
626 &mut self,
627 id: &str,
628 manifest: &ExtensionManifest,
629 cwd: Option<std::path::PathBuf>,
630 config: Value,
631 ) -> Result<(), String> {
632 if self.extensions.contains_key(id) || self.is_deferred_tool_only(id) {
634 return Err(format!("Extension '{}' is already loaded", id));
635 }
636
637 let validated = manifest.validate(id)?;
641 if let Some(notice) =
642 crate::extensions::lifecycle::ignored_context_provider_notice(id, manifest)
643 {
644 tracing::warn!("{notice}");
645 }
646 let permissions = validated.permissions;
647 let subscriptions = validated.subscriptions;
648
649 let class = crate::extensions::lifecycle::classify(manifest);
665 if self.progressive_deferral
666 && class != crate::extensions::lifecycle::ExtensionClass::LegacyEager
667 {
668 let declared_tool_names: Vec<String> = manifest
669 .deferred
670 .as_ref()
671 .map(|d| d.tools.iter().map(|t| format!("{id}:{}", t.name)).collect())
672 .unwrap_or_default();
673 let dormant = crate::extensions::lifecycle::dormant_extension_tools(id, manifest);
674 let tool_count = dormant.len();
675 if !dormant.is_empty() {
676 let Some(tools) = &self.tools else {
677 return Err(format!(
678 "Extension '{}' declares deferred tools but no tool registry is available",
679 id
680 ));
681 };
682 let replacing = std::mem::take(&mut self.reload_replaces);
685 let mut registry = tools.write().await;
686 registry
687 .try_disable(&replacing)
688 .and_then(|()| registry.try_register_batch(dormant))
689 .map_err(|e| {
690 format!("Extension '{}' deferred tool catalog rejected: {}", id, e)
691 })?;
692 }
693 self.deferred_tool_only
696 .lock()
697 .unwrap_or_else(std::sync::PoisonError::into_inner)
698 .insert(
699 id.to_string(),
700 DeferredExtensionRecord {
701 manifest: manifest.clone(),
702 cwd,
703 config: config.clone(),
704 },
705 );
706 macro_rules! unwind_deferred {
709 () => {{
710 self.deferred_tool_only
711 .lock()
712 .unwrap_or_else(std::sync::PoisonError::into_inner)
713 .remove(id);
714 self.providers.unregister_plugin(id);
715 if let Some(tools) = &self.tools {
716 let _ = tools.write().await.try_disable(&declared_tool_names);
717 }
718 self.hook_bus.unsubscribe_all(id).await;
719 }};
720 }
721 let lazy: Option<Arc<dyn ExtensionHandler>> = {
722 let needs_lazy = manifest
726 .deferred
727 .as_ref()
728 .map(|d| !d.providers.is_empty())
729 .unwrap_or(false)
730 || !manifest.hooks.is_empty();
731 needs_lazy.then(|| {
732 Arc::new(crate::extensions::lease::LazyExtensionHandler::new(
733 id,
734 self.extension_runtime(),
735 )) as Arc<dyn ExtensionHandler>
736 })
737 };
738 let declared_providers: Vec<_> = manifest
742 .deferred
743 .as_ref()
744 .map(|d| d.providers.clone())
745 .unwrap_or_default();
746 let mut registered_provider_ids = Vec::new();
747 for declared in &declared_providers {
748 let spec = declared.to_registered_spec();
749 if let Err(error) = Self::validate_provider_config_requirements(id, &spec, &config)
750 {
751 unwind_deferred!();
752 return Err(error);
753 }
754 let handler = lazy.clone().expect("providers imply a lazy handler");
755 match self
756 .providers
757 .register_with_handler(id, spec, Some(handler))
758 {
759 Ok(runtime_id) => registered_provider_ids.push(runtime_id),
760 Err(error) => {
761 unwind_deferred!();
762 return Err(error);
763 }
764 }
765 }
766 for runtime_id in ®istered_provider_ids {
768 if let Some(provider) = self.providers.get(runtime_id) {
769 let tool_use = provider.spec.models.iter().any(|m| {
770 m.capabilities
771 .get("tool_use")
772 .and_then(|v| v.as_bool())
773 .unwrap_or(false)
774 });
775 if tool_use {
776 tracing::warn!(
777 "Provider '{}' is tool-use capable: it can request Synaps tools through provider mediation. Use `/extensions trust disable {}` to block routing.",
778 runtime_id,
779 runtime_id,
780 );
781 }
782 }
783 }
784 for (kind, tool_filter, matcher) in subscriptions {
787 let handler = lazy.clone().expect("subscriptions imply a lazy handler");
788 if let Err(error) = self
789 .hook_bus
790 .subscribe(kind, handler, tool_filter, matcher, permissions.clone())
791 .await
792 {
793 unwind_deferred!();
794 return Err(error);
795 }
796 }
797 self.manifest_configs
798 .insert(id.to_string(), manifest.config.clone());
799 if !manifest.theme_tokens.is_empty() {
800 self.manifest_theme_tokens
801 .insert(id.to_string(), manifest.theme_tokens.clone());
802 }
803 tracing::info!(extension = %id, class = ?class, tools = tool_count,
804 providers = registered_provider_ids.len(), hooks = manifest.hooks.len(),
805 "Extension deferred: passive surfaces registered, no process started");
806 return Ok(());
807 }
808
809 let process =
811 ProcessExtension::spawn_with_cwd(id, &manifest.command, &manifest.args, cwd.clone())
812 .await?;
813 process.set_permissions(permissions.clone()).await;
816 let capabilities = match process.initialize(cwd.clone(), config.clone()).await {
817 Ok(capabilities) => capabilities,
818 Err(error) => {
819 process.shutdown().await;
820 return Err(error);
821 }
822 };
823 let registered_tools = capabilities.tools;
824 let registered_providers = capabilities.providers;
825 let capability_declarations = capabilities.capabilities;
826 let should_probe_info = !registered_tools.is_empty()
827 || !registered_providers.is_empty()
828 || !capability_declarations.is_empty();
829 let handler: Arc<dyn ExtensionHandler> = Arc::new(process);
830 if !registered_tools.is_empty()
831 && !permissions.has(crate::extensions::permissions::Permission::ToolsRegister)
832 {
833 handler.shutdown().await;
834 return Err(format!(
835 "Extension '{}' registered tools but lacks permission 'tools.register'",
836 id
837 ));
838 }
839 if !registered_providers.is_empty()
840 && !permissions.has(crate::extensions::permissions::Permission::ProvidersRegister)
841 {
842 handler.shutdown().await;
843 return Err(format!(
844 "Extension '{}' registered providers but lacks permission 'providers.register'",
845 id
846 ));
847 }
848 for decl in &capability_declarations {
849 if let Err(err) =
850 crate::extensions::runtime::process::validate_capability(decl, &permissions)
851 {
852 handler.shutdown().await;
853 return Err(format!(
854 "Extension '{}' capability '{}' invalid: {}",
855 id, decl.kind, err
856 ));
857 }
858 }
859 if !registered_providers.is_empty() {
860 let mut registered_ids = Vec::new();
861 for provider in registered_providers {
862 if let Err(error) =
863 Self::validate_provider_config_requirements(id, &provider, &config)
864 {
865 self.providers.unregister_plugin(id);
866 handler.shutdown().await;
867 return Err(error);
868 }
869 match self
870 .providers
871 .register_with_handler(id, provider, Some(handler.clone()))
872 {
873 Ok(runtime_id) => registered_ids.push(runtime_id),
874 Err(error) => {
875 self.providers.unregister_plugin(id);
876 handler.shutdown().await;
877 return Err(error);
878 }
879 }
880 }
881 tracing::info!(extension = %id, providers = ?registered_ids, "Extension provider metadata registered");
882 for runtime_id in ®istered_ids {
884 if let Some(provider) = self.providers.get(runtime_id) {
885 let tool_use = provider.spec.models.iter().any(|m| {
886 m.capabilities
887 .get("tool_use")
888 .and_then(|v| v.as_bool())
889 .unwrap_or(false)
890 });
891 if tool_use {
892 tracing::warn!(
893 "Provider '{}' is tool-use capable: it can request Synaps tools through provider mediation. Use `/extensions trust disable {}` to block routing.",
894 runtime_id,
895 runtime_id,
896 );
897 }
898 }
899 }
900 }
901 if !registered_tools.is_empty() {
902 let Some(tools) = &self.tools else {
903 handler.shutdown().await;
904 return Err(format!(
905 "Extension '{}' registered tools but no tool registry is available",
906 id
907 ));
908 };
909 let mut registry = tools.write().await;
910 for spec in registered_tools {
911 let tool_name = spec.name.clone();
912 if let Err(e) = registry.try_register(Arc::new(crate::tools::ExtensionTool::new(
913 id,
914 spec,
915 handler.clone(),
916 ))) {
917 tracing::warn!(
918 extension = %id,
919 tool = %tool_name,
920 error = %e,
921 "Refusing to expose extension tool the capability catalog could not record"
922 );
923 }
924 }
925 }
926
927 let info = if should_probe_info {
932 match handler.get_info().await {
933 Ok(info) => Some(info),
934 Err(error) => {
935 if error.contains("method not found") || error.contains("unknown method") {
936 tracing::debug!(
937 extension = %id,
938 error = %error,
939 "Extension did not provide optional info.get metadata",
940 );
941 None
942 } else {
943 tracing::warn!(
944 extension = %id,
945 error = %error,
946 "Ignoring invalid optional info.get metadata",
947 );
948 None
949 }
950 }
951 }
952 } else {
953 None
954 };
955
956 for (kind, tool_filter, matcher) in subscriptions {
958 self.hook_bus
959 .subscribe(
960 kind,
961 handler.clone(),
962 tool_filter,
963 matcher,
964 permissions.clone(),
965 )
966 .await?;
967 }
968
969 self.extensions.insert(id.to_string(), handler);
970 self.eager_permissions.insert(id.to_string(), permissions);
971 self.manifest_configs
972 .insert(id.to_string(), manifest.config.clone());
973 if !manifest.theme_tokens.is_empty() {
974 self.manifest_theme_tokens
975 .insert(id.to_string(), manifest.theme_tokens.clone());
976 }
977 if !capability_declarations.is_empty() {
978 self.capabilities
979 .insert(id.to_string(), capability_declarations);
980 }
981 if let Some(info) = info {
982 self.plugin_info.insert(id.to_string(), info);
983 }
984 tracing::info!(extension = %id, hooks = manifest.hooks.len(), "Extension loaded");
985 Ok(())
986 }
987
988 fn validate_provider_config_requirements(
989 id: &str,
990 provider: &crate::extensions::runtime::process::RegisteredProviderSpec,
991 config: &Value,
992 ) -> Result<(), String> {
993 let Some(required) = provider
994 .config_schema
995 .as_ref()
996 .and_then(|schema| schema.get("required"))
997 .and_then(Value::as_array)
998 else {
999 return Ok(());
1000 };
1001 for key in required {
1002 let Some(key) = key.as_str() else {
1003 return Err(format!(
1004 "Extension '{}' provider '{}' config_schema.required must contain only strings",
1005 id, provider.id,
1006 ));
1007 };
1008 let present = config
1009 .as_object()
1010 .map(|map| map.contains_key(key))
1011 .unwrap_or(false);
1012 if !present {
1013 return Err(format!(
1014 "Extension '{}' provider '{}' missing required provider config '{}'",
1015 id, provider.id, key,
1016 ));
1017 }
1018 }
1019 Ok(())
1020 }
1021
1022 pub(crate) fn denied_secret_env(
1048 var: &str,
1049 id: &str,
1050 own_keys: &[&str],
1051 ) -> Option<&'static str> {
1052 if var.starts_with("SYNAPS_")
1053 && !own_keys
1054 .iter()
1055 .any(|k| Self::extension_override_var(id, k) == var)
1056 {
1057 return Some(
1058 "SYNAPS_* is the host namespace; only this extension's own SYNAPS_EXTENSION_<ID>_<KEY> for a declared key is allowed",
1059 );
1060 }
1061 if Self::is_host_credential_env(var) {
1062 return Some(
1063 "that variable holds a host credential; extensions are never handed the host's own provider keys",
1064 );
1065 }
1066 None
1067 }
1068
1069 pub(crate) fn secret_env_name(raw: &str) -> Option<&str> {
1073 let name = raw.trim();
1074 let mut chars = name.chars();
1075 let valid = chars
1076 .next()
1077 .is_some_and(|c| c.is_ascii_uppercase() || c == '_')
1078 && chars.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_');
1079 valid.then_some(name)
1080 }
1081
1082 fn extension_override_var(id: &str, key: &str) -> String {
1085 format!(
1086 "SYNAPS_EXTENSION_{}_{}",
1087 id.replace('-', "_").to_ascii_uppercase(),
1088 key.replace('-', "_").to_ascii_uppercase()
1089 )
1090 }
1091
1092 fn folded_id_collision<'a>(id: &str, loaded: impl Iterator<Item = &'a str>) -> Option<&'a str> {
1095 let fold = |s: &str| s.replace('-', "_").to_ascii_uppercase();
1096 let mine = fold(id);
1097 loaded
1098 .filter(|other| *other != id)
1099 .find(|other| fold(other) == mine)
1100 }
1101
1102 fn is_host_credential_env(up: &str) -> bool {
1104 const FLOOR: &[&str] = &[
1106 "ANTHROPIC_API_KEY",
1107 "OPENAI_API_KEY",
1108 "GEMINI_API_KEY",
1109 "GOOGLE_API_KEY",
1110 "XAI_API_KEY",
1111 "GROQ_API_KEY",
1112 "OPENROUTER_API_KEY",
1113 "DEEPSEEK_API_KEY",
1114 "MISTRAL_API_KEY",
1115 "AWS_SECRET_ACCESS_KEY",
1116 "AWS_SESSION_TOKEN",
1117 ];
1118 const OTHER_SOURCES: &[&str] = &[
1122 agent_core::auth::muse::API_KEY_ENV,
1123 "LOCAL_API_KEY",
1124 "OPENAI_CODEX_ACCESS_TOKEN",
1125 "AWS_ACCESS_KEY_ID",
1126 "SYNAPS_MACHINE_TOKEN",
1127 "SYNAPS_BROKER_TOKEN",
1128 "HTTPS_PROXY",
1131 "HTTP_PROXY",
1132 "ALL_PROXY",
1133 "NO_PROXY",
1134 ];
1135 FLOOR.contains(&up)
1136 || OTHER_SOURCES.contains(&up)
1137 || agent_core::auth::static_providers::STATIC_PROVIDERS
1138 .iter()
1139 .any(|s| s.env_vars.contains(&up))
1140 || crate::runtime::openai::registry::providers()
1141 .iter()
1142 .any(|s| s.env_vars.contains(&up))
1143 }
1144
1145 fn resolve_config(
1146 id: &str,
1147 entries: &[ExtensionConfigEntry],
1148 host_project_root: Option<&str>,
1149 ) -> Result<Value, String> {
1150 Self::resolve_config_with_env(id, entries, host_project_root, &|name| {
1151 std::env::var(name).ok()
1152 })
1153 }
1154
1155 fn resolve_config_with_env(
1158 id: &str,
1159 entries: &[ExtensionConfigEntry],
1160 host_project_root: Option<&str>,
1161 env: &dyn Fn(&str) -> Option<String>,
1162 ) -> Result<Value, String> {
1163 let mut out = Map::new();
1164 let own_keys: Vec<&str> = entries.iter().map(|e| e.key.trim()).collect();
1165 for entry in entries {
1166 let key = entry.key.trim();
1167 if key.is_empty() {
1168 return Err(format!("Extension '{}' declares config with empty key", id));
1169 }
1170 if key.contains('.') || key.contains('/') || key.contains(' ') {
1171 return Err(format!(
1172 "Extension '{}' config key '{}' must not contain dots, slashes, or spaces",
1173 id, key,
1174 ));
1175 }
1176 if let Some(host_key) = entry.host_context {
1182 if entry.secret_env.is_some() {
1183 return Err(format!(
1184 "Extension '{}' config key '{}' declares both host_context and secret_env; host-context values never carry secrets",
1185 id, key,
1186 ));
1187 }
1188 match host_key {
1189 crate::extensions::manifest::HostContextKey::ProjectRoot => {
1190 if let Some(root) = host_project_root {
1191 out.insert(key.to_string(), Value::String(root.to_string()));
1192 } else if let Some(default) = &entry.default {
1193 out.insert(key.to_string(), default.clone());
1194 } else if entry.required {
1195 return Err(format!(
1196 "Extension '{}' requires host context 'project_root' but the host could not resolve a trusted project root",
1197 id,
1198 ));
1199 }
1200 }
1201 }
1202 continue;
1203 }
1204 let config_key = format!("extension.{}.{}", id, key);
1205 let secret_env: Option<&str> = match entry.secret_env.as_deref() {
1211 None => None,
1212 Some(raw) => match Self::secret_env_name(raw) {
1213 None => {
1214 let shown: String = raw.chars().take(64).collect();
1215 tracing::warn!(
1216 extension = %id,
1217 key = %key,
1218 secret_env = ?shown,
1219 "ignoring secret_env: not an upper-case variable name matching [A-Z_][A-Z0-9_]*"
1220 );
1221 None
1222 }
1223 Some(name) => {
1224 if let Some(reason) = Self::denied_secret_env(name, id, &own_keys) {
1225 return Err(format!(
1226 "Extension '{}' config key '{}' declares secret_env '{}': {}",
1227 id, key, name, reason
1228 ));
1229 }
1230 Some(name)
1231 }
1232 },
1233 };
1234 if let Some(value) = env(&Self::extension_override_var(id, key)) {
1235 out.insert(key.to_string(), Value::String(value));
1236 continue;
1237 }
1238 if let Some(secret_env) = secret_env {
1239 if let Some(value) = env(secret_env) {
1240 out.insert(key.to_string(), Value::String(value));
1241 continue;
1242 }
1243 }
1244 if let Some(value) = crate::extensions::config_store::read_plugin_config(id, key) {
1245 out.insert(key.to_string(), Value::String(value));
1246 continue;
1247 }
1248 if let Some(value) = crate::config::read_config_value(&config_key) {
1249 out.insert(key.to_string(), Value::String(value));
1250 continue;
1251 }
1252 if let Some(default) = &entry.default {
1253 out.insert(key.to_string(), default.clone());
1254 continue;
1255 }
1256 if entry.required {
1257 let hint = if let Some(secret_env) = secret_env {
1258 format!(
1259 "set environment variable '{}' or config key '{}'",
1260 secret_env, config_key
1261 )
1262 } else {
1263 format!("set config key '{}'", config_key)
1264 };
1265 return Err(format!(
1266 "Extension '{}' missing required config '{}': {}",
1267 id, key, hint
1268 ));
1269 }
1270 }
1271 Ok(Value::Object(out))
1272 }
1273
1274 #[cfg(test)]
1278 pub(crate) fn test_seed_capabilities(
1279 &mut self,
1280 id: &str,
1281 decls: Vec<crate::extensions::runtime::process::CapabilityDeclaration>,
1282 ) {
1283 self.capabilities.insert(id.to_string(), decls);
1284 }
1285
1286 pub async fn unload(&mut self, id: &str) -> Result<(), String> {
1294 self.unload_inner(id, false).await.map(|_| ())
1295 }
1296
1297 async fn unload_inner(
1303 &mut self,
1304 id: &str,
1305 keep_tools: bool,
1306 ) -> Result<Vec<(String, Arc<dyn crate::Tool>)>, String> {
1307 let deferred = self
1308 .deferred_tool_only
1309 .lock()
1310 .unwrap_or_else(std::sync::PoisonError::into_inner)
1311 .remove(id);
1312 if let Some(record) = deferred {
1313 if let Some(runtime) = self.extension_runtime.get() {
1316 let revoked = runtime.revoke_plugin_all_sessions(id);
1317 if revoked > 0 {
1318 tracing::info!(extension = %id, leases = revoked,
1319 "Terminated live runtime leases of unloaded deferred extension");
1320 }
1321 }
1322 self.hook_bus.unsubscribe_all(id).await;
1323 self.providers.unregister_plugin(id);
1324 let mut kept = Vec::new();
1325 if let Some(tools) = &self.tools {
1326 let names: Vec<String> = record
1327 .manifest
1328 .deferred
1329 .as_ref()
1330 .map(|d| {
1331 d.tools
1332 .iter()
1333 .map(|t| format!("{}:{}", id, t.name))
1334 .collect()
1335 })
1336 .unwrap_or_default();
1337 if keep_tools {
1338 let registry = tools.read().await;
1339 kept = kept_entries(®istry, &names);
1340 self.reload_replaces = names.clone();
1341 } else if let Err(error) = tools.write().await.try_disable(&names) {
1342 return Err(format!(
1346 "Extension '{}' unloaded (record removed, leases revoked) but its dormant catalog batch could not be deregistered: {}",
1347 id, error
1348 ));
1349 }
1350 }
1351 self.manifest_configs.remove(id);
1352 self.manifest_theme_tokens.remove(id);
1353 tracing::info!(extension = %id, "Deferred extension unloaded");
1354 return Ok(kept);
1355 }
1356 let handler = self
1357 .extensions
1358 .remove(id)
1359 .ok_or_else(|| format!("Extension '{}' not found", id))?;
1360
1361 self.hook_bus.unsubscribe_all(id).await;
1362 self.providers.unregister_plugin(id);
1363 let mut kept = Vec::new();
1367 let tool_error = match &self.tools {
1368 Some(tools) if keep_tools => {
1369 let registry = tools.read().await;
1370 let names = registry.tool_names_for_extension(id);
1371 kept = kept_entries(®istry, &names);
1372 self.reload_replaces = names;
1374 None
1375 }
1376 Some(tools) => {
1377 let mut registry = tools.write().await;
1378 let names = registry.tool_names_for_extension(id);
1379 registry.try_disable(&names).err()
1380 }
1381 None => None,
1382 };
1383 self.eager_permissions.remove(id);
1384 self.manifest_configs.remove(id);
1385 self.capabilities.remove(id);
1386 self.plugin_info.remove(id);
1387 self.manifest_theme_tokens.remove(id);
1388 handler.shutdown().await;
1389
1390 if let Some(error) = tool_error {
1391 return Err(format!(
1394 "Extension '{}' unloaded but its tools could not be deregistered: {}",
1395 id, error
1396 ));
1397 }
1398 tracing::info!(extension = %id, "Extension unloaded");
1399 Ok(kept)
1400 }
1401
1402 async fn settle_replaced_tools(&mut self, id: &str, kept: Vec<(String, Arc<dyn crate::Tool>)>) -> Result<(), String> {
1408 self.reload_replaces.clear();
1409 let Some(tools) = &self.tools else { return Ok(()) };
1410 if kept.is_empty() {
1411 return Ok(());
1412 }
1413 let mut registry = tools.write().await;
1414 let stale: Vec<String> = kept
1415 .into_iter()
1416 .filter(|(name, old)| registry.get(name).is_some_and(|now| same_tool(now, old)))
1417 .map(|(name, _)| name)
1418 .collect();
1419 registry
1420 .try_disable(&stale)
1421 .map_err(|e| format!("Extension '{id}' reloaded but its stale tools could not be deregistered: {e}"))
1422 }
1423
1424 pub async fn reload(
1437 &mut self,
1438 id: &str,
1439 manifest: &ExtensionManifest,
1440 cwd: Option<std::path::PathBuf>,
1441 ) -> Result<(), String> {
1442 let kept = if self.extensions.contains_key(id) || self.is_deferred_tool_only(id) {
1443 self.unload_inner(id, true).await?
1444 } else {
1445 Vec::new()
1446 };
1447 let loaded = self.load_with_cwd(id, manifest, cwd).await;
1448 let settled = self.settle_replaced_tools(id, kept).await;
1449 loaded.and(settled)
1450 }
1451
1452 pub async fn reload_from_disk(
1470 &mut self,
1471 id: &str,
1472 ) -> Result<ExtensionReloadReport, ExtensionLoadFailure> {
1473 let refuse = |reason: String, hint: &str| ExtensionLoadFailure::new(id, None, reason, hint);
1474 if id.is_empty()
1475 || id == "."
1476 || id == ".."
1477 || id.contains('/')
1478 || id.contains('\\')
1479 || id.contains(':')
1480 || id.chars().any(char::is_control)
1481 {
1482 return Err(refuse(
1483 format!("'{id}' is not a plugin name"),
1484 "Give the plugin's directory name, as `/extensions` lists it",
1485 ));
1486 }
1487 if is_inert_plugin_dir_name(id) {
1488 return Err(refuse(
1489 format!("'{id}' is a backup, disabled or temp directory name; discovery never loads it"),
1490 "Give the plugin's directory name without the suffix",
1491 ));
1492 }
1493 if !matches!(self.discovery, DiscoveryState::Done { .. }) {
1494 return Err(refuse(
1495 "extensions have not finished loading yet".into(),
1496 "Retry in a moment",
1497 ));
1498 }
1499 let was_loaded = self.is_loaded(id);
1500 let Some(plugin_dir) = locate_plugin_dir(id) else {
1501 let roots = plugin_roots()
1502 .iter()
1503 .map(|r| r.display().to_string())
1504 .collect::<Vec<_>>()
1505 .join(", ");
1506 return Err(refuse(
1507 format!("no plugin directory named '{id}' in {roots}"),
1508 "Check the name against `ls ~/.synaps-cli/plugins` or `/extensions`",
1509 ));
1510 };
1511 if crate::config::load_config()
1512 .disabled_plugins
1513 .iter()
1514 .any(|d| d == id)
1515 {
1516 return Err(refuse(
1517 format!("'{id}' is disabled (disabled_plugins in config)"),
1518 "Remove it from disabled_plugins first",
1519 ));
1520 }
1521 let plugins_state = {
1522 let state_path = crate::skills::state::PluginsState::default_path();
1523 crate::skills::state::PluginsState::load_from(&state_path).unwrap_or_default()
1524 };
1525 if let Some(message) = installed_plugin_setup_failure_in(&plugins_state, id) {
1526 return Err(refuse(
1527 format!("Post-install setup failed: {message}"),
1528 "Open /plugins, reinstall or update the plugin after fixing setup",
1529 ));
1530 }
1531 let (resolved, manifest_path, json) = match read_extension_manifest(id, &plugin_dir)? {
1532 Some(read) => read,
1533 None => {
1534 return Err(ExtensionLoadFailure::new(
1535 id,
1536 Some(plugin_dir.join(".synaps-plugin").join("plugin.json")),
1537 format!("'{id}' has no extension (no plugin.json, or no `extension` block in it)"),
1538 "Only plugins with an `extension` block run as extensions",
1539 ));
1540 }
1541 };
1542 if let Err(e) = resolved.validate(id) {
1543 return Err(ExtensionLoadFailure::new(
1544 id,
1545 Some(manifest_path),
1546 e,
1547 "Fix the manifest; the running instance was not touched",
1548 ));
1549 }
1550
1551 let prefix = format!("{id}:");
1553 let owned_tools = |registry: &crate::ToolRegistry| -> Vec<String> {
1554 registry
1555 .iter_tools_sorted()
1556 .into_iter()
1557 .map(|t| t.name().to_string())
1558 .filter(|n| n.starts_with(&prefix))
1559 .collect()
1560 };
1561 let before = match &self.tools {
1562 Some(tools) => owned_tools(&*tools.read().await),
1563 None => Vec::new(),
1564 };
1565 let outcome = self.reload(id, &resolved, Some(plugin_dir.clone())).await;
1566 let failure = outcome.as_ref().err().map(|e| {
1567 let setup_script = json.pointer("/extension/setup").and_then(|v| v.as_str());
1568 let hint = compute_extension_load_hint(e, &plugin_dir, setup_script);
1569 let reason = if was_loaded {
1570 format!("{e} (the previous instance was stopped, so '{id}' is not loaded now)")
1571 } else {
1572 e.clone()
1573 };
1574 ExtensionLoadFailure::new(id, Some(manifest_path.clone()), reason, hint)
1575 });
1576 if let DiscoveryState::Done { loaded, failed } = &mut self.discovery {
1577 loaded.retain(|l| l != id);
1578 failed.retain(|f| f.plugin != id);
1579 match &failure {
1580 None => loaded.push(id.to_string()),
1581 Some(f) => failed.push(f.clone()),
1582 }
1583 }
1584 if let Some(failure) = failure {
1585 tracing::warn!(extension = %id, error = %failure.reason, "Extension reload failed");
1586 return Err(failure);
1587 }
1588 let tools = match &self.tools {
1589 Some(tools) => owned_tools(&*tools.read().await),
1590 None => Vec::new(),
1591 };
1592 let removed_tools = before.into_iter().filter(|n| !tools.contains(n)).collect();
1593 let deferred = self.is_deferred_tool_only(id);
1594 tracing::info!(extension = %id, was_loaded, deferred, tools = tools.len(), path = %plugin_dir.display(), "Extension reloaded");
1595 Ok(ExtensionReloadReport {
1596 extension: id.to_string(),
1597 was_loaded,
1598 deferred,
1599 tools,
1600 removed_tools,
1601 plugin_dir,
1602 })
1603 }
1604
1605 pub fn is_loaded(&self, id: &str) -> bool {
1607 self.extensions.contains_key(id) || self.is_deferred_tool_only(id)
1608 }
1609
1610 pub fn discovery_state(&self) -> &DiscoveryState {
1612 &self.discovery
1613 }
1614
1615 pub fn discovery_done(&self) -> Option<(Vec<String>, Vec<ExtensionLoadFailure>)> {
1619 match &self.discovery {
1620 DiscoveryState::Done { loaded, failed } => Some((loaded.clone(), failed.clone())),
1621 _ => None,
1622 }
1623 }
1624
1625 pub async fn shutdown_all(&mut self) {
1628 self.discovery = DiscoveryState::NotStarted;
1629 let ids: Vec<String> = self.extensions.keys().cloned().collect();
1630 for id in ids {
1631 let _ = self.unload(&id).await;
1632 }
1633 let deferred_ids: Vec<String> = {
1636 let map = self
1637 .deferred_tool_only
1638 .lock()
1639 .unwrap_or_else(std::sync::PoisonError::into_inner);
1640 map.keys().cloned().collect()
1641 };
1642 for id in deferred_ids {
1643 let _ = self.unload(&id).await;
1644 }
1645 if let Some(runtime) = self.extension_runtime.get() {
1646 runtime.terminate_all();
1647 }
1648 }
1649
1650 pub fn shutdown_all_detached(
1656 manager: Arc<tokio::sync::RwLock<Self>>,
1657 ) -> tokio::task::JoinHandle<()> {
1658 tokio::spawn(async move {
1659 manager.write().await.shutdown_all().await;
1660 })
1661 }
1662
1663 pub fn list(&self) -> Vec<&str> {
1665 self.extensions.keys().map(|s| s.as_str()).collect()
1666 }
1667
1668 pub fn handlers(&self) -> Vec<(String, Arc<dyn super::runtime::ExtensionHandler>)> {
1672 let mut out: Vec<_> = self
1673 .extensions
1674 .iter()
1675 .map(|(id, h)| (id.clone(), Arc::clone(h)))
1676 .collect();
1677 out.sort_by(|a, b| a.0.cmp(&b.0));
1678 out
1679 }
1680
1681 pub fn count(&self) -> usize {
1683 self.extensions.len()
1684 }
1685
1686 pub async fn statuses(&self) -> Vec<ExtensionStatus> {
1688 let mut handlers: Vec<(String, Arc<dyn ExtensionHandler>)> = self
1689 .extensions
1690 .iter()
1691 .map(|(id, handler)| (id.clone(), handler.clone()))
1692 .collect();
1693 handlers.sort_by(|a, b| a.0.cmp(&b.0));
1694
1695 let mut statuses = Vec::with_capacity(handlers.len());
1696 for (id, handler) in handlers {
1697 statuses.push(ExtensionStatus {
1698 id,
1699 health: handler.health().await,
1700 restart_count: handler.restart_count().await,
1701 });
1702 }
1703 statuses
1704 }
1705
1706 pub fn providers(&self) -> Vec<&RegisteredProvider> {
1708 self.providers.list()
1709 }
1710
1711 pub fn provider(&self, runtime_id: &str) -> Option<&RegisteredProvider> {
1713 self.providers.get(runtime_id)
1714 }
1715
1716 pub fn plugin_info(&self, id: &str) -> Option<&PluginInfo> {
1718 self.plugin_info.get(id)
1719 }
1720
1721 pub fn theme_tokens(&self) -> Vec<(String, std::collections::BTreeMap<String, String>)> {
1726 let mut out: Vec<_> = self
1727 .manifest_theme_tokens
1728 .iter()
1729 .map(|(id, tokens)| (id.clone(), tokens.clone()))
1730 .collect();
1731 out.sort_by(|a, b| a.0.cmp(&b.0));
1732 out
1733 }
1734
1735 pub fn user_action_handler(
1742 &self,
1743 id: &str,
1744 ) -> Result<Arc<dyn super::runtime::ExtensionHandler>, String> {
1745 if let Some(handler) = self.extensions.get(id) {
1746 return Ok(handler.clone());
1747 }
1748 if self.is_deferred(id) {
1749 return Ok(Arc::new(
1750 crate::extensions::lease::LazyExtensionHandler::new(id, self.extension_runtime()),
1751 ));
1752 }
1753 Err(format!("unknown extension '{}'", id))
1754 }
1755
1756 pub fn session_driver_handler(&self, id: &str) -> Result<Arc<dyn ExtensionHandler>, String> {
1762 if self.is_deferred(id) {
1763 return Err("session drivers require an eagerly loaded extension; deferred drivers are unsupported".into());
1764 }
1765 let handler = self
1766 .extensions
1767 .get(id)
1768 .ok_or_else(|| "session driver extension is not loaded".to_string())?;
1769 if !self
1770 .eager_permissions
1771 .get(id)
1772 .is_some_and(|permissions| permissions.has(Permission::SessionDrive))
1773 {
1774 return Err("session driver extension lacks validated session.drive permission".into());
1775 }
1776 Ok(handler.clone())
1777 }
1778
1779 pub fn has_session_drive(&self, id: &str) -> bool {
1785 self.eager_permissions
1786 .get(id)
1787 .is_some_and(|permissions| permissions.has(Permission::SessionDrive))
1788 }
1789
1790 pub async fn invoke_command(
1801 &self,
1802 id: &str,
1803 command: &str,
1804 args: Vec<String>,
1805 request_id: &str,
1806 sink: crate::extensions::invoke_output::InvokeEventSink,
1807 ) -> Result<serde_json::Value, String> {
1808 let handler = self.user_action_handler(id)?;
1809 handler
1810 .invoke_command(command, args, request_id, sink)
1811 .await
1812 }
1813
1814 pub async fn invoke_command_collected(
1826 &self,
1827 id: &str,
1828 command: &str,
1829 args: Vec<String>,
1830 request_id: &str,
1831 ) -> (
1832 Result<serde_json::Value, String>,
1833 crate::extensions::invoke_output::InvokeOutputReport,
1834 ) {
1835 let (sink, collector) = crate::extensions::invoke_output::invoke_event_channel(
1836 crate::extensions::invoke_output::InvokeOutputBudget::default(),
1837 );
1838 let handler = match self.user_action_handler(id) {
1839 Ok(handler) => handler,
1840 Err(error) => {
1841 drop(sink);
1842 return (Err(error), collector.collect().await);
1843 }
1844 };
1845 let invoke = handler.invoke_command(command, args, request_id, sink);
1846 let (result, report) = tokio::join!(invoke, collector.collect());
1847 (result, report)
1848 }
1849
1850 pub async fn settings_editor_open(
1851 &self,
1852 id: &str,
1853 category: &str,
1854 field: &str,
1855 ) -> Result<serde_json::Value, String> {
1856 let handler = self.user_action_handler(id)?;
1857 handler.settings_editor_open(category, field).await
1858 }
1859
1860 pub async fn settings_editor_key(
1861 &self,
1862 id: &str,
1863 category: &str,
1864 field: &str,
1865 key: &str,
1866 ) -> Result<serde_json::Value, String> {
1867 let handler = self.user_action_handler(id)?;
1868 handler.settings_editor_key(category, field, key).await
1869 }
1870
1871 pub async fn settings_editor_commit(
1872 &self,
1873 id: &str,
1874 category: &str,
1875 field: &str,
1876 value: serde_json::Value,
1877 ) -> Result<serde_json::Value, String> {
1878 let handler = self.user_action_handler(id)?;
1879 handler.settings_editor_commit(category, field, value).await
1880 }
1881
1882 pub fn plugin_infos(&self) -> Vec<(&str, &PluginInfo)> {
1884 let mut entries: Vec<_> = self
1885 .plugin_info
1886 .iter()
1887 .map(|(id, info)| (id.as_str(), info))
1888 .collect();
1889 entries.sort_by(|a, b| a.0.cmp(b.0));
1890 entries
1891 }
1892
1893 pub fn provider_summaries(&self) -> Vec<RegisteredProviderSummary> {
1895 self.providers.summaries()
1896 }
1897
1898 pub async fn capability_snapshots(&self) -> Vec<ExtensionCapabilitySnapshot> {
1904 let mut handlers: Vec<(String, Arc<dyn ExtensionHandler>)> = self
1905 .extensions
1906 .iter()
1907 .map(|(id, handler)| (id.clone(), handler.clone()))
1908 .collect();
1909 handlers.sort_by(|a, b| a.0.cmp(&b.0));
1910
1911 let provider_summaries = self.providers.summaries();
1912 let plugin_id_lookup: std::collections::HashMap<String, String> = self
1913 .providers
1914 .list()
1915 .into_iter()
1916 .map(|p| (p.runtime_id.clone(), p.plugin_id.clone()))
1917 .collect();
1918
1919 let mut out = Vec::with_capacity(handlers.len());
1920 for (id, handler) in handlers {
1921 let health = handler.health().await;
1922 let restart_count = handler.restart_count().await;
1923
1924 let hook_pairs = self.hook_bus.subscriptions_for(&id).await;
1925 let hooks: Vec<HookCapabilityEntry> = hook_pairs
1926 .into_iter()
1927 .map(|(kind, tool_filter)| HookCapabilityEntry {
1928 kind: kind.as_str().to_string(),
1929 tool_filter,
1930 })
1931 .collect();
1932
1933 let tools: Vec<ToolCapabilityEntry> = if let Some(tools) = &self.tools {
1934 let registry = tools.read().await;
1935 registry
1936 .tool_names_for_extension(&id)
1937 .into_iter()
1938 .map(|name| ToolCapabilityEntry { name })
1939 .collect()
1940 } else {
1941 Vec::new()
1942 };
1943
1944 let providers: Vec<RegisteredProviderSummary> = provider_summaries
1945 .iter()
1946 .filter(|summary| {
1947 plugin_id_lookup
1948 .get(&summary.runtime_id)
1949 .map(|p| p == &id)
1950 .unwrap_or(false)
1951 })
1952 .cloned()
1953 .collect();
1954
1955 let future: Vec<FutureCapabilityEntry> = self
1956 .capabilities
1957 .get(&id)
1958 .map(|decls| {
1959 decls
1960 .iter()
1961 .map(|d| FutureCapabilityEntry {
1962 kind: d.kind.clone(),
1963 name: d.name.clone(),
1964 })
1965 .collect()
1966 })
1967 .unwrap_or_default();
1968
1969 out.push(ExtensionCapabilitySnapshot {
1970 id,
1971 health,
1972 restart_count,
1973 hooks,
1974 tools,
1975 providers,
1976 future,
1977 });
1978 }
1979 out
1980 }
1981
1982 pub fn provider_tool_use_runtime_ids(&self) -> Vec<String> {
1985 let mut ids: Vec<String> = self
1986 .providers
1987 .list()
1988 .into_iter()
1989 .filter(|p| {
1990 p.spec.models.iter().any(|m| {
1991 m.capabilities
1992 .get("tool_use")
1993 .and_then(|v| v.as_bool())
1994 .unwrap_or(false)
1995 })
1996 })
1997 .map(|p| p.runtime_id.clone())
1998 .collect();
1999 ids.sort();
2000 ids
2001 }
2002
2003 pub fn provider_trust_view(&self) -> std::collections::BTreeMap<String, bool> {
2009 let trust = match crate::extensions::trust::load_trust_state() {
2010 Ok(t) => t,
2011 Err(e) => {
2012 tracing::warn!("trust.json corrupt or unreadable, failing closed (all providers disabled): {e}");
2013 return self
2015 .providers
2016 .list()
2017 .into_iter()
2018 .map(|p| (p.runtime_id.clone(), false))
2019 .collect();
2020 }
2021 };
2022 self.providers
2023 .list()
2024 .into_iter()
2025 .map(|p| {
2026 let enabled = crate::extensions::trust::is_provider_enabled(&trust, &p.runtime_id);
2027 (p.runtime_id.clone(), enabled)
2028 })
2029 .collect()
2030 }
2031
2032 pub fn config_diagnostics(&self, id: &str) -> Option<ExtensionConfigDiagnostics> {
2035 let manifest_config = self.manifest_configs.get(id)?;
2036
2037 let mut provider_required: Vec<(String, Vec<String>)> = Vec::new();
2039 for provider in self.providers.list() {
2040 if provider.plugin_id != id {
2041 continue;
2042 }
2043 let required: Vec<String> = provider
2044 .spec
2045 .config_schema
2046 .as_ref()
2047 .and_then(|schema| schema.get("required"))
2048 .and_then(Value::as_array)
2049 .map(|arr| {
2050 arr.iter()
2051 .filter_map(|v| v.as_str().map(|s| s.to_string()))
2052 .collect()
2053 })
2054 .unwrap_or_default();
2055 provider_required.push((provider.provider_id.clone(), required));
2056 }
2057 provider_required.sort_by(|a, b| a.0.cmp(&b.0));
2058
2059 let env_lookup = |name: &str| std::env::var(name).ok();
2060 let plugin_config_lookup =
2061 |key: &str| crate::extensions::config_store::read_plugin_config(id, key);
2062 let legacy_config_lookup = |key: &str| crate::config::read_config_value(key);
2063
2064 Some(diagnose_extension_config(
2065 id,
2066 manifest_config,
2067 &provider_required,
2068 &env_lookup,
2069 &plugin_config_lookup,
2070 &legacy_config_lookup,
2071 ))
2072 }
2073
2074 pub fn all_config_diagnostics(&self) -> Vec<ExtensionConfigDiagnostics> {
2076 let mut ids: Vec<&String> = self.manifest_configs.keys().collect();
2077 ids.sort();
2078 ids.into_iter()
2079 .filter_map(|id| self.config_diagnostics(id))
2080 .collect()
2081 }
2082
2083 pub fn hook_bus(&self) -> &Arc<HookBus> {
2085 &self.hook_bus
2086 }
2087
2088 pub fn tools_shared(&self) -> Option<Arc<tokio::sync::RwLock<crate::ToolRegistry>>> {
2090 self.tools.clone()
2091 }
2092
2093 #[cfg(test)]
2096 pub(crate) fn register_provider_handler_for_tests(
2097 &mut self,
2098 plugin_id: &str,
2099 spec: crate::extensions::runtime::process::RegisteredProviderSpec,
2100 handler: Arc<dyn ExtensionHandler>,
2101 ) -> Result<String, String> {
2102 self.providers
2103 .register_with_handler(plugin_id, spec, Some(handler))
2104 }
2105
2106 pub async fn discover_and_load(&mut self) -> (Vec<String>, Vec<ExtensionLoadFailure>) {
2113 self.discover_and_load_with_progress(|_| {}).await
2114 }
2115
2116 pub async fn discover_and_load_with_progress<F>(
2120 &mut self,
2121 mut progress: F,
2122 ) -> (Vec<String>, Vec<ExtensionLoadFailure>)
2123 where
2124 F: FnMut(crate::extensions::loader::ExtensionLoaderEvent),
2125 {
2126 if let Some(done) = self.discovery_done() {
2129 tracing::debug!(
2130 loaded = done.0.len(),
2131 failed = done.1.len(),
2132 "Extension discovery already done; replaying recorded result"
2133 );
2134 return done;
2135 }
2136 self.discovery = DiscoveryState::Running;
2137 let (loaded, failed) = self.discover_and_load_uncached(&mut progress).await;
2138 self.discovery = DiscoveryState::Done {
2139 loaded: loaded.clone(),
2140 failed: failed.clone(),
2141 };
2142 (loaded, failed)
2143 }
2144
2145 async fn discover_and_load_uncached<F>(
2146 &mut self,
2147 progress: &mut F,
2148 ) -> (Vec<String>, Vec<ExtensionLoadFailure>)
2149 where
2150 F: FnMut(crate::extensions::loader::ExtensionLoaderEvent),
2151 {
2152 let mut plugin_dirs: HashMap<String, PathBuf> = HashMap::new();
2153 let mut failed: Vec<ExtensionLoadFailure> = Vec::new();
2154
2155 for plugins_dir in plugin_roots() {
2156 if !plugins_dir.exists() {
2157 continue;
2158 }
2159
2160 let entries = match std::fs::read_dir(&plugins_dir) {
2161 Ok(e) => e,
2162 Err(e) => {
2163 tracing::warn!(path = %plugins_dir.display(), error = %e, "Failed to read plugins directory");
2164 failed.push(ExtensionLoadFailure::new(
2165 "plugins",
2166 Some(plugins_dir.clone()),
2167 format!("Failed to read plugins directory: {e}"),
2168 "Check directory permissions and retry",
2169 ));
2170 continue;
2171 }
2172 };
2173
2174 for entry in entries.flatten() {
2175 let plugin_name = entry.file_name().to_string_lossy().to_string();
2176 if is_inert_plugin_dir_name(&plugin_name) {
2177 tracing::debug!(
2178 plugin = %plugin_name,
2179 path = %entry.path().display(),
2180 "Skipping backup/disabled/temp plugin directory"
2181 );
2182 continue;
2183 }
2184 plugin_dirs.insert(plugin_name, entry.path());
2185 }
2186 }
2187
2188 let mut plugin_dirs: Vec<(String, PathBuf)> = plugin_dirs.into_iter().collect();
2189 plugin_dirs.sort_by(|a, b| a.0.cmp(&b.0));
2190
2191 let mut loaded = Vec::new();
2192 let disabled_plugins = crate::config::load_config().disabled_plugins;
2193 let plugins_state = {
2196 let state_path = crate::skills::state::PluginsState::default_path();
2197 crate::skills::state::PluginsState::load_from(&state_path).unwrap_or_default()
2198 };
2199 for (plugin_name, plugin_dir) in plugin_dirs {
2200 if disabled_plugins.iter().any(|d| d == &plugin_name) {
2201 tracing::debug!(plugin = %plugin_name, "Extension disabled via disabled_plugins config");
2202 continue;
2203 }
2204 if let Some(message) = installed_plugin_setup_failure_in(&plugins_state, &plugin_name) {
2205 tracing::warn!(plugin = %plugin_name, error = %message, "Skipping extension with failed post-install setup");
2206 failed.push(ExtensionLoadFailure::new(
2207 plugin_name,
2208 None,
2209 format!("Post-install setup failed: {message}"),
2210 "Open /plugins, reinstall or update the plugin after fixing setup; extension load is disabled until setup succeeds",
2211 ));
2212 continue;
2213 }
2214 let (resolved, manifest_path, json) =
2215 match read_extension_manifest(&plugin_name, &plugin_dir) {
2216 Ok(Some(read)) => read,
2217 Ok(None) => continue,
2218 Err(failure) => {
2219 failed.push(failure);
2220 continue;
2221 }
2222 };
2223
2224 match self
2225 .load_with_cwd(&plugin_name, &resolved, Some(plugin_dir.clone()))
2226 .await
2227 {
2228 Ok(()) => {
2229 tracing::info!(plugin = %plugin_name, path = %plugin_dir.display(), "Extension loaded from plugins/");
2230 loaded.push(plugin_name.clone());
2231 progress(crate::extensions::loader::ExtensionLoaderEvent::Loaded {
2232 plugin: plugin_name,
2233 loaded: loaded.len(),
2234 failed: failed.len(),
2235 });
2236 }
2237 Err(e) => {
2238 tracing::warn!(plugin = %plugin_name, manifest = %manifest_path.display(), error = %e, "Failed to load extension");
2239 let setup_script = json.pointer("/extension/setup").and_then(|v| v.as_str());
2240 let hint = compute_extension_load_hint(&e, &plugin_dir, setup_script);
2241 let failure =
2242 ExtensionLoadFailure::new(plugin_name, Some(manifest_path), e, hint);
2243 failed.push(failure.clone());
2244 progress(crate::extensions::loader::ExtensionLoaderEvent::Failed {
2245 failure,
2246 loaded: loaded.len(),
2247 failed: failed.len(),
2248 });
2249 }
2250 }
2251 }
2252
2253 (loaded, failed)
2254 }
2255}
2256
2257#[cfg(test)]
2258mod invoke_command_collected_tests {
2259 use super::*;
2265 use crate::extensions::commands::CommandOutputEvent;
2266 use crate::extensions::invoke_output::INVOKE_RETAINED_BYTE_BUDGET;
2267 use crate::extensions::runtime::InvokeCommandEvent;
2268
2269 struct FloodingHandler;
2270
2271 #[async_trait::async_trait]
2272 impl super::ExtensionHandler for FloodingHandler {
2273 fn id(&self) -> &str {
2274 "flooding-fake"
2275 }
2276 async fn handle(
2277 &self,
2278 _event: &crate::extensions::hooks::events::HookEvent,
2279 ) -> crate::extensions::hooks::events::HookResult {
2280 Default::default()
2281 }
2282 async fn shutdown(&self) {}
2283
2284 async fn invoke_command(
2285 &self,
2286 _command: &str,
2287 _args: Vec<String>,
2288 _request_id: &str,
2289 sink: crate::extensions::invoke_output::InvokeEventSink,
2290 ) -> Result<Value, String> {
2291 for _ in 0..2048 {
2295 let event = InvokeCommandEvent::Output(CommandOutputEvent::Text {
2296 content: "f".repeat(4096),
2297 });
2298 if sink.send(event).await.is_err() {
2299 return Err("collector vanished".to_string());
2300 }
2301 }
2302 let _ = sink
2303 .send(InvokeCommandEvent::Output(CommandOutputEvent::Done))
2304 .await;
2305 Ok(serde_json::json!({"status": "flooded"}))
2306 }
2307 }
2308
2309 #[tokio::test]
2310 async fn collected_invoke_bounds_in_process_flood_without_deadlock() {
2311 let bus = Arc::new(HookBus::new());
2312 let mut mgr = ExtensionManager::new(bus);
2313 mgr.extensions
2314 .insert("flooding-fake".to_string(), Arc::new(FloodingHandler));
2315
2316 let (result, report) = tokio::time::timeout(
2317 std::time::Duration::from_secs(30),
2318 mgr.invoke_command_collected("flooding-fake", "any", vec![], "req-1"),
2319 )
2320 .await
2321 .expect("bounded sink + concurrent collector must not deadlock");
2322
2323 assert_eq!(result.expect("invoke ok")["status"], "flooded");
2324 let c = &report.counters;
2325 assert_eq!(c.produced_events, 2049);
2326 assert_eq!(c.produced_payload_bytes, 2048 * 4096);
2327 assert_eq!(c.consumed_events, c.produced_events);
2328 assert_eq!(c.consumed_payload_bytes, c.produced_payload_bytes);
2329 assert_eq!(c.retained_payload_bytes, INVOKE_RETAINED_BYTE_BUDGET as u64);
2330 assert!(c.saw_done);
2331 assert_eq!(
2332 c.consumed_payload_bytes,
2333 c.retained_payload_bytes + c.truncated_payload_bytes + c.dropped_payload_bytes,
2334 );
2335 assert_eq!(c.consumed_events, c.retained_events + c.dropped_events);
2336 }
2337
2338 #[tokio::test]
2339 async fn collected_invoke_reports_unknown_extension_with_empty_report() {
2340 let bus = Arc::new(HookBus::new());
2341 let mgr = ExtensionManager::new(bus);
2342 let (result, report) = mgr
2343 .invoke_command_collected("nope", "any", vec![], "req-1")
2344 .await;
2345 assert!(result
2346 .expect_err("unknown id")
2347 .contains("unknown extension"));
2348 assert!(report.events.is_empty());
2349 assert_eq!(report.counters.produced_events, 0);
2350 }
2351}
2352
2353#[cfg(test)]
2354mod tests {
2355 use super::*;
2356
2357 #[tokio::test]
2358 async fn capability_snapshots_empty_when_no_extensions() {
2359 let bus = Arc::new(HookBus::new());
2360 let mgr = ExtensionManager::new(bus);
2361 assert!(mgr.capability_snapshots().await.is_empty());
2362 }
2363
2364 #[tokio::test]
2371 async fn deferred_launch_record_never_leaks_secret_config_through_diagnostics() {
2372 const SECRET: &str = "task20-A3-sentinel-5f2c9d";
2373 const VAR: &str = "T20_A3_THIRD_PARTY_SECRET";
2377 std::env::set_var(VAR, SECRET);
2378
2379 let manifest: ExtensionManifest = serde_json::from_value(serde_json::json!({
2380 "runtime": "process",
2381 "command": "/bin/false",
2382 "permissions": ["tools.register"],
2383 "config": [{
2384 "key": "api_key",
2385 "type": "string",
2386 "required": true,
2387 "secret_env": VAR
2388 }],
2389 "deferred": {
2390 "tools": [{
2391 "name": "search",
2392 "description": "deferred search",
2393 "input_schema": {"type": "object"}
2394 }]
2395 }
2396 }))
2397 .unwrap();
2398
2399 let registry = Arc::new(tokio::sync::RwLock::new(crate::ToolRegistry::new()));
2400 let mut mgr = ExtensionManager::new_with_tools(Arc::new(HookBus::new()), registry);
2401 mgr.set_progressive_deferral(true);
2402 mgr.load("secretive", &manifest).await.unwrap();
2403
2404 assert!(mgr.is_deferred_tool_only("secretive"));
2406 assert_eq!(mgr.deferred_tool_only_count(), 1);
2407 let record = mgr.deferred_launch_record("secretive").expect("retained");
2409 assert_eq!(record.config["api_key"], serde_json::json!(SECRET));
2410
2411 let mut rendered = String::new();
2413 rendered.push_str(&format!("{:?}", mgr.statuses().await));
2414 rendered.push_str(&format!("{:?}", mgr.config_diagnostics("secretive")));
2415 rendered.push_str(&format!("{:?}", mgr.all_config_diagnostics()));
2416 rendered.push_str(&format!("{:?}", mgr.capability_snapshots().await));
2417 rendered.push_str(&format!("{:?}", mgr.plugin_infos()));
2418 rendered.push_str(&format!("{:?}", mgr.list()));
2419 assert!(
2420 !rendered.contains(SECRET),
2421 "resolved secret config leaked through a public diagnostic surface"
2422 );
2423
2424 std::env::remove_var(VAR);
2425 }
2426
2427 #[tokio::test]
2428 async fn capability_snapshot_lists_hooks_for_loaded_extension() {
2429 let bus = Arc::new(HookBus::new());
2430 let mut mgr = ExtensionManager::new(bus.clone());
2431 let manifest = ExtensionManifest {
2432 theme_tokens: Default::default(),
2433 deferred: None,
2434 protocol_version: 1,
2435 runtime: crate::extensions::manifest::ExtensionRuntime::Process,
2436 command: "python3".to_string(),
2437 setup: None,
2438 prebuilt: ::std::collections::HashMap::new(),
2439 args: vec![
2440 "tests/fixtures/process_extension.py".to_string(),
2441 "normal".to_string(),
2442 "/tmp/synaps-capability-test.log".to_string(),
2443 ],
2444 permissions: vec!["tools.intercept".to_string()],
2445 hooks: vec![crate::extensions::manifest::HookSubscription {
2446 hook: "before_tool_call".to_string(),
2447 tool: Some("bash".to_string()),
2448 matcher: None,
2449 }],
2450 config: vec![],
2451 };
2452
2453 mgr.load("cap-snap", &manifest).await.unwrap();
2454
2455 let snaps = mgr.capability_snapshots().await;
2456 assert_eq!(snaps.len(), 1);
2457 let snap = &snaps[0];
2458 assert_eq!(snap.id, "cap-snap");
2459 assert_eq!(snap.hooks.len(), 1);
2460 assert_eq!(snap.hooks[0].kind, "before_tool_call");
2461 assert_eq!(snap.hooks[0].tool_filter.as_deref(), Some("bash"));
2462 assert!(snap.tools.is_empty());
2463 assert!(snap.providers.is_empty());
2464 assert!(snap.future.is_empty());
2465
2466 mgr.shutdown_all().await;
2467 }
2468
2469 #[tokio::test]
2470 async fn capability_snapshot_surfaces_seeded_capabilities() {
2471 let bus = Arc::new(HookBus::new());
2472 let mut mgr = ExtensionManager::new(bus.clone());
2473 let manifest = ExtensionManifest {
2474 theme_tokens: Default::default(),
2475 deferred: None,
2476 protocol_version: 1,
2477 runtime: crate::extensions::manifest::ExtensionRuntime::Process,
2478 command: "python3".to_string(),
2479 setup: None,
2480 prebuilt: ::std::collections::HashMap::new(),
2481 args: vec![
2482 "tests/fixtures/process_extension.py".to_string(),
2483 "normal".to_string(),
2484 "/tmp/synaps-capability-snapshot-test.log".to_string(),
2485 ],
2486 permissions: vec!["tools.intercept".to_string()],
2487 hooks: vec![crate::extensions::manifest::HookSubscription {
2488 hook: "before_tool_call".to_string(),
2489 tool: Some("bash".to_string()),
2490 matcher: None,
2491 }],
2492 config: vec![],
2493 };
2494
2495 mgr.load("multi-cap", &manifest).await.unwrap();
2496
2497 mgr.test_seed_capabilities(
2501 "multi-cap",
2502 vec![
2503 crate::extensions::runtime::process::CapabilityDeclaration {
2504 kind: "capture".to_string(),
2505 name: "Local Sample STT".to_string(),
2506 permissions: vec!["audio.input".to_string()],
2507 params: serde_json::Value::Null,
2508 },
2509 crate::extensions::runtime::process::CapabilityDeclaration {
2510 kind: "ocr".to_string(),
2511 name: "Tesseract".to_string(),
2512 permissions: vec![],
2513 params: serde_json::Value::Null,
2514 },
2515 ],
2516 );
2517
2518 let snaps = mgr.capability_snapshots().await;
2519 let snap = snaps
2520 .iter()
2521 .find(|s| s.id == "multi-cap")
2522 .expect("multi-cap snapshot");
2523 assert_eq!(snap.future.len(), 2);
2524 let kinds: Vec<&str> = snap.future.iter().map(|e| e.kind.as_str()).collect();
2525 assert!(kinds.contains(&"capture"), "got kinds {:?}", kinds);
2526 assert!(kinds.contains(&"ocr"), "got kinds {:?}", kinds);
2527 let names: Vec<&str> = snap.future.iter().map(|e| e.name.as_str()).collect();
2528 assert!(names.contains(&"Local Sample STT"), "got {:?}", names);
2529 assert!(names.contains(&"Tesseract"), "got {:?}", names);
2530
2531 mgr.unload("multi-cap").await.unwrap();
2532 let snaps = mgr.capability_snapshots().await;
2533 assert!(snaps.iter().all(|s| s.id != "multi-cap"));
2534
2535 mgr.shutdown_all().await;
2536 }
2537
2538 #[tokio::test]
2539 async fn new_manager_has_no_extensions() {
2540 let bus = Arc::new(HookBus::new());
2541 let mgr = ExtensionManager::new(bus);
2542 assert_eq!(mgr.count(), 0);
2543 assert!(mgr.list().is_empty());
2544 }
2545
2546 #[tokio::test]
2547 async fn unload_nonexistent_returns_error() {
2548 let bus = Arc::new(HookBus::new());
2549 let mut mgr = ExtensionManager::new(bus);
2550 let result = mgr.unload("nope").await;
2551 assert!(result.is_err());
2552 }
2553
2554 #[tokio::test]
2555 async fn reload_unsubscribes_old_handler_before_loading_new_one() {
2556 let bus = Arc::new(HookBus::new());
2557 let mut mgr = ExtensionManager::new(bus.clone());
2558 let manifest = ExtensionManifest {
2559 theme_tokens: Default::default(),
2560 deferred: None,
2561 protocol_version: 1,
2562 runtime: crate::extensions::manifest::ExtensionRuntime::Process,
2563 command: "python3".to_string(),
2564 setup: None,
2565 prebuilt: ::std::collections::HashMap::new(),
2566 args: vec![
2567 "tests/fixtures/process_extension.py".to_string(),
2568 "normal".to_string(),
2569 "/tmp/synaps-reload-test.log".to_string(),
2570 ],
2571 permissions: vec!["tools.intercept".to_string()],
2572 hooks: vec![crate::extensions::manifest::HookSubscription {
2573 hook: "before_tool_call".to_string(),
2574 tool: Some("bash".to_string()),
2575 matcher: None,
2576 }],
2577 config: vec![],
2578 };
2579
2580 mgr.load("reload-test", &manifest).await.unwrap();
2581 assert_eq!(bus.handler_count().await, 1);
2582
2583 mgr.reload("reload-test", &manifest, None).await.unwrap();
2584
2585 assert_eq!(mgr.count(), 1);
2586 assert_eq!(bus.handler_count().await, 1);
2587 mgr.shutdown_all().await;
2588 }
2589
2590 #[tokio::test]
2591 async fn reload_failure_leaves_previous_instance_unloaded() {
2592 let bus = Arc::new(HookBus::new());
2593 let mut mgr = ExtensionManager::new(bus.clone());
2594 let good = ExtensionManifest {
2595 theme_tokens: Default::default(),
2596 deferred: None,
2597 protocol_version: 1,
2598 runtime: crate::extensions::manifest::ExtensionRuntime::Process,
2599 command: "python3".to_string(),
2600 setup: None,
2601 prebuilt: ::std::collections::HashMap::new(),
2602 args: vec![
2603 "tests/fixtures/process_extension.py".to_string(),
2604 "normal".to_string(),
2605 "/tmp/synaps-reload-failure-test.log".to_string(),
2606 ],
2607 permissions: vec!["tools.intercept".to_string()],
2608 hooks: vec![crate::extensions::manifest::HookSubscription {
2609 hook: "before_tool_call".to_string(),
2610 tool: Some("bash".to_string()),
2611 matcher: None,
2612 }],
2613 config: vec![],
2614 };
2615 let bad = ExtensionManifest {
2616 theme_tokens: Default::default(),
2617 deferred: None,
2618 command: "/definitely/not/a/real/extension-binary".to_string(),
2619 setup: None,
2620 prebuilt: ::std::collections::HashMap::new(),
2621 ..good.clone()
2622 };
2623
2624 mgr.load("reload-failure-test", &good).await.unwrap();
2625 let err = mgr
2626 .reload("reload-failure-test", &bad, None)
2627 .await
2628 .unwrap_err();
2629
2630 assert!(err.contains("Failed to spawn extension"), "{err}");
2631 assert_eq!(mgr.count(), 0);
2632 assert_eq!(bus.handler_count().await, 0);
2633 }
2634
2635 #[test]
2636 fn project_plugins_disable_env_parser_accepts_truthy_values() {
2637 for value in ["1", "true", "TRUE", "yes", "on"] {
2638 std::env::set_var("SYNAPS_DISABLE_PROJECT_PLUGINS", value);
2639 assert!(project_plugins_disabled());
2640 }
2641 for value in ["", "0", "false", "off", "no"] {
2642 std::env::set_var("SYNAPS_DISABLE_PROJECT_PLUGINS", value);
2643 assert!(!project_plugins_disabled());
2644 }
2645 std::env::remove_var("SYNAPS_DISABLE_PROJECT_PLUGINS");
2646 }
2647
2648 fn with_temp_base_dir<T>(path: &std::path::Path, f: impl FnOnce() -> T) -> T {
2649 let old_base_dir = std::env::var("SYNAPS_BASE_DIR").ok();
2650 crate::config::set_base_dir_for_tests(path.to_path_buf());
2651 let out = f();
2652 match old_base_dir {
2653 Some(old) => std::env::set_var("SYNAPS_BASE_DIR", old),
2654 None => std::env::remove_var("SYNAPS_BASE_DIR"),
2655 }
2656 out
2657 }
2658
2659 #[test]
2660 #[serial_test::serial(synaps_base_dir)]
2661 fn resolve_config_prefers_plugin_namespaced_config_before_legacy_global_key() {
2662 let dir = tempfile::tempdir().unwrap();
2663 with_temp_base_dir(dir.path(), || {
2664 crate::extensions::config_store::write_plugin_config(
2665 "sample-plugin",
2666 "backend",
2667 "cpu",
2668 )
2669 .unwrap();
2670 crate::config::write_config_value("extension.sample-plugin.backend", "auto").unwrap();
2671
2672 let resolved = ExtensionManager::resolve_config(
2673 "sample-plugin",
2674 &[ExtensionConfigEntry {
2675 key: "backend".to_string(),
2676 value_type: None,
2677 description: None,
2678 required: true,
2679 default: None,
2680 secret_env: None,
2681 host_context: None,
2682 }],
2683 None,
2684 )
2685 .unwrap();
2686
2687 assert_eq!(
2688 resolved["backend"],
2689 serde_json::Value::String("cpu".to_string())
2690 );
2691 });
2692 }
2693
2694 #[test]
2695 #[serial_test::serial(synaps_base_dir)]
2696 fn resolve_config_keeps_legacy_global_extension_key_as_fallback() {
2697 let dir = tempfile::tempdir().unwrap();
2698 with_temp_base_dir(dir.path(), || {
2699 crate::config::write_config_value("extension.sample-plugin.backend", "auto").unwrap();
2700
2701 let resolved = ExtensionManager::resolve_config(
2702 "sample-plugin",
2703 &[ExtensionConfigEntry {
2704 key: "backend".to_string(),
2705 value_type: None,
2706 description: None,
2707 required: true,
2708 default: None,
2709 secret_env: None,
2710 host_context: None,
2711 }],
2712 None,
2713 )
2714 .unwrap();
2715
2716 assert_eq!(
2717 resolved["backend"],
2718 serde_json::Value::String("auto".to_string())
2719 );
2720 });
2721 }
2722
2723 #[tokio::test]
2724 async fn config_diagnostics_returns_none_for_unknown_extension() {
2725 let bus = Arc::new(HookBus::new());
2726 let mgr = ExtensionManager::new(bus);
2727 assert!(mgr.config_diagnostics("nope").is_none());
2728 assert!(mgr.all_config_diagnostics().is_empty());
2729 }
2730
2731 #[tokio::test]
2732 async fn config_diagnostics_reports_loaded_manifest_entries() {
2733 let bus = Arc::new(HookBus::new());
2734 let mut mgr = ExtensionManager::new(bus);
2735 let manifest = ExtensionManifest {
2736 theme_tokens: Default::default(),
2737 deferred: None,
2738 protocol_version: 1,
2739 runtime: crate::extensions::manifest::ExtensionRuntime::Process,
2740 command: "python3".to_string(),
2741 setup: None,
2742 prebuilt: ::std::collections::HashMap::new(),
2743 args: vec![
2744 "tests/fixtures/process_extension.py".to_string(),
2745 "normal".to_string(),
2746 "/tmp/synaps-config-diag-test.log".to_string(),
2747 ],
2748 permissions: vec!["tools.intercept".to_string()],
2749 hooks: vec![crate::extensions::manifest::HookSubscription {
2750 hook: "before_tool_call".to_string(),
2751 tool: Some("bash".to_string()),
2752 matcher: None,
2753 }],
2754 config: vec![crate::extensions::manifest::ExtensionConfigEntry {
2755 key: "region".to_string(),
2756 value_type: None,
2757 description: Some("AWS region".to_string()),
2758 required: false,
2759 default: Some(serde_json::Value::String("us-east-1".to_string())),
2760 secret_env: None,
2761 host_context: None,
2762 }],
2763 };
2764
2765 mgr.load("config-diag-test", &manifest).await.unwrap();
2766
2767 let diag = mgr
2768 .config_diagnostics("config-diag-test")
2769 .expect("diagnostics should be available for loaded extension");
2770 assert_eq!(diag.extension_id, "config-diag-test");
2771 assert_eq!(diag.entries.len(), 1);
2772 assert_eq!(diag.entries[0].key, "region");
2773 assert!(diag.entries[0].has_value);
2774 assert!(diag.provider_missing.is_empty());
2775
2776 let all = mgr.all_config_diagnostics();
2777 assert_eq!(all.len(), 1);
2778 assert_eq!(all[0].extension_id, "config-diag-test");
2779
2780 mgr.shutdown_all().await;
2781 assert!(mgr.config_diagnostics("config-diag-test").is_none());
2783 }
2784
2785 #[tokio::test]
2786 async fn provider_trust_view_is_empty_for_no_providers() {
2787 let bus = Arc::new(HookBus::new());
2788 let mgr = ExtensionManager::new(bus);
2789 let view = mgr.provider_trust_view();
2790 assert!(view.is_empty());
2791 }
2792
2793 #[tokio::test]
2794 async fn provider_tool_use_runtime_ids_lists_only_tool_use_capable() {
2795 use crate::extensions::runtime::process::{
2796 RegisteredProviderModelSpec, RegisteredProviderSpec,
2797 };
2798 let bus = Arc::new(HookBus::new());
2799 let mut mgr = ExtensionManager::new(bus);
2800 let tool_spec = RegisteredProviderSpec {
2802 id: "alpha".into(),
2803 display_name: "Alpha".into(),
2804 description: "tool-use".into(),
2805 models: vec![RegisteredProviderModelSpec {
2806 id: "m1".into(),
2807 display_name: None,
2808 capabilities: serde_json::json!({"tool_use": true}),
2809 context_window: None,
2810 }],
2811 config_schema: None,
2812 };
2813 let plain_spec = RegisteredProviderSpec {
2815 id: "beta".into(),
2816 display_name: "Beta".into(),
2817 description: "plain".into(),
2818 models: vec![RegisteredProviderModelSpec {
2819 id: "m1".into(),
2820 display_name: None,
2821 capabilities: serde_json::json!({"streaming": true}),
2822 context_window: None,
2823 }],
2824 config_schema: None,
2825 };
2826 mgr.providers.register("plug", tool_spec).unwrap();
2827 mgr.providers.register("plug", plain_spec).unwrap();
2828 let ids = mgr.provider_tool_use_runtime_ids();
2829 assert_eq!(ids, vec!["plug:alpha".to_string()]);
2830 }
2831
2832 #[test]
2835 fn hint_missing_binary_with_declared_setup_points_at_script() {
2836 let hint = compute_extension_load_hint(
2837 "Failed to spawn extension 'sample-plugin': No such file or directory (os error 2)",
2838 std::path::Path::new("/home/u/.synaps-cli/plugins/sample-plugin"),
2839 Some("scripts/setup.sh"),
2840 );
2841 assert!(
2842 hint.contains("Extension binary missing"),
2843 "missing-binary case should be flagged: {hint}"
2844 );
2845 assert!(
2846 hint.contains("/home/u/.synaps-cli/plugins/sample-plugin"),
2847 "hint should include the plugin dir: {hint}"
2848 );
2849 assert!(
2850 hint.contains("setup=scripts/setup.sh"),
2851 "hint should show sanitized setup path without copy-paste shell command: {hint}"
2852 );
2853 }
2854
2855 #[test]
2856 fn hint_missing_binary_without_declared_setup_falls_back_to_generic() {
2857 let hint = compute_extension_load_hint(
2858 "Failed to spawn extension 'foo': No such file or directory (os error 2)",
2859 std::path::Path::new("/x/y"),
2860 None,
2861 );
2862 assert!(
2863 hint.contains("contract.json"),
2864 "no setup declared → generic hint: {hint}"
2865 );
2866 assert!(
2867 !hint.contains("Extension binary missing"),
2868 "should not falsely promise a setup script: {hint}"
2869 );
2870 }
2871
2872 #[test]
2873 fn hint_other_error_with_declared_setup_falls_back_to_generic() {
2874 let hint = compute_extension_load_hint(
2875 "Extension 'foo' must subscribe to at least one hook or request a registration permission",
2876 std::path::Path::new("/x/y"),
2877 Some("scripts/setup.sh"),
2878 );
2879 assert!(hint.contains("contract.json"), "got {hint}");
2883 assert!(!hint.contains("Extension binary missing"), "got {hint}");
2884 }
2885
2886 #[test]
2887 fn hint_recognises_os_error_2_format() {
2888 let hint = compute_extension_load_hint(
2891 "spawn failed (os error 2)",
2892 std::path::Path::new("/p"),
2893 Some("setup.sh"),
2894 );
2895 assert!(hint.contains("Extension binary missing"), "got {hint}");
2896 }
2897
2898 fn write_installed_plugin(plugins_root: &std::path::Path, dir_name: &str) {
2903 let plugin_dir = plugins_root.join(dir_name).join(".synaps-plugin");
2904 std::fs::create_dir_all(&plugin_dir).unwrap();
2905 let manifest = serde_json::json!({
2906 "extension": {
2907 "runtime": "process",
2908 "command": "/bin/false",
2909 "permissions": ["tools.register"],
2910 "deferred": {
2911 "tools": [{
2912 "name": "memory_fetch",
2913 "description": "Fetch one memory by exact ID.",
2914 "input_schema": {
2915 "type": "object",
2916 "properties": {"id": {"type": "string"}},
2917 "required": ["id"]
2918 }
2919 }]
2920 }
2921 }
2922 });
2923 std::fs::write(
2924 plugin_dir.join("plugin.json"),
2925 serde_json::to_string_pretty(&manifest).unwrap(),
2926 )
2927 .unwrap();
2928 }
2929
2930 #[tokio::test]
2940 #[serial_test::serial(synaps_base_dir)]
2941 async fn discover_and_load_skips_backup_and_disabled_plugin_directories() {
2942 let base = crate::test_env::BaseDirGuard::new();
2943 let _no_project_plugins =
2944 crate::test_env::EnvVarGuard::set("SYNAPS_DISABLE_PROJECT_PLUGINS", "1");
2945 let plugins_root = base.path().join("plugins");
2946
2947 write_installed_plugin(&plugins_root, "axel-memory-manager");
2948 for inert in [
2951 "axel-memory-manager.backup.20260719210053",
2952 "axel-memory-manager.backup.20260720101416",
2953 "axel-memory-manager.update-backup",
2954 "axel-memory-manager.backup",
2955 "axel-memory-manager.bak",
2956 "axel-memory-manager.disabled",
2957 "axel-memory-manager.old",
2958 "axel-memory-manager.orig",
2959 "axel-memory-manager.tmp",
2960 ] {
2961 write_installed_plugin(&plugins_root, inert);
2962 }
2963 write_installed_plugin(&plugins_root, "com.example.notes");
2965
2966 let registry = Arc::new(tokio::sync::RwLock::new(
2967 crate::ToolRegistry::without_subagent(),
2968 ));
2969 let mut mgr = ExtensionManager::new_with_tools(Arc::new(HookBus::new()), registry.clone());
2970 mgr.set_progressive_deferral(true);
2971
2972 let (mut loaded, failed) = mgr.discover_and_load().await;
2973 loaded.sort();
2974
2975 assert!(failed.is_empty(), "no failures expected: {failed:?}");
2976 assert_eq!(
2977 loaded,
2978 vec![
2979 "axel-memory-manager".to_string(),
2980 "com.example.notes".to_string()
2981 ],
2982 "backup/disabled/temp plugin directories must not be discovered"
2983 );
2984
2985 let reg = registry.read().await;
2986 assert!(reg.get("axel-memory-manager:memory_fetch").is_some());
2987 assert!(reg.get("com.example.notes:memory_fetch").is_some());
2988 assert!(
2989 reg.get("axel-memory-manager.backup.20260719210053:memory_fetch")
2990 .is_none(),
2991 "backup plugin tools must never be cataloged"
2992 );
2993 assert!(reg
2994 .get("axel-memory-manager.backup.20260720101416:memory_fetch")
2995 .is_none());
2996 drop(reg);
2997
2998 assert_eq!(mgr.count(), 0, "discovery must spawn nothing");
3001 }
3002
3003 fn write_provider_only_plugin(
3008 plugins_root: &std::path::Path,
3009 dir_name: &str,
3010 command: &str,
3011 args: Vec<String>,
3012 ) {
3013 let plugin_dir = plugins_root.join(dir_name).join(".synaps-plugin");
3014 std::fs::create_dir_all(&plugin_dir).unwrap();
3015 let manifest = serde_json::json!({
3016 "extension": {
3017 "runtime": "process",
3018 "command": command,
3019 "args": args,
3020 "permissions": ["context_providers.register"],
3021 "deferred": {"context_providers": [{"id": "recall"}]}
3022 }
3023 });
3024 std::fs::write(
3025 plugin_dir.join("plugin.json"),
3026 serde_json::to_string_pretty(&manifest).unwrap(),
3027 )
3028 .unwrap();
3029 }
3030
3031 #[derive(Clone, Default)]
3032 struct LogCapture(Arc<std::sync::Mutex<Vec<u8>>>);
3033 struct LogWriter(Arc<std::sync::Mutex<Vec<u8>>>);
3034 impl std::io::Write for LogWriter {
3035 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
3036 self.0.lock().unwrap().extend_from_slice(buf);
3037 Ok(buf.len())
3038 }
3039 fn flush(&mut self) -> std::io::Result<()> {
3040 Ok(())
3041 }
3042 }
3043 impl LogCapture {
3044 fn subscriber(&self) -> impl tracing::Subscriber + Send + Sync {
3045 let sink = self.0.clone();
3046 tracing_subscriber::fmt()
3047 .with_max_level(tracing::Level::WARN)
3048 .with_writer(move || LogWriter(sink.clone()))
3049 .with_ansi(false)
3050 .finish()
3051 }
3052 fn text(&self) -> String {
3053 String::from_utf8(self.0.lock().unwrap().clone()).unwrap()
3054 }
3055 }
3056
3057 #[tokio::test]
3062 #[serial_test::serial(synaps_base_dir)]
3063 async fn a_provider_only_manifest_loads_eagerly_and_logs_the_ignored_declaration() {
3064 let base = crate::test_env::BaseDirGuard::new();
3065 let _no_project_plugins =
3066 crate::test_env::EnvVarGuard::set("SYNAPS_DISABLE_PROJECT_PLUGINS", "1");
3067 let plugins_root = base.path().join("plugins");
3068 let spy = base.path().join("spy.log");
3069 let tools_json = base.path().join("tools.json");
3070 std::fs::write(&tools_json, "[]").unwrap();
3071 let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
3072 .join("tests/fixtures/extension_fixture.py");
3073 write_provider_only_plugin(
3074 &plugins_root,
3075 "provider-only",
3076 "python3",
3077 vec![
3078 fixture.display().to_string(),
3079 spy.display().to_string(),
3080 tools_json.display().to_string(),
3081 "ok".to_string(),
3082 ],
3083 );
3084
3085 let registry = Arc::new(tokio::sync::RwLock::new(
3086 crate::ToolRegistry::without_subagent(),
3087 ));
3088 let mut mgr = ExtensionManager::new_with_tools(Arc::new(HookBus::new()), registry);
3089 mgr.set_progressive_deferral(true);
3090 let log = LogCapture::default();
3091 let (loaded, failed) = {
3092 let _guard = tracing::subscriber::set_default(log.subscriber());
3093 mgr.discover_and_load().await
3094 };
3095
3096 assert!(failed.is_empty(), "no failures expected: {failed:?}");
3097 assert_eq!(loaded, vec!["provider-only".to_string()]);
3098 assert!(!mgr.is_deferred("provider-only"), "not dormant");
3099 assert!(mgr.is_loaded("provider-only"));
3100 assert_eq!(mgr.count(), 1, "its process is running");
3101 let spy_log = std::fs::read_to_string(&spy).unwrap_or_default();
3102 assert!(
3103 spy_log.lines().any(|l| l == "request:initialize"),
3104 "the process was started and initialized at load: {spy_log:?}"
3105 );
3106 let text = log.text();
3107 assert_eq!(
3108 text.matches(&format!(
3109 "extension provider-only declares a context provider; built-in automatic \
3110 memory was removed in {}, the declaration is ignored",
3111 agent_core::AXEL_BACKEND_REMOVED_IN
3112 ))
3113 .count(),
3114 1,
3115 "one notice in the log: {text}"
3116 );
3117 mgr.shutdown_all().await;
3118 }
3119
3120 #[tokio::test]
3124 #[serial_test::serial(synaps_base_dir)]
3125 async fn a_provider_only_manifest_with_a_dead_command_fails_alone() {
3126 let base = crate::test_env::BaseDirGuard::new();
3127 let _no_project_plugins =
3128 crate::test_env::EnvVarGuard::set("SYNAPS_DISABLE_PROJECT_PLUGINS", "1");
3129 let plugins_root = base.path().join("plugins");
3130 write_provider_only_plugin(&plugins_root, "provider-only", "/bin/false", Vec::new());
3131 write_installed_plugin(&plugins_root, "com.example.notes");
3132
3133 let registry = Arc::new(tokio::sync::RwLock::new(
3134 crate::ToolRegistry::without_subagent(),
3135 ));
3136 let mut mgr = ExtensionManager::new_with_tools(Arc::new(HookBus::new()), registry.clone());
3137 mgr.set_progressive_deferral(true);
3138 let (loaded, failed) = mgr.discover_and_load().await;
3139
3140 assert_eq!(loaded, vec!["com.example.notes".to_string()]);
3141 assert_eq!(failed.len(), 1, "{failed:?}");
3142 assert_eq!(failed[0].plugin, "provider-only");
3143 assert!(!mgr.is_loaded("provider-only"));
3144 assert!(!mgr.is_deferred("provider-only"));
3145 assert!(registry
3146 .read()
3147 .await
3148 .get("com.example.notes:memory_fetch")
3149 .is_some());
3150 }
3151
3152 #[test]
3156 fn inert_plugin_dir_name_filter_is_exact() {
3157 for inert in [
3158 "axel-memory-manager.backup.20260720101416",
3159 "axel-memory-manager.BACKUP.1",
3160 "axel-memory-manager.backup",
3161 "axel-memory-manager.update-backup",
3162 "axel-memory-manager.bak",
3163 "axel-memory-manager.disabled",
3164 "axel-memory-manager.old",
3165 "axel-memory-manager.orig",
3166 "axel-memory-manager.tmp",
3167 "axel-memory-manager.temp",
3168 ] {
3169 assert!(
3170 is_inert_plugin_dir_name(inert),
3171 "{inert} should be excluded from discovery"
3172 );
3173 }
3174 for live in [
3175 "axel-memory-manager",
3176 "com.example.notes",
3177 "backup-manager",
3178 "old-school-tools",
3179 "tmpfs-inspector",
3180 "my.backups.viewer",
3181 ] {
3182 assert!(
3183 !is_inert_plugin_dir_name(live),
3184 "{live} is a legitimate plugin name and must stay discoverable"
3185 );
3186 }
3187 }
3188}
3189
3190#[cfg(test)]
3191mod secret_env_allowlist_tests {
3192 use super::*;
3193
3194 fn entry(key: &str, secret_env: &str) -> ExtensionConfigEntry {
3195 serde_json::from_value(serde_json::json!({
3196 "key": key,
3197 "type": "string",
3198 "secret_env": secret_env,
3199 }))
3200 .unwrap()
3201 }
3202
3203 #[test]
3206 fn host_provider_credentials_are_rejected() {
3207 for var in [
3208 "ANTHROPIC_API_KEY",
3209 "OPENAI_API_KEY",
3210 "AWS_SECRET_ACCESS_KEY",
3211 ] {
3212 let err = ExtensionManager::resolve_config("evil", &[entry("k", var)], None)
3213 .expect_err(&format!("{var} must be rejected"));
3214 assert!(err.contains("host credential"), "unexpected error: {err}");
3215 }
3216 let entries = [entry("k", " ANTHROPIC_API_KEY ")];
3218 let err = ExtensionManager::resolve_config("evil", &entries, None)
3219 .expect_err("padded host credential must be rejected");
3220 assert!(err.contains("host credential"), "unexpected error: {err}");
3221 let entries = [entry("k", "anthropic_api_key")];
3223 let out = ExtensionManager::resolve_config("evil", &entries, None)
3224 .expect("malformed name is dropped, not fatal");
3225 assert!(out.get("k").is_none(), "{out}");
3226 }
3227
3228 #[test]
3231 fn host_namespace_is_rejected_but_own_scope_is_allowed() {
3232 let err =
3233 ExtensionManager::resolve_config("evil", &[entry("k", "SYNAPS_BROKER_TOKEN")], None)
3234 .expect_err("host namespace must be rejected");
3235 assert!(err.contains("host namespace"), "unexpected error: {err}");
3236
3237 assert!(ExtensionManager::denied_secret_env(
3239 "SYNAPS_EXTENSION_MY_PLUGIN_TOKEN",
3240 "my-plugin",
3241 &["token"]
3242 )
3243 .is_none());
3244 }
3245
3246 #[test]
3248 fn third_party_secrets_are_still_allowed() {
3249 for var in ["GITHUB_TOKEN", "STRIPE_KEY", "MY_SERVICE_TOKEN"] {
3250 assert!(
3251 ExtensionManager::denied_secret_env(var, "some-ext", &[]).is_none(),
3252 "{var} should be allowed"
3253 );
3254 }
3255 }
3256
3257 #[test]
3260 fn every_registered_provider_env_var_is_denied() {
3261 let mut vars: Vec<&str> = Vec::new();
3262 for spec in agent_core::auth::static_providers::STATIC_PROVIDERS {
3263 vars.extend(spec.env_vars.iter().copied());
3264 }
3265 for spec in crate::runtime::openai::registry::providers() {
3266 vars.extend(spec.env_vars.iter().copied());
3267 }
3268 vars.extend([
3269 agent_core::auth::muse::API_KEY_ENV,
3270 "META_API_KEY",
3271 "KIMI_API_KEY",
3272 "MOONSHOT_API_KEY",
3273 "OPENAI_CODEX_ACCESS_TOKEN",
3274 "LOCAL_API_KEY",
3275 "TOGETHER_API_KEY",
3276 "CEREBRAS_API_KEY",
3277 "AWS_ACCESS_KEY_ID",
3278 ]);
3279 assert!(vars.len() > 20, "registry walk found too few vars");
3280 for var in vars {
3281 let reason = ExtensionManager::denied_secret_env(var, "some-ext", &[])
3282 .unwrap_or_else(|| panic!("{var} must be denied as secret_env"));
3283 assert!(reason.contains("host credential"), "{var}: {reason}");
3284 }
3285 }
3286
3287 fn literal_args(src: &str, pat: &str) -> Vec<String> {
3290 let mut out = Vec::new();
3291 let mut rest = src;
3292 while let Some(i) = rest.find(pat) {
3293 rest = &rest[i + pat.len()..];
3294 let arg = rest.trim_start();
3295 if let Some(lit) = arg.strip_prefix('"') {
3296 if let Some(end) = lit.find('"') {
3297 out.push(lit[..end].to_string());
3298 }
3299 }
3300 }
3301 out
3302 }
3303
3304 #[test]
3311 fn credential_env_reads_in_workspace_sources_are_denied() {
3312 fn walk(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
3313 for e in std::fs::read_dir(dir).unwrap() {
3314 let p = e.unwrap().path();
3315 if p.is_dir() {
3316 walk(&p, out);
3317 } else if p.extension().is_some_and(|x| x == "rs") {
3318 out.push(p);
3319 }
3320 }
3321 }
3322 let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
3323 let mut files = Vec::new();
3324 walk(&root.join("src"), &mut files);
3325 for e in std::fs::read_dir(root.join("crates")).unwrap() {
3326 let src = e.unwrap().path().join("src");
3327 if src.is_dir() {
3328 walk(&src, &mut files);
3329 }
3330 }
3331 for must in [
3333 "crates/agent-core/src/core/auth/muse.rs",
3334 "crates/agent-core/src/core/config.rs",
3335 "crates/agent-engine/src/runtime/google_gemini/runtime.rs",
3336 "crates/agent-engine/src/session/types.rs",
3337 "crates/agent-engine/src/daemon/mod.rs",
3338 "crates/agent-tui/src/lib.rs",
3339 "src/cmd/auth_broker.rs",
3340 "src/cmd/login.rs",
3341 ] {
3342 assert!(
3343 files.iter().any(|f| f.ends_with(must)),
3344 "scan does not cover {must}"
3345 );
3346 }
3347
3348 let mut seen = 0;
3349 for f in &files {
3350 let src = std::fs::read_to_string(f).unwrap();
3351 let mut names = literal_args(&src, "env::var(");
3352 names.extend(literal_args(&src, "env::var_os("));
3353 names.extend(literal_args(&src, "_ENV: &str ="));
3354 for name in names {
3355 let up = name.to_ascii_uppercase();
3356 let looks_secret = ["KEY", "TOKEN", "SECRET", "PASSWORD"]
3357 .iter()
3358 .any(|w| up.contains(w));
3359 if !looks_secret {
3360 continue;
3361 }
3362 seen += 1;
3363 assert!(
3364 ExtensionManager::secret_env_name(&name).is_none()
3365 || ExtensionManager::denied_secret_env(&name, "some-ext", &[]).is_some(),
3366 "{} reads credential env var {name}, which extensions may still request via secret_env",
3367 f.display()
3368 );
3369 }
3370 }
3371 assert!(
3372 seen > 3,
3373 "source scan matched almost nothing; the patterns rotted"
3374 );
3375 }
3376
3377 #[test]
3378 fn literal_scan_handles_rustfmt_split_calls() {
3379 let src = "let a = std::env::var(\n \"SPLIT_TOKEN\",\n );\nenv::var(\"ONE\")";
3382 assert_eq!(literal_args(src, "env::var("), ["SPLIT_TOKEN", "ONE"]);
3383 }
3384
3385 #[test]
3387 fn proxy_variables_are_denied() {
3388 for var in ["HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY", "NO_PROXY"] {
3389 assert!(
3390 ExtensionManager::denied_secret_env(var, "some-ext", &[]).is_some(),
3391 "{var} must be denied"
3392 );
3393 let lower = var.to_ascii_lowercase();
3397 assert!(
3398 ExtensionManager::secret_env_name(&lower).is_none(),
3399 "{lower} must never be read"
3400 );
3401 }
3402 }
3403
3404 #[test]
3407 fn own_scope_is_limited_to_declared_keys() {
3408 assert!(ExtensionManager::denied_secret_env(
3409 "SYNAPS_EXTENSION_FOO_BAR_TOKEN",
3410 "foo",
3411 &["api_key"]
3412 )
3413 .is_some());
3414 assert!(ExtensionManager::denied_secret_env(
3415 "SYNAPS_EXTENSION_FOO_API_KEY",
3416 "foo",
3417 &["api_key"]
3418 )
3419 .is_none());
3420 assert!(ExtensionManager::denied_secret_env(
3421 "SYNAPS_EXTENSION_FOO_BAR_API_KEY",
3422 "foo-bar",
3423 &["api-key"]
3424 )
3425 .is_none());
3426 let err = ExtensionManager::resolve_config(
3428 "foo",
3429 &[entry("k", "SYNAPS_EXTENSION_FOO_BAR_TOKEN")],
3430 None,
3431 )
3432 .expect_err("cross-extension override must be rejected");
3433 assert!(err.contains("host namespace"), "{err}");
3434 }
3435
3436 #[test]
3437 fn ids_differing_only_in_separators_collide() {
3438 assert_eq!(
3439 ExtensionManager::folded_id_collision("foo_bar", ["foo-bar", "x"].into_iter()),
3440 Some("foo-bar")
3441 );
3442 assert_eq!(
3443 ExtensionManager::folded_id_collision("Foo-Bar", ["foo-bar"].into_iter()),
3444 Some("foo-bar")
3445 );
3446 assert_eq!(
3448 ExtensionManager::folded_id_collision("foo-bar", ["foo-bar", "foo"].into_iter()),
3449 None
3450 );
3451 }
3452
3453 #[test]
3454 fn secret_env_name_format() {
3455 for bad in [
3456 "",
3457 " ",
3458 "lower_case",
3459 "HAS SPACE",
3460 "A=B",
3461 "A-B",
3462 "1ABC",
3463 "A\0B",
3464 "ÄPI_KEY",
3465 ] {
3466 assert_eq!(ExtensionManager::secret_env_name(bad), None, "{bad:?}");
3467 }
3468 for good in ["GITHUB_TOKEN", "_X", "A1"] {
3469 assert_eq!(ExtensionManager::secret_env_name(good), Some(good));
3470 assert!(ExtensionManager::denied_secret_env(good, "some-ext", &[]).is_none());
3471 }
3472 assert_eq!(
3474 ExtensionManager::secret_env_name(" PADDED "),
3475 Some("PADDED")
3476 );
3477 }
3478
3479 #[test]
3483 fn malformed_secret_env_is_never_read_even_when_set() {
3484 let entry: ExtensionConfigEntry = serde_json::from_value(serde_json::json!({
3485 "key": "k",
3486 "secret_env": "lower_token",
3487 "default": "fallback",
3488 }))
3489 .unwrap();
3490 let asked = std::sync::Mutex::new(Vec::<String>::new());
3491 let env = |name: &str| {
3492 asked.lock().unwrap().push(name.to_string());
3493 (name == "lower_token").then(|| "LEAK".to_string())
3494 };
3495 let out = ExtensionManager::resolve_config_with_env("ext", &[entry], None, &env)
3496 .expect("malformed secret_env must not fail the load");
3497 assert_eq!(out["k"], "fallback", "{out}");
3498 assert!(
3499 !asked.lock().unwrap().iter().any(|n| n == "lower_token"),
3500 "malformed name was looked up: {:?}",
3501 asked.lock().unwrap()
3502 );
3503
3504 let good: ExtensionConfigEntry = serde_json::from_value(serde_json::json!({
3506 "key": "k",
3507 "secret_env": " GOOD_TOKEN ",
3508 "default": "fallback",
3509 }))
3510 .unwrap();
3511 let env = |name: &str| (name == "GOOD_TOKEN").then(|| "value".to_string());
3512 let out = ExtensionManager::resolve_config_with_env("ext", &[good], None, &env).unwrap();
3513 assert_eq!(out["k"], "value");
3514 }
3515
3516 #[test]
3520 fn malformed_secret_env_is_dropped_not_fatal() {
3521 let with_default: ExtensionConfigEntry = serde_json::from_value(serde_json::json!({
3522 "key": "k",
3523 "secret_env": "my-token",
3524 "default": "fallback",
3525 }))
3526 .unwrap();
3527 let out = ExtensionManager::resolve_config("ext", &[with_default], None)
3528 .expect("malformed secret_env must not fail the load");
3529 assert_eq!(out["k"], "fallback");
3530
3531 let required: ExtensionConfigEntry = serde_json::from_value(serde_json::json!({
3532 "key": "k",
3533 "secret_env": "lower_case",
3534 "required": true,
3535 }))
3536 .unwrap();
3537 let err = ExtensionManager::resolve_config("ext", &[required], None)
3538 .expect_err("required entry with no value still fails");
3539 assert!(err.contains("missing required config"), "{err}");
3540 assert!(
3541 !err.contains("lower_case"),
3542 "dropped name is not suggested: {err}"
3543 );
3544 }
3545}