Skip to main content

synaps_cli/extensions/
manager.rs

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