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