1use std::collections::HashMap;
4use std::path::PathBuf;
5use std::sync::Arc;
6
7use super::config::{diagnose_extension_config, ExtensionConfigDiagnostics};
8use super::info::PluginInfo;
9use super::hooks::HookBus;
10use super::manifest::{ExtensionConfigEntry, ExtensionManifest};
11use super::providers::{ProviderRegistry, RegisteredProvider, RegisteredProviderSummary};
12use super::runtime::{ExtensionHandler, ExtensionHealth};
13use super::runtime::process::ProcessExtension;
14use super::capability::{ExtensionCapabilitySnapshot, FutureCapabilityEntry, HookCapabilityEntry, ToolCapabilityEntry};
15use serde_json::{Map, Value};
16
17fn project_plugins_disabled() -> bool {
18 std::env::var("SYNAPS_DISABLE_PROJECT_PLUGINS")
19 .map(|value| {
20 let normalized = value.trim().to_ascii_lowercase();
21 matches!(normalized.as_str(), "1" | "true" | "yes" | "on")
22 })
23 .unwrap_or(false)
24}
25
26
27fn installed_plugin_setup_failure_in(
28 state: &crate::skills::state::PluginsState,
29 plugin_name: &str,
30) -> Option<String> {
31 let plugin = state.installed.iter().find(|p| p.name == plugin_name)?;
32 match &plugin.setup_status {
33 crate::skills::state::SetupStatus::Failed { message, .. } => Some(message.clone()),
34 _ => None,
35 }
36}
37
38fn sanitize_hint_fragment(input: &str) -> String {
39 input
40 .chars()
41 .map(|ch| if ch.is_control() { '?' } else { ch })
42 .collect::<String>()
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct ExtensionLoadFailure {
48 pub plugin: String,
49 pub manifest_path: Option<PathBuf>,
50 pub reason: String,
51 pub hint: String,
52}
53
54impl ExtensionLoadFailure {
55 fn new(
56 plugin: impl Into<String>,
57 manifest_path: Option<PathBuf>,
58 reason: impl Into<String>,
59 hint: impl Into<String>,
60 ) -> Self {
61 Self {
62 plugin: plugin.into(),
63 manifest_path,
64 reason: reason.into(),
65 hint: hint.into(),
66 }
67 }
68
69 pub fn concise_message(&self) -> String {
70 match &self.manifest_path {
71 Some(path) => format!(
72 "{} (manifest: {}; hint: {})",
73 self.reason,
74 path.display(),
75 self.hint
76 ),
77 None => format!("{} (hint: {})", self.reason, self.hint),
78 }
79 }
80}
81
82#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct ExtensionStatus {
85 pub id: String,
86 pub health: ExtensionHealth,
87 pub restart_count: usize,
88}
89
90pub fn compute_extension_load_hint(
105 error: &str,
106 plugin_dir: &std::path::Path,
107 declared_setup: Option<&str>,
108) -> String {
109 let missing_binary =
110 error.contains("No such file or directory") || error.contains("os error 2");
111 match (missing_binary, declared_setup) {
112 (true, Some(setup)) => format!(
113 "Extension binary missing — this plugin ships source only. Run the setup script from the plugin directory, then reload. plugin_dir={}, setup={}",
114 sanitize_hint_fragment(&plugin_dir.display().to_string()),
115 sanitize_hint_fragment(setup),
116 ),
117 _ => "Run `plugin validate <plugin-dir>` and confirm the extension command is installed"
118 .to_string(),
119 }
120}
121
122pub struct ExtensionManager {
124 hook_bus: Arc<HookBus>,
126 tools: Option<Arc<tokio::sync::RwLock<crate::ToolRegistry>>>,
128 providers: ProviderRegistry,
130 extensions: HashMap<String, Arc<dyn ExtensionHandler>>,
132 manifest_configs: HashMap<String, Vec<ExtensionConfigEntry>>,
135 capabilities: HashMap<String, Vec<crate::extensions::runtime::process::CapabilityDeclaration>>,
139 plugin_info: HashMap<String, PluginInfo>,
141 manifest_theme_tokens: HashMap<String, std::collections::BTreeMap<String, String>>,
146}
147
148impl ExtensionManager {
149 pub fn new(hook_bus: Arc<HookBus>) -> Self {
151 Self {
152 hook_bus,
153 tools: None,
154 providers: ProviderRegistry::new(),
155 extensions: HashMap::new(),
156 manifest_configs: HashMap::new(),
157 capabilities: HashMap::new(),
158 plugin_info: HashMap::new(),
159 manifest_theme_tokens: HashMap::new(),
160 }
161 }
162
163 pub fn new_with_tools(
165 hook_bus: Arc<HookBus>,
166 tools: Arc<tokio::sync::RwLock<crate::ToolRegistry>>,
167 ) -> Self {
168 Self {
169 hook_bus,
170 tools: Some(tools),
171 providers: ProviderRegistry::new(),
172 extensions: HashMap::new(),
173 manifest_configs: HashMap::new(),
174 capabilities: HashMap::new(),
175 plugin_info: HashMap::new(),
176 manifest_theme_tokens: HashMap::new(),
177 }
178 }
179
180 pub async fn load(
182 &mut self,
183 id: &str,
184 manifest: &ExtensionManifest,
185 ) -> Result<(), String> {
186 self.load_with_cwd(id, manifest, None).await
187 }
188
189 pub async fn load_with_cwd(
191 &mut self,
192 id: &str,
193 manifest: &ExtensionManifest,
194 cwd: Option<std::path::PathBuf>,
195 ) -> Result<(), String> {
196 let config = Self::resolve_config(id, &manifest.config)?;
197 self.load_with_cwd_and_config(id, manifest, cwd, config).await
198 }
199
200 async fn load_with_cwd_and_config(
201 &mut self,
202 id: &str,
203 manifest: &ExtensionManifest,
204 cwd: Option<std::path::PathBuf>,
205 config: Value,
206 ) -> Result<(), String> {
207 if self.extensions.contains_key(id) {
209 return Err(format!("Extension '{}' is already loaded", id));
210 }
211
212 let validated = manifest.validate(id)?;
216 let permissions = validated.permissions;
217 let subscriptions = validated.subscriptions;
218
219 let process = ProcessExtension::spawn_with_cwd(id, &manifest.command, &manifest.args, cwd.clone()).await?;
221 process.set_permissions(permissions.clone()).await;
224 let capabilities = match process.initialize(cwd.clone(), config.clone()).await {
225 Ok(capabilities) => capabilities,
226 Err(error) => {
227 process.shutdown().await;
228 return Err(error);
229 }
230 };
231 let registered_tools = capabilities.tools;
232 let registered_providers = capabilities.providers;
233 let capability_declarations = capabilities.capabilities;
234 let should_probe_info = !registered_tools.is_empty()
235 || !registered_providers.is_empty()
236 || !capability_declarations.is_empty();
237 let handler: Arc<dyn ExtensionHandler> = Arc::new(process);
238 if !registered_tools.is_empty() && !permissions.has(crate::extensions::permissions::Permission::ToolsRegister) {
239 handler.shutdown().await;
240 return Err(format!(
241 "Extension '{}' registered tools but lacks permission 'tools.register'",
242 id
243 ));
244 }
245 if !registered_providers.is_empty() && !permissions.has(crate::extensions::permissions::Permission::ProvidersRegister) {
246 handler.shutdown().await;
247 return Err(format!(
248 "Extension '{}' registered providers but lacks permission 'providers.register'",
249 id
250 ));
251 }
252 for decl in &capability_declarations {
253 if let Err(err) = crate::extensions::runtime::process::validate_capability(decl, &permissions) {
254 handler.shutdown().await;
255 return Err(format!(
256 "Extension '{}' capability '{}' invalid: {}",
257 id, decl.kind, err
258 ));
259 }
260 }
261 if !registered_providers.is_empty() {
262 let mut registered_ids = Vec::new();
263 for provider in registered_providers {
264 if let Err(error) = Self::validate_provider_config_requirements(id, &provider, &config) {
265 self.providers.unregister_plugin(id);
266 handler.shutdown().await;
267 return Err(error);
268 }
269 match self.providers.register_with_handler(id, provider, Some(handler.clone())) {
270 Ok(runtime_id) => registered_ids.push(runtime_id),
271 Err(error) => {
272 self.providers.unregister_plugin(id);
273 handler.shutdown().await;
274 return Err(error);
275 }
276 }
277 }
278 tracing::info!(extension = %id, providers = ?registered_ids, "Extension provider metadata registered");
279 for runtime_id in ®istered_ids {
281 if let Some(provider) = self.providers.get(runtime_id) {
282 let tool_use = provider.spec.models.iter().any(|m| {
283 m.capabilities
284 .get("tool_use")
285 .and_then(|v| v.as_bool())
286 .unwrap_or(false)
287 });
288 if tool_use {
289 tracing::warn!(
290 "Provider '{}' is tool-use capable: it can request Synaps tools through provider mediation. Use `/extensions trust disable {}` to block routing.",
291 runtime_id,
292 runtime_id,
293 );
294 }
295 }
296 }
297 }
298 if !registered_tools.is_empty() {
299 let Some(tools) = &self.tools else {
300 handler.shutdown().await;
301 return Err(format!(
302 "Extension '{}' registered tools but no tool registry is available",
303 id
304 ));
305 };
306 let mut registry = tools.write().await;
307 for spec in registered_tools {
308 registry.register(Arc::new(crate::tools::ExtensionTool::new(id, spec, handler.clone())));
309 }
310 }
311
312 let info = if should_probe_info {
317 match handler.get_info().await {
318 Ok(info) => Some(info),
319 Err(error) => {
320 if error.contains("method not found") || error.contains("unknown method") {
321 tracing::debug!(
322 extension = %id,
323 error = %error,
324 "Extension did not provide optional info.get metadata",
325 );
326 None
327 } else {
328 tracing::warn!(
329 extension = %id,
330 error = %error,
331 "Ignoring invalid optional info.get metadata",
332 );
333 None
334 }
335 }
336 }
337 } else {
338 None
339 };
340
341 for (kind, tool_filter, matcher) in subscriptions {
343 self.hook_bus
344 .subscribe(kind, handler.clone(), tool_filter, matcher, permissions.clone())
345 .await?;
346 }
347
348 self.extensions.insert(id.to_string(), handler);
349 self.manifest_configs
350 .insert(id.to_string(), manifest.config.clone());
351 if !manifest.theme_tokens.is_empty() {
352 self.manifest_theme_tokens
353 .insert(id.to_string(), manifest.theme_tokens.clone());
354 }
355 if !capability_declarations.is_empty() {
356 self.capabilities
357 .insert(id.to_string(), capability_declarations);
358 }
359 if let Some(info) = info {
360 self.plugin_info.insert(id.to_string(), info);
361 }
362 tracing::info!(extension = %id, hooks = manifest.hooks.len(), "Extension loaded");
363 Ok(())
364 }
365
366 fn validate_provider_config_requirements(
367 id: &str,
368 provider: &crate::extensions::runtime::process::RegisteredProviderSpec,
369 config: &Value,
370 ) -> Result<(), String> {
371 let Some(required) = provider
372 .config_schema
373 .as_ref()
374 .and_then(|schema| schema.get("required"))
375 .and_then(Value::as_array) else {
376 return Ok(());
377 };
378 for key in required {
379 let Some(key) = key.as_str() else {
380 return Err(format!(
381 "Extension '{}' provider '{}' config_schema.required must contain only strings",
382 id, provider.id,
383 ));
384 };
385 let present = config
386 .as_object()
387 .map(|map| map.contains_key(key))
388 .unwrap_or(false);
389 if !present {
390 return Err(format!(
391 "Extension '{}' provider '{}' missing required provider config '{}'",
392 id, provider.id, key,
393 ));
394 }
395 }
396 Ok(())
397 }
398
399 fn resolve_config(id: &str, entries: &[ExtensionConfigEntry]) -> Result<Value, String> {
400 let mut out = Map::new();
401 for entry in entries {
402 let key = entry.key.trim();
403 if key.is_empty() {
404 return Err(format!("Extension '{}' declares config with empty key", id));
405 }
406 if key.contains('.') || key.contains('/') || key.contains(' ') {
407 return Err(format!(
408 "Extension '{}' config key '{}' must not contain dots, slashes, or spaces",
409 id, key,
410 ));
411 }
412 let config_key = format!("extension.{}.{}", id, key);
413 if let Ok(value) = std::env::var(format!("SYNAPS_EXTENSION_{}_{}", id.replace('-', "_").to_ascii_uppercase(), key.replace('-', "_").to_ascii_uppercase())) {
414 out.insert(key.to_string(), Value::String(value));
415 continue;
416 }
417 if let Some(secret_env) = &entry.secret_env {
418 if let Ok(value) = std::env::var(secret_env) {
419 out.insert(key.to_string(), Value::String(value));
420 continue;
421 }
422 }
423 if let Some(value) = crate::extensions::config_store::read_plugin_config(id, key) {
424 out.insert(key.to_string(), Value::String(value));
425 continue;
426 }
427 if let Some(value) = crate::config::read_config_value(&config_key) {
428 out.insert(key.to_string(), Value::String(value));
429 continue;
430 }
431 if let Some(default) = &entry.default {
432 out.insert(key.to_string(), default.clone());
433 continue;
434 }
435 if entry.required {
436 let hint = if let Some(secret_env) = &entry.secret_env {
437 format!("set environment variable '{}' or config key '{}'", secret_env, config_key)
438 } else {
439 format!("set config key '{}'", config_key)
440 };
441 return Err(format!("Extension '{}' missing required config '{}': {}", id, key, hint));
442 }
443 }
444 Ok(Value::Object(out))
445 }
446
447 #[cfg(test)]
451 pub(crate) fn test_seed_capabilities(
452 &mut self,
453 id: &str,
454 decls: Vec<crate::extensions::runtime::process::CapabilityDeclaration>,
455 ) {
456 self.capabilities.insert(id.to_string(), decls);
457 }
458
459 pub async fn unload(&mut self, id: &str) -> Result<(), String> {
461 let handler = self
462 .extensions
463 .remove(id)
464 .ok_or_else(|| format!("Extension '{}' not found", id))?;
465
466 self.hook_bus.unsubscribe_all(id).await;
467 self.providers.unregister_plugin(id);
468 self.manifest_configs.remove(id);
469 self.capabilities.remove(id);
470 self.plugin_info.remove(id);
471 self.manifest_theme_tokens.remove(id);
472 handler.shutdown().await;
473
474 tracing::info!(extension = %id, "Extension unloaded");
475 Ok(())
476 }
477
478 pub async fn reload(
482 &mut self,
483 id: &str,
484 manifest: &ExtensionManifest,
485 cwd: Option<std::path::PathBuf>,
486 ) -> Result<(), String> {
487 if self.extensions.contains_key(id) {
488 self.unload(id).await?;
489 }
490 self.load_with_cwd(id, manifest, cwd).await
491 }
492
493 pub async fn shutdown_all(&mut self) {
495 let ids: Vec<String> = self.extensions.keys().cloned().collect();
496 for id in ids {
497 let _ = self.unload(&id).await;
498 }
499 }
500
501 pub fn shutdown_all_detached(manager: Arc<tokio::sync::RwLock<Self>>) -> tokio::task::JoinHandle<()> {
507 tokio::spawn(async move {
508 manager.write().await.shutdown_all().await;
509 })
510 }
511
512 pub fn list(&self) -> Vec<&str> {
514 self.extensions.keys().map(|s| s.as_str()).collect()
515 }
516
517 pub fn handlers(&self) -> Vec<(String, Arc<dyn super::runtime::ExtensionHandler>)> {
521 let mut out: Vec<_> = self
522 .extensions
523 .iter()
524 .map(|(id, h)| (id.clone(), Arc::clone(h)))
525 .collect();
526 out.sort_by(|a, b| a.0.cmp(&b.0));
527 out
528 }
529
530 pub fn count(&self) -> usize {
532 self.extensions.len()
533 }
534
535 pub async fn statuses(&self) -> Vec<ExtensionStatus> {
537 let mut handlers: Vec<(String, Arc<dyn ExtensionHandler>)> = self
538 .extensions
539 .iter()
540 .map(|(id, handler)| (id.clone(), handler.clone()))
541 .collect();
542 handlers.sort_by(|a, b| a.0.cmp(&b.0));
543
544 let mut statuses = Vec::with_capacity(handlers.len());
545 for (id, handler) in handlers {
546 statuses.push(ExtensionStatus {
547 id,
548 health: handler.health().await,
549 restart_count: handler.restart_count().await,
550 });
551 }
552 statuses
553 }
554
555 pub fn providers(&self) -> Vec<&RegisteredProvider> {
557 self.providers.list()
558 }
559
560 pub fn provider(&self, runtime_id: &str) -> Option<&RegisteredProvider> {
562 self.providers.get(runtime_id)
563 }
564
565 pub fn plugin_info(&self, id: &str) -> Option<&PluginInfo> {
567 self.plugin_info.get(id)
568 }
569
570 pub fn theme_tokens(&self) -> Vec<(String, std::collections::BTreeMap<String, String>)> {
575 let mut out: Vec<_> = self
576 .manifest_theme_tokens
577 .iter()
578 .map(|(id, tokens)| (id.clone(), tokens.clone()))
579 .collect();
580 out.sort_by(|a, b| a.0.cmp(&b.0));
581 out
582 }
583
584 pub async fn sidecar_spawn_args(
589 &self,
590 id: &str,
591 ) -> Result<crate::sidecar::spawn::SidecarSpawnArgs, String> {
592 let handler = self
593 .extensions
594 .get(id)
595 .ok_or_else(|| format!("unknown extension '{}'", id))?
596 .clone();
597 handler.sidecar_spawn_args().await
598 }
599
600 pub async fn invoke_command(
604 &self,
605 id: &str,
606 command: &str,
607 args: Vec<String>,
608 request_id: &str,
609 sink: tokio::sync::mpsc::UnboundedSender<crate::extensions::runtime::InvokeCommandEvent>,
610 ) -> Result<serde_json::Value, String> {
611 let handler = self
612 .extensions
613 .get(id)
614 .ok_or_else(|| format!("unknown extension '{}'", id))?
615 .clone();
616 handler.invoke_command(command, args, request_id, sink).await
617 }
618
619 pub async fn settings_editor_open(
620 &self,
621 id: &str,
622 category: &str,
623 field: &str,
624 ) -> Result<serde_json::Value, String> {
625 let handler = self
626 .extensions
627 .get(id)
628 .ok_or_else(|| format!("unknown extension '{}'", id))?
629 .clone();
630 handler.settings_editor_open(category, field).await
631 }
632
633 pub async fn settings_editor_key(
634 &self,
635 id: &str,
636 category: &str,
637 field: &str,
638 key: &str,
639 ) -> Result<serde_json::Value, String> {
640 let handler = self
641 .extensions
642 .get(id)
643 .ok_or_else(|| format!("unknown extension '{}'", id))?
644 .clone();
645 handler.settings_editor_key(category, field, key).await
646 }
647
648 pub async fn settings_editor_commit(
649 &self,
650 id: &str,
651 category: &str,
652 field: &str,
653 value: serde_json::Value,
654 ) -> Result<serde_json::Value, String> {
655 let handler = self
656 .extensions
657 .get(id)
658 .ok_or_else(|| format!("unknown extension '{}'", id))?
659 .clone();
660 handler.settings_editor_commit(category, field, value).await
661 }
662
663 pub fn plugin_infos(&self) -> Vec<(&str, &PluginInfo)> {
665 let mut entries: Vec<_> = self
666 .plugin_info
667 .iter()
668 .map(|(id, info)| (id.as_str(), info))
669 .collect();
670 entries.sort_by(|a, b| a.0.cmp(b.0));
671 entries
672 }
673
674 pub fn provider_summaries(&self) -> Vec<RegisteredProviderSummary> {
676 self.providers.summaries()
677 }
678
679 pub async fn capability_snapshots(&self) -> Vec<ExtensionCapabilitySnapshot> {
685 let mut handlers: Vec<(String, Arc<dyn ExtensionHandler>)> = self
686 .extensions
687 .iter()
688 .map(|(id, handler)| (id.clone(), handler.clone()))
689 .collect();
690 handlers.sort_by(|a, b| a.0.cmp(&b.0));
691
692 let provider_summaries = self.providers.summaries();
693 let plugin_id_lookup: std::collections::HashMap<String, String> = self
694 .providers
695 .list()
696 .into_iter()
697 .map(|p| (p.runtime_id.clone(), p.plugin_id.clone()))
698 .collect();
699
700 let mut out = Vec::with_capacity(handlers.len());
701 for (id, handler) in handlers {
702 let health = handler.health().await;
703 let restart_count = handler.restart_count().await;
704
705 let hook_pairs = self.hook_bus.subscriptions_for(&id).await;
706 let hooks: Vec<HookCapabilityEntry> = hook_pairs
707 .into_iter()
708 .map(|(kind, tool_filter)| HookCapabilityEntry {
709 kind: kind.as_str().to_string(),
710 tool_filter,
711 })
712 .collect();
713
714 let tools: Vec<ToolCapabilityEntry> = if let Some(tools) = &self.tools {
715 let registry = tools.read().await;
716 registry
717 .tool_names_for_extension(&id)
718 .into_iter()
719 .map(|name| ToolCapabilityEntry { name })
720 .collect()
721 } else {
722 Vec::new()
723 };
724
725 let providers: Vec<RegisteredProviderSummary> = provider_summaries
726 .iter()
727 .filter(|summary| {
728 plugin_id_lookup
729 .get(&summary.runtime_id)
730 .map(|p| p == &id)
731 .unwrap_or(false)
732 })
733 .cloned()
734 .collect();
735
736 let future: Vec<FutureCapabilityEntry> = self
737 .capabilities
738 .get(&id)
739 .map(|decls| {
740 decls
741 .iter()
742 .map(|d| FutureCapabilityEntry {
743 kind: d.kind.clone(),
744 name: d.name.clone(),
745 })
746 .collect()
747 })
748 .unwrap_or_default();
749
750 out.push(ExtensionCapabilitySnapshot {
751 id,
752 health,
753 restart_count,
754 hooks,
755 tools,
756 providers,
757 future,
758 });
759 }
760 out
761 }
762
763 pub fn provider_tool_use_runtime_ids(&self) -> Vec<String> {
766 let mut ids: Vec<String> = self
767 .providers
768 .list()
769 .into_iter()
770 .filter(|p| {
771 p.spec.models.iter().any(|m| {
772 m.capabilities
773 .get("tool_use")
774 .and_then(|v| v.as_bool())
775 .unwrap_or(false)
776 })
777 })
778 .map(|p| p.runtime_id.clone())
779 .collect();
780 ids.sort();
781 ids
782 }
783
784 pub fn provider_trust_view(&self) -> std::collections::BTreeMap<String, bool> {
790 let trust = match crate::extensions::trust::load_trust_state() {
791 Ok(t) => t,
792 Err(e) => {
793 tracing::warn!("trust.json corrupt or unreadable, failing closed (all providers disabled): {e}");
794 return self.providers
796 .list()
797 .into_iter()
798 .map(|p| (p.runtime_id.clone(), false))
799 .collect();
800 }
801 };
802 self.providers
803 .list()
804 .into_iter()
805 .map(|p| {
806 let enabled =
807 crate::extensions::trust::is_provider_enabled(&trust, &p.runtime_id);
808 (p.runtime_id.clone(), enabled)
809 })
810 .collect()
811 }
812
813 pub fn config_diagnostics(&self, id: &str) -> Option<ExtensionConfigDiagnostics> {
816 let manifest_config = self.manifest_configs.get(id)?;
817
818 let mut provider_required: Vec<(String, Vec<String>)> = Vec::new();
820 for provider in self.providers.list() {
821 if provider.plugin_id != id {
822 continue;
823 }
824 let required: Vec<String> = provider
825 .spec
826 .config_schema
827 .as_ref()
828 .and_then(|schema| schema.get("required"))
829 .and_then(Value::as_array)
830 .map(|arr| {
831 arr.iter()
832 .filter_map(|v| v.as_str().map(|s| s.to_string()))
833 .collect()
834 })
835 .unwrap_or_default();
836 provider_required.push((provider.provider_id.clone(), required));
837 }
838 provider_required.sort_by(|a, b| a.0.cmp(&b.0));
839
840 let env_lookup = |name: &str| std::env::var(name).ok();
841 let plugin_config_lookup = |key: &str| crate::extensions::config_store::read_plugin_config(id, key);
842 let legacy_config_lookup = |key: &str| crate::config::read_config_value(key);
843
844 Some(diagnose_extension_config(
845 id,
846 manifest_config,
847 &provider_required,
848 &env_lookup,
849 &plugin_config_lookup,
850 &legacy_config_lookup,
851 ))
852 }
853
854 pub fn all_config_diagnostics(&self) -> Vec<ExtensionConfigDiagnostics> {
856 let mut ids: Vec<&String> = self.manifest_configs.keys().collect();
857 ids.sort();
858 ids.into_iter()
859 .filter_map(|id| self.config_diagnostics(id))
860 .collect()
861 }
862
863 pub fn hook_bus(&self) -> &Arc<HookBus> {
865 &self.hook_bus
866 }
867
868 pub fn tools_shared(&self) -> Option<Arc<tokio::sync::RwLock<crate::ToolRegistry>>> {
870 self.tools.clone()
871 }
872
873 pub async fn discover_and_load(&mut self) -> (Vec<String>, Vec<ExtensionLoadFailure>) {
880 self.discover_and_load_with_progress(|_| {}).await
881 }
882
883 pub async fn discover_and_load_with_progress<F>(&mut self, mut progress: F) -> (Vec<String>, Vec<ExtensionLoadFailure>)
887 where
888 F: FnMut(crate::extensions::loader::ExtensionLoaderEvent),
889 {
890 let mut plugin_roots = vec![crate::config::base_dir().join("plugins")];
891 if !project_plugins_disabled() {
892 if let Ok(cwd) = std::env::current_dir() {
893 let project_plugins = cwd.join(".synaps").join("plugins");
894 if project_plugins != plugin_roots[0] {
895 plugin_roots.push(project_plugins);
896 }
897 }
898 }
899
900 let mut plugin_dirs: HashMap<String, PathBuf> = HashMap::new();
901 let mut failed: Vec<ExtensionLoadFailure> = Vec::new();
902
903 for plugins_dir in plugin_roots {
904 if !plugins_dir.exists() {
905 continue;
906 }
907
908 let entries = match std::fs::read_dir(&plugins_dir) {
909 Ok(e) => e,
910 Err(e) => {
911 tracing::warn!(path = %plugins_dir.display(), error = %e, "Failed to read plugins directory");
912 failed.push(ExtensionLoadFailure::new(
913 "plugins",
914 Some(plugins_dir.clone()),
915 format!("Failed to read plugins directory: {e}"),
916 "Check directory permissions and retry",
917 ));
918 continue;
919 }
920 };
921
922 for entry in entries.flatten() {
923 let plugin_name = entry.file_name().to_string_lossy().to_string();
924 plugin_dirs.insert(plugin_name, entry.path());
925 }
926 }
927
928 let mut plugin_dirs: Vec<(String, PathBuf)> = plugin_dirs.into_iter().collect();
929 plugin_dirs.sort_by(|a, b| a.0.cmp(&b.0));
930
931 let mut loaded = Vec::new();
932 let disabled_plugins = crate::config::load_config().disabled_plugins;
933 let plugins_state = {
936 let state_path = crate::skills::state::PluginsState::default_path();
937 crate::skills::state::PluginsState::load_from(&state_path).unwrap_or_default()
938 };
939 for (plugin_name, plugin_dir) in plugin_dirs {
940 if disabled_plugins.iter().any(|d| d == &plugin_name) {
941 tracing::debug!(plugin = %plugin_name, "Extension disabled via disabled_plugins config");
942 continue;
943 }
944 if let Some(message) = installed_plugin_setup_failure_in(&plugins_state, &plugin_name) {
945 tracing::warn!(plugin = %plugin_name, error = %message, "Skipping extension with failed post-install setup");
946 failed.push(ExtensionLoadFailure::new(
947 plugin_name,
948 None,
949 format!("Post-install setup failed: {message}"),
950 "Open /plugins, reinstall or update the plugin after fixing setup; extension load is disabled until setup succeeds",
951 ));
952 continue;
953 }
954 let manifest_path = plugin_dir.join(".synaps-plugin").join("plugin.json");
955 if !manifest_path.exists() {
956 continue;
957 }
958
959 let content = match std::fs::read_to_string(&manifest_path) {
960 Ok(c) => c,
961 Err(e) => {
962 let reason = format!("Failed to read plugin manifest: {e}");
963 tracing::warn!(plugin = %plugin_name, manifest = %manifest_path.display(), error = %e, "Failed to read plugin manifest");
964 failed.push(ExtensionLoadFailure::new(
965 plugin_name,
966 Some(manifest_path),
967 reason,
968 "Check manifest file permissions, then run `plugin validate <plugin-dir>`",
969 ));
970 continue;
971 }
972 };
973
974 let json: serde_json::Value = match serde_json::from_str(&content) {
975 Ok(v) => v,
976 Err(e) => {
977 let reason = format!("Invalid plugin manifest JSON: {e}");
978 tracing::warn!(plugin = %plugin_name, manifest = %manifest_path.display(), error = %e, "Invalid plugin manifest JSON");
979 failed.push(ExtensionLoadFailure::new(
980 plugin_name,
981 Some(manifest_path),
982 reason,
983 "Fix JSON syntax, then run `plugin validate <plugin-dir>`",
984 ));
985 continue;
986 }
987 };
988
989 let ext_value = match json.get("extension") {
990 Some(v) => v.clone(),
991 None => continue,
992 };
993
994 let ext_manifest: ExtensionManifest = match serde_json::from_value(ext_value) {
995 Ok(m) => m,
996 Err(e) => {
997 let reason = format!("Failed to parse extension manifest: {e}");
998 tracing::warn!(plugin = %plugin_name, manifest = %manifest_path.display(), error = %e, "Failed to parse extension manifest");
999 failed.push(ExtensionLoadFailure::new(
1000 plugin_name,
1001 Some(manifest_path),
1002 reason,
1003 "Check the `extension` block shape against docs/extensions/contract.json, then run `plugin validate <plugin-dir>`",
1004 ));
1005 continue;
1006 }
1007 };
1008
1009 #[allow(clippy::if_same_then_else)]
1010 let command = if std::path::Path::new(&ext_manifest.command).is_absolute() {
1011 ext_manifest.command.clone()
1012 } else if !ext_manifest.command.contains(std::path::MAIN_SEPARATOR) && !ext_manifest.command.contains('/') {
1013 ext_manifest.command.clone()
1014 } else {
1015 plugin_dir.join(&ext_manifest.command)
1016 .to_string_lossy().to_string()
1017 };
1018
1019 let args: Vec<String> = ext_manifest.args.iter().map(|arg| {
1020 let arg_path = plugin_dir.join(arg);
1021 if arg_path.exists() {
1022 if let (Ok(canonical), Ok(plugin_canonical)) = (
1023 arg_path.canonicalize(),
1024 plugin_dir.canonicalize(),
1025 ) {
1026 if canonical.starts_with(&plugin_canonical) {
1027 return canonical.to_string_lossy().to_string();
1028 }
1029 }
1030 }
1031 arg.clone()
1032 }).collect();
1033
1034 let resolved = ExtensionManifest {
1035 theme_tokens: Default::default(),
1036 command,
1037 args,
1038 ..ext_manifest
1039 };
1040
1041 match self.load_with_cwd(&plugin_name, &resolved, Some(plugin_dir.clone())).await {
1042 Ok(()) => {
1043 tracing::info!(plugin = %plugin_name, path = %plugin_dir.display(), "Extension loaded from plugins/");
1044 loaded.push(plugin_name.clone());
1045 progress(crate::extensions::loader::ExtensionLoaderEvent::Loaded {
1046 plugin: plugin_name,
1047 loaded: loaded.len(),
1048 failed: failed.len(),
1049 });
1050 }
1051 Err(e) => {
1052 tracing::warn!(plugin = %plugin_name, manifest = %manifest_path.display(), error = %e, "Failed to load extension");
1053 let setup_script = json
1054 .pointer("/extension/setup")
1055 .and_then(|v| v.as_str())
1056 .or_else(|| json.pointer("/provides/sidecar/setup").and_then(|v| v.as_str()));
1057 let hint = compute_extension_load_hint(&e, &plugin_dir, setup_script);
1058 let failure = ExtensionLoadFailure::new(
1059 plugin_name,
1060 Some(manifest_path),
1061 e,
1062 hint,
1063 );
1064 failed.push(failure.clone());
1065 progress(crate::extensions::loader::ExtensionLoaderEvent::Failed {
1066 failure,
1067 loaded: loaded.len(),
1068 failed: failed.len(),
1069 });
1070 }
1071 }
1072 }
1073
1074 (loaded, failed)
1075 }
1076}
1077
1078#[cfg(test)]
1079mod tests {
1080 use super::*;
1081
1082 #[tokio::test]
1083 async fn capability_snapshots_empty_when_no_extensions() {
1084 let bus = Arc::new(HookBus::new());
1085 let mgr = ExtensionManager::new(bus);
1086 assert!(mgr.capability_snapshots().await.is_empty());
1087 }
1088
1089 #[tokio::test]
1090 async fn capability_snapshot_lists_hooks_for_loaded_extension() {
1091 let bus = Arc::new(HookBus::new());
1092 let mut mgr = ExtensionManager::new(bus.clone());
1093 let manifest = ExtensionManifest {
1094 theme_tokens: Default::default(),
1095 protocol_version: 1,
1096 runtime: crate::extensions::manifest::ExtensionRuntime::Process,
1097 command: "python3".to_string(),
1098 setup: None,
1099 prebuilt: ::std::collections::HashMap::new(),
1100 args: vec![
1101 "tests/fixtures/process_extension.py".to_string(),
1102 "normal".to_string(),
1103 "/tmp/synaps-capability-test.log".to_string(),
1104 ],
1105 permissions: vec!["tools.intercept".to_string()],
1106 hooks: vec![crate::extensions::manifest::HookSubscription {
1107 hook: "before_tool_call".to_string(),
1108 tool: Some("bash".to_string()),
1109 matcher: None,
1110 }],
1111 config: vec![],
1112 };
1113
1114 mgr.load("cap-snap", &manifest).await.unwrap();
1115
1116 let snaps = mgr.capability_snapshots().await;
1117 assert_eq!(snaps.len(), 1);
1118 let snap = &snaps[0];
1119 assert_eq!(snap.id, "cap-snap");
1120 assert_eq!(snap.hooks.len(), 1);
1121 assert_eq!(snap.hooks[0].kind, "before_tool_call");
1122 assert_eq!(snap.hooks[0].tool_filter.as_deref(), Some("bash"));
1123 assert!(snap.tools.is_empty());
1124 assert!(snap.providers.is_empty());
1125 assert!(snap.future.is_empty());
1126
1127 mgr.shutdown_all().await;
1128 }
1129
1130 #[tokio::test]
1131 async fn capability_snapshot_surfaces_seeded_capabilities() {
1132 let bus = Arc::new(HookBus::new());
1133 let mut mgr = ExtensionManager::new(bus.clone());
1134 let manifest = ExtensionManifest {
1135 theme_tokens: Default::default(),
1136 protocol_version: 1,
1137 runtime: crate::extensions::manifest::ExtensionRuntime::Process,
1138 command: "python3".to_string(),
1139 setup: None,
1140 prebuilt: ::std::collections::HashMap::new(),
1141 args: vec![
1142 "tests/fixtures/process_extension.py".to_string(),
1143 "normal".to_string(),
1144 "/tmp/synaps-capability-snapshot-test.log".to_string(),
1145 ],
1146 permissions: vec!["tools.intercept".to_string()],
1147 hooks: vec![crate::extensions::manifest::HookSubscription {
1148 hook: "before_tool_call".to_string(),
1149 tool: Some("bash".to_string()),
1150 matcher: None,
1151 }],
1152 config: vec![],
1153 };
1154
1155 mgr.load("multi-cap", &manifest).await.unwrap();
1156
1157 mgr.test_seed_capabilities(
1161 "multi-cap",
1162 vec![
1163 crate::extensions::runtime::process::CapabilityDeclaration {
1164 kind: "capture".to_string(),
1165 name: "Local Sample STT".to_string(),
1166 permissions: vec!["audio.input".to_string()],
1167 params: serde_json::Value::Null,
1168 },
1169 crate::extensions::runtime::process::CapabilityDeclaration {
1170 kind: "ocr".to_string(),
1171 name: "Tesseract".to_string(),
1172 permissions: vec![],
1173 params: serde_json::Value::Null,
1174 },
1175 ],
1176 );
1177
1178 let snaps = mgr.capability_snapshots().await;
1179 let snap = snaps
1180 .iter()
1181 .find(|s| s.id == "multi-cap")
1182 .expect("multi-cap snapshot");
1183 assert_eq!(snap.future.len(), 2);
1184 let kinds: Vec<&str> = snap.future.iter().map(|e| e.kind.as_str()).collect();
1185 assert!(kinds.contains(&"capture"), "got kinds {:?}", kinds);
1186 assert!(kinds.contains(&"ocr"), "got kinds {:?}", kinds);
1187 let names: Vec<&str> = snap.future.iter().map(|e| e.name.as_str()).collect();
1188 assert!(names.contains(&"Local Sample STT"), "got {:?}", names);
1189 assert!(names.contains(&"Tesseract"), "got {:?}", names);
1190
1191 mgr.unload("multi-cap").await.unwrap();
1192 let snaps = mgr.capability_snapshots().await;
1193 assert!(snaps.iter().all(|s| s.id != "multi-cap"));
1194
1195 mgr.shutdown_all().await;
1196 }
1197
1198 #[tokio::test]
1199 async fn new_manager_has_no_extensions() {
1200 let bus = Arc::new(HookBus::new());
1201 let mgr = ExtensionManager::new(bus);
1202 assert_eq!(mgr.count(), 0);
1203 assert!(mgr.list().is_empty());
1204 }
1205
1206 #[tokio::test]
1207 async fn unload_nonexistent_returns_error() {
1208 let bus = Arc::new(HookBus::new());
1209 let mut mgr = ExtensionManager::new(bus);
1210 let result = mgr.unload("nope").await;
1211 assert!(result.is_err());
1212 }
1213
1214 #[tokio::test]
1215 async fn reload_unsubscribes_old_handler_before_loading_new_one() {
1216 let bus = Arc::new(HookBus::new());
1217 let mut mgr = ExtensionManager::new(bus.clone());
1218 let manifest = ExtensionManifest {
1219 theme_tokens: Default::default(),
1220 protocol_version: 1,
1221 runtime: crate::extensions::manifest::ExtensionRuntime::Process,
1222 command: "python3".to_string(),
1223 setup: None,
1224 prebuilt: ::std::collections::HashMap::new(),
1225 args: vec!["tests/fixtures/process_extension.py".to_string(), "normal".to_string(), "/tmp/synaps-reload-test.log".to_string()],
1226 permissions: vec!["tools.intercept".to_string()],
1227 hooks: vec![crate::extensions::manifest::HookSubscription {
1228 hook: "before_tool_call".to_string(),
1229 tool: Some("bash".to_string()),
1230 matcher: None,
1231 }],
1232 config: vec![],
1233 };
1234
1235 mgr.load("reload-test", &manifest).await.unwrap();
1236 assert_eq!(bus.handler_count().await, 1);
1237
1238 mgr.reload("reload-test", &manifest, None).await.unwrap();
1239
1240 assert_eq!(mgr.count(), 1);
1241 assert_eq!(bus.handler_count().await, 1);
1242 mgr.shutdown_all().await;
1243 }
1244
1245 #[tokio::test]
1246 async fn reload_failure_leaves_previous_instance_unloaded() {
1247 let bus = Arc::new(HookBus::new());
1248 let mut mgr = ExtensionManager::new(bus.clone());
1249 let good = ExtensionManifest {
1250 theme_tokens: Default::default(),
1251 protocol_version: 1,
1252 runtime: crate::extensions::manifest::ExtensionRuntime::Process,
1253 command: "python3".to_string(),
1254 setup: None,
1255 prebuilt: ::std::collections::HashMap::new(),
1256 args: vec!["tests/fixtures/process_extension.py".to_string(), "normal".to_string(), "/tmp/synaps-reload-failure-test.log".to_string()],
1257 permissions: vec!["tools.intercept".to_string()],
1258 hooks: vec![crate::extensions::manifest::HookSubscription {
1259 hook: "before_tool_call".to_string(),
1260 tool: Some("bash".to_string()),
1261 matcher: None,
1262 }],
1263 config: vec![],
1264 };
1265 let bad = ExtensionManifest {
1266 theme_tokens: Default::default(),
1267 command: "/definitely/not/a/real/extension-binary".to_string(),
1268 setup: None,
1269 prebuilt: ::std::collections::HashMap::new(),
1270 ..good.clone()
1271 };
1272
1273 mgr.load("reload-failure-test", &good).await.unwrap();
1274 let err = mgr.reload("reload-failure-test", &bad, None).await.unwrap_err();
1275
1276 assert!(err.contains("Failed to spawn extension"), "{err}");
1277 assert_eq!(mgr.count(), 0);
1278 assert_eq!(bus.handler_count().await, 0);
1279 }
1280
1281 #[test]
1282 fn project_plugins_disable_env_parser_accepts_truthy_values() {
1283 for value in ["1", "true", "TRUE", "yes", "on"] {
1284 std::env::set_var("SYNAPS_DISABLE_PROJECT_PLUGINS", value);
1285 assert!(project_plugins_disabled());
1286 }
1287 for value in ["", "0", "false", "off", "no"] {
1288 std::env::set_var("SYNAPS_DISABLE_PROJECT_PLUGINS", value);
1289 assert!(!project_plugins_disabled());
1290 }
1291 std::env::remove_var("SYNAPS_DISABLE_PROJECT_PLUGINS");
1292 }
1293
1294 fn with_temp_base_dir<T>(path: &std::path::Path, f: impl FnOnce() -> T) -> T {
1295 let old_base_dir = std::env::var("SYNAPS_BASE_DIR").ok();
1296 crate::config::set_base_dir_for_tests(path.to_path_buf());
1297 let out = f();
1298 match old_base_dir {
1299 Some(old) => std::env::set_var("SYNAPS_BASE_DIR", old),
1300 None => std::env::remove_var("SYNAPS_BASE_DIR"),
1301 }
1302 out
1303 }
1304
1305 #[test]
1306 #[serial_test::serial(synaps_base_dir)]
1307 fn resolve_config_prefers_plugin_namespaced_config_before_legacy_global_key() {
1308 let dir = tempfile::tempdir().unwrap();
1309 with_temp_base_dir(dir.path(), || {
1310 crate::extensions::config_store::write_plugin_config("sample-sidecar", "backend", "cpu")
1311 .unwrap();
1312 crate::config::write_config_value("extension.sample-sidecar.backend", "auto").unwrap();
1313
1314 let resolved = ExtensionManager::resolve_config(
1315 "sample-sidecar",
1316 &[ExtensionConfigEntry {
1317 key: "backend".to_string(),
1318 value_type: None,
1319 description: None,
1320 required: true,
1321 default: None,
1322 secret_env: None,
1323 }],
1324 )
1325 .unwrap();
1326
1327 assert_eq!(resolved["backend"], serde_json::Value::String("cpu".to_string()));
1328 });
1329 }
1330
1331 #[test]
1332 #[serial_test::serial(synaps_base_dir)]
1333 fn resolve_config_keeps_legacy_global_extension_key_as_fallback() {
1334 let dir = tempfile::tempdir().unwrap();
1335 with_temp_base_dir(dir.path(), || {
1336 crate::config::write_config_value("extension.sample-sidecar.backend", "auto").unwrap();
1337
1338 let resolved = ExtensionManager::resolve_config(
1339 "sample-sidecar",
1340 &[ExtensionConfigEntry {
1341 key: "backend".to_string(),
1342 value_type: None,
1343 description: None,
1344 required: true,
1345 default: None,
1346 secret_env: None,
1347 }],
1348 )
1349 .unwrap();
1350
1351 assert_eq!(resolved["backend"], serde_json::Value::String("auto".to_string()));
1352 });
1353 }
1354
1355 #[tokio::test]
1356 async fn config_diagnostics_returns_none_for_unknown_extension() {
1357 let bus = Arc::new(HookBus::new());
1358 let mgr = ExtensionManager::new(bus);
1359 assert!(mgr.config_diagnostics("nope").is_none());
1360 assert!(mgr.all_config_diagnostics().is_empty());
1361 }
1362
1363 #[tokio::test]
1364 async fn config_diagnostics_reports_loaded_manifest_entries() {
1365 let bus = Arc::new(HookBus::new());
1366 let mut mgr = ExtensionManager::new(bus);
1367 let manifest = ExtensionManifest {
1368 theme_tokens: Default::default(),
1369 protocol_version: 1,
1370 runtime: crate::extensions::manifest::ExtensionRuntime::Process,
1371 command: "python3".to_string(),
1372 setup: None,
1373 prebuilt: ::std::collections::HashMap::new(),
1374 args: vec![
1375 "tests/fixtures/process_extension.py".to_string(),
1376 "normal".to_string(),
1377 "/tmp/synaps-config-diag-test.log".to_string(),
1378 ],
1379 permissions: vec!["tools.intercept".to_string()],
1380 hooks: vec![crate::extensions::manifest::HookSubscription {
1381 hook: "before_tool_call".to_string(),
1382 tool: Some("bash".to_string()),
1383 matcher: None,
1384 }],
1385 config: vec![crate::extensions::manifest::ExtensionConfigEntry {
1386 key: "region".to_string(),
1387 value_type: None,
1388 description: Some("AWS region".to_string()),
1389 required: false,
1390 default: Some(serde_json::Value::String("us-east-1".to_string())),
1391 secret_env: None,
1392 }],
1393 };
1394
1395 mgr.load("config-diag-test", &manifest).await.unwrap();
1396
1397 let diag = mgr
1398 .config_diagnostics("config-diag-test")
1399 .expect("diagnostics should be available for loaded extension");
1400 assert_eq!(diag.extension_id, "config-diag-test");
1401 assert_eq!(diag.entries.len(), 1);
1402 assert_eq!(diag.entries[0].key, "region");
1403 assert!(diag.entries[0].has_value);
1404 assert!(diag.provider_missing.is_empty());
1405
1406 let all = mgr.all_config_diagnostics();
1407 assert_eq!(all.len(), 1);
1408 assert_eq!(all[0].extension_id, "config-diag-test");
1409
1410 mgr.shutdown_all().await;
1411 assert!(mgr.config_diagnostics("config-diag-test").is_none());
1413 }
1414
1415 #[tokio::test]
1416 async fn provider_trust_view_is_empty_for_no_providers() {
1417 let bus = Arc::new(HookBus::new());
1418 let mgr = ExtensionManager::new(bus);
1419 let view = mgr.provider_trust_view();
1420 assert!(view.is_empty());
1421 }
1422
1423 #[tokio::test]
1424 async fn provider_tool_use_runtime_ids_lists_only_tool_use_capable() {
1425 use crate::extensions::runtime::process::{RegisteredProviderModelSpec, RegisteredProviderSpec};
1426 let bus = Arc::new(HookBus::new());
1427 let mut mgr = ExtensionManager::new(bus);
1428 let tool_spec = RegisteredProviderSpec {
1430 id: "alpha".into(),
1431 display_name: "Alpha".into(),
1432 description: "tool-use".into(),
1433 models: vec![RegisteredProviderModelSpec {
1434 id: "m1".into(),
1435 display_name: None,
1436 capabilities: serde_json::json!({"tool_use": true}),
1437 context_window: None,
1438 }],
1439 config_schema: None,
1440 };
1441 let plain_spec = RegisteredProviderSpec {
1443 id: "beta".into(),
1444 display_name: "Beta".into(),
1445 description: "plain".into(),
1446 models: vec![RegisteredProviderModelSpec {
1447 id: "m1".into(),
1448 display_name: None,
1449 capabilities: serde_json::json!({"streaming": true}),
1450 context_window: None,
1451 }],
1452 config_schema: None,
1453 };
1454 mgr.providers.register("plug", tool_spec).unwrap();
1455 mgr.providers.register("plug", plain_spec).unwrap();
1456 let ids = mgr.provider_tool_use_runtime_ids();
1457 assert_eq!(ids, vec!["plug:alpha".to_string()]);
1458 }
1459
1460 #[test]
1463 fn hint_missing_binary_with_declared_setup_points_at_script() {
1464 let hint = compute_extension_load_hint(
1465 "Failed to spawn extension 'sample-sidecar': No such file or directory (os error 2)",
1466 std::path::Path::new("/home/u/.synaps-cli/plugins/sample-sidecar"),
1467 Some("scripts/setup.sh"),
1468 );
1469 assert!(
1470 hint.contains("Extension binary missing"),
1471 "missing-binary case should be flagged: {hint}"
1472 );
1473 assert!(
1474 hint.contains("/home/u/.synaps-cli/plugins/sample-sidecar"),
1475 "hint should include the plugin dir: {hint}"
1476 );
1477 assert!(
1478 hint.contains("setup=scripts/setup.sh"),
1479 "hint should show sanitized setup path without copy-paste shell command: {hint}"
1480 );
1481 }
1482
1483 #[test]
1484 fn hint_missing_binary_without_declared_setup_falls_back_to_generic() {
1485 let hint = compute_extension_load_hint(
1486 "Failed to spawn extension 'foo': No such file or directory (os error 2)",
1487 std::path::Path::new("/x/y"),
1488 None,
1489 );
1490 assert!(
1491 hint.contains("plugin validate"),
1492 "no setup declared → generic hint: {hint}"
1493 );
1494 assert!(
1495 !hint.contains("Extension binary missing"),
1496 "should not falsely promise a setup script: {hint}"
1497 );
1498 }
1499
1500 #[test]
1501 fn hint_other_error_with_declared_setup_falls_back_to_generic() {
1502 let hint = compute_extension_load_hint(
1503 "Extension 'foo' must subscribe to at least one hook or request a registration permission",
1504 std::path::Path::new("/x/y"),
1505 Some("scripts/setup.sh"),
1506 );
1507 assert!(hint.contains("plugin validate"), "got {hint}");
1511 assert!(!hint.contains("Extension binary missing"), "got {hint}");
1512 }
1513
1514 #[test]
1515 fn hint_recognises_os_error_2_format() {
1516 let hint = compute_extension_load_hint(
1519 "spawn failed (os error 2)",
1520 std::path::Path::new("/p"),
1521 Some("setup.sh"),
1522 );
1523 assert!(hint.contains("Extension binary missing"), "got {hint}");
1524 }
1525}