Skip to main content

agent_engine/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_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/// Actionable discovery/load failure for an installed plugin extension.
46#[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/// Snapshot of a loaded extension's runtime status.
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct ExtensionStatus {
85    pub id: String,
86    pub health: ExtensionHealth,
87    pub restart_count: usize,
88}
89
90/// Compute the hint for an extension load failure.
91///
92/// Two cases:
93/// 1. **Missing extension binary AND plugin declares
94///    `provides.sidecar.setup`** — the plugin ships source only (the
95///    binary is typically gitignored) and the setup script needs to
96///    be run. The hint points the user at the exact command. This is
97///    the common case for fresh marketplace installs of plugins that
98///    build their extension binary from source.
99/// 2. **Anything else** — the generic "run plugin validate" hint.
100///
101/// Pure function for unit-testability. Lives here (not in
102/// `ExtensionLoadFailure`) because the sidecar/setup convention is a
103/// plugin-layer concern.
104pub 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
122/// Manages the lifecycle of all loaded extensions.
123pub struct ExtensionManager {
124    /// The shared hook bus.
125    hook_bus: Arc<HookBus>,
126    /// Optional shared tool registry for extension-provided tools.
127    tools: Option<Arc<tokio::sync::RwLock<crate::ToolRegistry>>>,
128    /// Provider metadata registered by loaded extensions. Routing is not wired yet.
129    providers: ProviderRegistry,
130    /// Running extensions keyed by ID.
131    extensions: HashMap<String, Arc<dyn ExtensionHandler>>,
132    /// Declared manifest config entries per loaded extension, kept so we can
133    /// produce diagnostics without re-reading the manifest.
134    manifest_configs: HashMap<String, Vec<ExtensionConfigEntry>>,
135    /// Capability declarations per loaded extension. Each plugin may
136    /// declare zero or more capabilities (kind is plugin-defined; core
137    /// does not enumerate). Populated on load.
138    capabilities: HashMap<String, Vec<crate::extensions::runtime::process::CapabilityDeclaration>>,
139    /// Optional plugin-reported info from the `info.get` RPC.
140    plugin_info: HashMap<String, PluginInfo>,
141}
142
143impl ExtensionManager {
144    /// Create a new manager with a shared hook bus.
145    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    /// Create a new manager with shared hook bus and tool registry.
158    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    /// Load and start an extension from its manifest.
174    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    /// Load and start an extension from its manifest with a process cwd.
183    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        // Don't load duplicates
201        if self.extensions.contains_key(id) {
202            return Err(format!("Extension '{}' is already loaded", id));
203        }
204
205        // Validate permissions and hook subscriptions before spawning the
206        // extension process. This keeps malformed manifests from leaking child
207        // processes when a later subscription step fails.
208        let validated = manifest.validate(id)?;
209        let permissions = validated.permissions;
210        let subscriptions = validated.subscriptions;
211
212        // Spawn the extension process only after the manifest is known-good.
213        let process = ProcessExtension::spawn_with_cwd(id, &manifest.command, &manifest.args, cwd.clone()).await?;
214        // Publish permissions to the inbound-request dispatcher so memory.*
215        // calls during initialize can be authorized correctly.
216        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            // Warn for tool-use-capable providers so authors and users can audit them.
273            for runtime_id in &registered_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        // Do not probe optional info.get for legacy hook-only extensions. The
306        // best-effort call can race with simple fixtures that exit after
307        // shutdown/EOF and is only needed for richer extension-capability
308        // surfaces (providers/tools/plugin-defined capabilities).
309        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        // Register hook subscriptions
335        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    /// Test-only seeder: synthetically insert capability declarations
437    /// for an extension id. Used to exercise capability snapshot
438    /// rendering without spinning up a real plugin process.
439    #[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    /// Unload an extension — unsubscribe hooks and shut down the process.
449    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    /// Reload one extension by unloading any existing instance first, then loading
467    /// the supplied manifest. If the new load fails, the previous instance remains
468    /// unloaded so duplicate handlers cannot survive a broken reload.
469    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    /// Shut down all extensions gracefully.
482    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    /// Start shutting down all extensions in the background.
490    ///
491    /// This is intended for process exit: the UI should not hang waiting for
492    /// extension child processes to acknowledge shutdown. Dropping the join handle
493    /// lets Tokio abort remaining work when the runtime exits.
494    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    /// List running extension IDs.
501    pub fn list(&self) -> Vec<&str> {
502        self.extensions.keys().map(|s| s.as_str()).collect()
503    }
504
505    /// Return Arc references to all running extension handlers, sorted by ID.
506    /// Intended for background notification watchers that need to hold onto
507    /// handlers beyond the lifetime of a manager lock.
508    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    /// Number of running extensions.
519    pub fn count(&self) -> usize {
520        self.extensions.len()
521    }
522
523    /// Return health snapshots for all loaded extensions, sorted by ID.
524    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    /// Return registered provider metadata sorted by runtime id.
544    pub fn providers(&self) -> Vec<&RegisteredProvider> {
545        self.providers.list()
546    }
547
548    /// Return registered provider metadata by runtime id.
549    pub fn provider(&self, runtime_id: &str) -> Option<&RegisteredProvider> {
550        self.providers.get(runtime_id)
551    }
552
553    /// Return optional cached plugin info reported by `info.get`.
554    pub fn plugin_info(&self, id: &str) -> Option<&PluginInfo> {
555        self.plugin_info.get(id)
556    }
557
558    /// Ask a plugin for its sidecar spawn arguments. Best-effort —
559    /// plugins that don't host a sidecar (or pre-Phase-7 plugins that
560    /// haven't implemented the RPC yet) return `Err`. Callers are
561    /// expected to treat that as "no overrides; use manifest defaults".
562    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    /// Invoke an interactive plugin command on extension `id`. Streams
575    /// `command.output` (matching `request_id`) and `task.*` notifications
576    /// to `sink`. Returns the final JSON-RPC response value.
577    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    /// Return all cached plugin info sorted by extension id.
638    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    /// Return provider status summaries sorted by provider runtime id.
649    pub fn provider_summaries(&self) -> Vec<RegisteredProviderSummary> {
650        self.providers.summaries()
651    }
652
653    /// Unified capability snapshot per loaded extension, sorted by id.
654    ///
655    /// Aggregates hook subscriptions, extension-provided tools, and registered
656    /// providers. `future` carries plugin-defined capability kinds and
657    /// capabilities land.
658    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    /// Return runtime ids of registered providers that declare at least one
738    /// tool-use-capable model. Sorted by runtime id.
739    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    /// Return a `runtime_id -> enabled` map for every registered provider, computed
759    /// from the persisted trust state. Providers without an entry default to
760    /// enabled. If the trust state file is missing, all providers are reported
761    /// as enabled (default). If the file is corrupt, all providers are reported
762    /// as **disabled** (fail-closed) and a warning is logged.
763    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 all providers as disabled
769                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    /// Compute config diagnostics for a loaded extension by id.
788    /// Returns `None` if the extension is not loaded.
789    pub fn config_diagnostics(&self, id: &str) -> Option<ExtensionConfigDiagnostics> {
790        let manifest_config = self.manifest_configs.get(id)?;
791
792        // Collect provider required keys from registered providers' config_schema.
793        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    /// Diagnostics for all loaded extensions, sorted alphabetically by id.
829    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    /// Get the shared hook bus.
838    pub fn hook_bus(&self) -> &Arc<HookBus> {
839        &self.hook_bus
840    }
841
842    /// Get the shared tool registry, when this manager was constructed with one.
843    pub fn tools_shared(&self) -> Option<Arc<tokio::sync::RwLock<crate::ToolRegistry>>> {
844        self.tools.clone()
845    }
846
847    /// Discover and load all extensions from the user and project plugin directories.
848    ///
849    /// Scans `~/.synaps-cli/plugins/*/.synaps-plugin/plugin.json` and
850    /// `./.synaps/plugins/*/.synaps-plugin/plugin.json` for manifests that contain
851    /// an `extension` field. Project-local plugins override user plugins with the
852    /// same directory name.
853    pub async fn discover_and_load(&mut self) -> (Vec<String>, Vec<ExtensionLoadFailure>) {
854        self.discover_and_load_with_progress(|_| {}).await
855    }
856
857    /// Discover and load all extensions, invoking `progress` after each load
858    /// attempt. Used by the async UI loader to update startup toasts without
859    /// blocking first paint.
860    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        // Hoist plugins.json read out of the per-plugin loop — was N reads + parses
908        // of the same file (one per discovered plugin). Read it once here.
909        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        // Seed two capabilities of *different* kinds — proves the
1129        // snapshot rendering iterates a generic list and uses the
1130        // plugin-supplied `kind` rather than hardcoding any modality.
1131        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        // After shutdown, manifest config storage is cleared.
1379        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        // Tool-use capable provider.
1396        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        // Plain provider, no tool_use.
1409        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    // ---- compute_extension_load_hint --------------------------------
1428
1429    #[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        // Setup script is declared, but the error is *not* a missing
1475        // binary — running the script wouldn't help. Fall back to the
1476        // generic hint so we don't mislead the user.
1477        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        // Older / cross-platform error formats may include the kernel
1484        // errno but not the "No such file or directory" English text.
1485        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}