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    /// Return Arc references to all running extension handlers, sorted by ID.
505    /// Intended for background notification watchers that need to hold onto
506    /// handlers beyond the lifetime of a manager lock.
507    pub fn handlers(&self) -> Vec<(String, Arc<dyn super::runtime::ExtensionHandler>)> {
508        let mut out: Vec<_> = self
509            .extensions
510            .iter()
511            .map(|(id, h)| (id.clone(), Arc::clone(h)))
512            .collect();
513        out.sort_by(|a, b| a.0.cmp(&b.0));
514        out
515    }
516
517    /// Number of running extensions.
518    pub fn count(&self) -> usize {
519        self.extensions.len()
520    }
521
522    /// Return health snapshots for all loaded extensions, sorted by ID.
523    pub async fn statuses(&self) -> Vec<ExtensionStatus> {
524        let mut handlers: Vec<(String, Arc<dyn ExtensionHandler>)> = self
525            .extensions
526            .iter()
527            .map(|(id, handler)| (id.clone(), handler.clone()))
528            .collect();
529        handlers.sort_by(|a, b| a.0.cmp(&b.0));
530
531        let mut statuses = Vec::with_capacity(handlers.len());
532        for (id, handler) in handlers {
533            statuses.push(ExtensionStatus {
534                id,
535                health: handler.health().await,
536                restart_count: handler.restart_count().await,
537            });
538        }
539        statuses
540    }
541
542    /// Return registered provider metadata sorted by runtime id.
543    pub fn providers(&self) -> Vec<&RegisteredProvider> {
544        self.providers.list()
545    }
546
547    /// Return registered provider metadata by runtime id.
548    pub fn provider(&self, runtime_id: &str) -> Option<&RegisteredProvider> {
549        self.providers.get(runtime_id)
550    }
551
552    /// Return optional cached plugin info reported by `info.get`.
553    pub fn plugin_info(&self, id: &str) -> Option<&PluginInfo> {
554        self.plugin_info.get(id)
555    }
556
557    /// Ask a plugin for its sidecar spawn arguments. Best-effort —
558    /// plugins that don't host a sidecar (or pre-Phase-7 plugins that
559    /// haven't implemented the RPC yet) return `Err`. Callers are
560    /// expected to treat that as "no overrides; use manifest defaults".
561    pub async fn sidecar_spawn_args(
562        &self,
563        id: &str,
564    ) -> Result<crate::sidecar::spawn::SidecarSpawnArgs, String> {
565        let handler = self
566            .extensions
567            .get(id)
568            .ok_or_else(|| format!("unknown extension '{}'", id))?
569            .clone();
570        handler.sidecar_spawn_args().await
571    }
572
573    /// Invoke an interactive plugin command on extension `id`. Streams
574    /// `command.output` (matching `request_id`) and `task.*` notifications
575    /// to `sink`. Returns the final JSON-RPC response value.
576    pub async fn invoke_command(
577        &self,
578        id: &str,
579        command: &str,
580        args: Vec<String>,
581        request_id: &str,
582        sink: tokio::sync::mpsc::UnboundedSender<crate::extensions::runtime::InvokeCommandEvent>,
583    ) -> Result<serde_json::Value, String> {
584        let handler = self
585            .extensions
586            .get(id)
587            .ok_or_else(|| format!("unknown extension '{}'", id))?
588            .clone();
589        handler.invoke_command(command, args, request_id, sink).await
590    }
591
592    pub async fn settings_editor_open(
593        &self,
594        id: &str,
595        category: &str,
596        field: &str,
597    ) -> Result<serde_json::Value, String> {
598        let handler = self
599            .extensions
600            .get(id)
601            .ok_or_else(|| format!("unknown extension '{}'", id))?
602            .clone();
603        handler.settings_editor_open(category, field).await
604    }
605
606    pub async fn settings_editor_key(
607        &self,
608        id: &str,
609        category: &str,
610        field: &str,
611        key: &str,
612    ) -> Result<serde_json::Value, String> {
613        let handler = self
614            .extensions
615            .get(id)
616            .ok_or_else(|| format!("unknown extension '{}'", id))?
617            .clone();
618        handler.settings_editor_key(category, field, key).await
619    }
620
621    pub async fn settings_editor_commit(
622        &self,
623        id: &str,
624        category: &str,
625        field: &str,
626        value: serde_json::Value,
627    ) -> Result<serde_json::Value, String> {
628        let handler = self
629            .extensions
630            .get(id)
631            .ok_or_else(|| format!("unknown extension '{}'", id))?
632            .clone();
633        handler.settings_editor_commit(category, field, value).await
634    }
635
636    /// Return all cached plugin info sorted by extension id.
637    pub fn plugin_infos(&self) -> Vec<(&str, &PluginInfo)> {
638        let mut entries: Vec<_> = self
639            .plugin_info
640            .iter()
641            .map(|(id, info)| (id.as_str(), info))
642            .collect();
643        entries.sort_by(|a, b| a.0.cmp(b.0));
644        entries
645    }
646
647    /// Return provider status summaries sorted by provider runtime id.
648    pub fn provider_summaries(&self) -> Vec<RegisteredProviderSummary> {
649        self.providers.summaries()
650    }
651
652    /// Unified capability snapshot per loaded extension, sorted by id.
653    ///
654    /// Aggregates hook subscriptions, extension-provided tools, and registered
655    /// providers. `future` carries plugin-defined capability kinds and
656    /// capabilities land.
657    pub async fn capability_snapshots(&self) -> Vec<ExtensionCapabilitySnapshot> {
658        let mut handlers: Vec<(String, Arc<dyn ExtensionHandler>)> = self
659            .extensions
660            .iter()
661            .map(|(id, handler)| (id.clone(), handler.clone()))
662            .collect();
663        handlers.sort_by(|a, b| a.0.cmp(&b.0));
664
665        let provider_summaries = self.providers.summaries();
666        let plugin_id_lookup: std::collections::HashMap<String, String> = self
667            .providers
668            .list()
669            .into_iter()
670            .map(|p| (p.runtime_id.clone(), p.plugin_id.clone()))
671            .collect();
672
673        let mut out = Vec::with_capacity(handlers.len());
674        for (id, handler) in handlers {
675            let health = handler.health().await;
676            let restart_count = handler.restart_count().await;
677
678            let hook_pairs = self.hook_bus.subscriptions_for(&id).await;
679            let hooks: Vec<HookCapabilityEntry> = hook_pairs
680                .into_iter()
681                .map(|(kind, tool_filter)| HookCapabilityEntry {
682                    kind: kind.as_str().to_string(),
683                    tool_filter,
684                })
685                .collect();
686
687            let tools: Vec<ToolCapabilityEntry> = if let Some(tools) = &self.tools {
688                let registry = tools.read().await;
689                registry
690                    .tool_names_for_extension(&id)
691                    .into_iter()
692                    .map(|name| ToolCapabilityEntry { name })
693                    .collect()
694            } else {
695                Vec::new()
696            };
697
698            let providers: Vec<RegisteredProviderSummary> = provider_summaries
699                .iter()
700                .filter(|summary| {
701                    plugin_id_lookup
702                        .get(&summary.runtime_id)
703                        .map(|p| p == &id)
704                        .unwrap_or(false)
705                })
706                .cloned()
707                .collect();
708
709            let future: Vec<FutureCapabilityEntry> = self
710                .capabilities
711                .get(&id)
712                .map(|decls| {
713                    decls
714                        .iter()
715                        .map(|d| FutureCapabilityEntry {
716                            kind: d.kind.clone(),
717                            name: d.name.clone(),
718                        })
719                        .collect()
720                })
721                .unwrap_or_default();
722
723            out.push(ExtensionCapabilitySnapshot {
724                id,
725                health,
726                restart_count,
727                hooks,
728                tools,
729                providers,
730                future,
731            });
732        }
733        out
734    }
735
736    /// Return runtime ids of registered providers that declare at least one
737    /// tool-use-capable model. Sorted by runtime id.
738    pub fn provider_tool_use_runtime_ids(&self) -> Vec<String> {
739        let mut ids: Vec<String> = self
740            .providers
741            .list()
742            .into_iter()
743            .filter(|p| {
744                p.spec.models.iter().any(|m| {
745                    m.capabilities
746                        .get("tool_use")
747                        .and_then(|v| v.as_bool())
748                        .unwrap_or(false)
749                })
750            })
751            .map(|p| p.runtime_id.clone())
752            .collect();
753        ids.sort();
754        ids
755    }
756
757    /// Return a `runtime_id -> enabled` map for every registered provider, computed
758    /// from the persisted trust state. Providers without an entry default to
759    /// enabled. If the trust state file is missing, all providers are reported
760    /// as enabled (default). If the file is corrupt, all providers are reported
761    /// as **disabled** (fail-closed) and a warning is logged.
762    pub fn provider_trust_view(&self) -> std::collections::BTreeMap<String, bool> {
763        let trust = match crate::extensions::trust::load_trust_state() {
764            Ok(t) => t,
765            Err(e) => {
766                tracing::warn!("trust.json corrupt or unreadable, failing closed (all providers disabled): {e}");
767                // Return all providers as disabled
768                return self.providers
769                    .list()
770                    .into_iter()
771                    .map(|p| (p.runtime_id.clone(), false))
772                    .collect();
773            }
774        };
775        self.providers
776            .list()
777            .into_iter()
778            .map(|p| {
779                let enabled =
780                    crate::extensions::trust::is_provider_enabled(&trust, &p.runtime_id);
781                (p.runtime_id.clone(), enabled)
782            })
783            .collect()
784    }
785
786    /// Compute config diagnostics for a loaded extension by id.
787    /// Returns `None` if the extension is not loaded.
788    pub fn config_diagnostics(&self, id: &str) -> Option<ExtensionConfigDiagnostics> {
789        let manifest_config = self.manifest_configs.get(id)?;
790
791        // Collect provider required keys from registered providers' config_schema.
792        let mut provider_required: Vec<(String, Vec<String>)> = Vec::new();
793        for provider in self.providers.list() {
794            if provider.plugin_id != id {
795                continue;
796            }
797            let required: Vec<String> = provider
798                .spec
799                .config_schema
800                .as_ref()
801                .and_then(|schema| schema.get("required"))
802                .and_then(Value::as_array)
803                .map(|arr| {
804                    arr.iter()
805                        .filter_map(|v| v.as_str().map(|s| s.to_string()))
806                        .collect()
807                })
808                .unwrap_or_default();
809            provider_required.push((provider.provider_id.clone(), required));
810        }
811        provider_required.sort_by(|a, b| a.0.cmp(&b.0));
812
813        let env_lookup = |name: &str| std::env::var(name).ok();
814        let plugin_config_lookup = |key: &str| crate::extensions::config_store::read_plugin_config(id, key);
815        let legacy_config_lookup = |key: &str| crate::config::read_config_value(key);
816
817        Some(diagnose_extension_config(
818            id,
819            manifest_config,
820            &provider_required,
821            &env_lookup,
822            &plugin_config_lookup,
823            &legacy_config_lookup,
824        ))
825    }
826
827    /// Diagnostics for all loaded extensions, sorted alphabetically by id.
828    pub fn all_config_diagnostics(&self) -> Vec<ExtensionConfigDiagnostics> {
829        let mut ids: Vec<&String> = self.manifest_configs.keys().collect();
830        ids.sort();
831        ids.into_iter()
832            .filter_map(|id| self.config_diagnostics(id))
833            .collect()
834    }
835
836    /// Get the shared hook bus.
837    pub fn hook_bus(&self) -> &Arc<HookBus> {
838        &self.hook_bus
839    }
840
841    /// Get the shared tool registry, when this manager was constructed with one.
842    pub fn tools_shared(&self) -> Option<Arc<tokio::sync::RwLock<crate::ToolRegistry>>> {
843        self.tools.clone()
844    }
845
846    /// Discover and load all extensions from the user and project plugin directories.
847    ///
848    /// Scans `~/.synaps-cli/plugins/*/.synaps-plugin/plugin.json` and
849    /// `./.synaps/plugins/*/.synaps-plugin/plugin.json` for manifests that contain
850    /// an `extension` field. Project-local plugins override user plugins with the
851    /// same directory name.
852    pub async fn discover_and_load(&mut self) -> (Vec<String>, Vec<ExtensionLoadFailure>) {
853        self.discover_and_load_with_progress(|_| {}).await
854    }
855
856    /// Discover and load all extensions, invoking `progress` after each load
857    /// attempt. Used by the async UI loader to update startup toasts without
858    /// blocking first paint.
859    pub async fn discover_and_load_with_progress<F>(&mut self, mut progress: F) -> (Vec<String>, Vec<ExtensionLoadFailure>)
860    where
861        F: FnMut(crate::extensions::loader::ExtensionLoaderEvent),
862    {
863        let mut plugin_roots = vec![crate::config::base_dir().join("plugins")];
864        if !project_plugins_disabled() {
865            if let Ok(cwd) = std::env::current_dir() {
866                let project_plugins = cwd.join(".synaps").join("plugins");
867                if project_plugins != plugin_roots[0] {
868                    plugin_roots.push(project_plugins);
869                }
870            }
871        }
872
873        let mut plugin_dirs: HashMap<String, PathBuf> = HashMap::new();
874        let mut failed: Vec<ExtensionLoadFailure> = Vec::new();
875
876        for plugins_dir in plugin_roots {
877            if !plugins_dir.exists() {
878                continue;
879            }
880
881            let entries = match std::fs::read_dir(&plugins_dir) {
882                Ok(e) => e,
883                Err(e) => {
884                    tracing::warn!(path = %plugins_dir.display(), error = %e, "Failed to read plugins directory");
885                    failed.push(ExtensionLoadFailure::new(
886                        "plugins",
887                        Some(plugins_dir.clone()),
888                        format!("Failed to read plugins directory: {e}"),
889                        "Check directory permissions and retry",
890                    ));
891                    continue;
892                }
893            };
894
895            for entry in entries.flatten() {
896                let plugin_name = entry.file_name().to_string_lossy().to_string();
897                plugin_dirs.insert(plugin_name, entry.path());
898            }
899        }
900
901        let mut plugin_dirs: Vec<(String, PathBuf)> = plugin_dirs.into_iter().collect();
902        plugin_dirs.sort_by(|a, b| a.0.cmp(&b.0));
903
904        let mut loaded = Vec::new();
905        let disabled_plugins = crate::config::load_config().disabled_plugins;
906        for (plugin_name, plugin_dir) in plugin_dirs {
907            if disabled_plugins.iter().any(|d| d == &plugin_name) {
908                tracing::debug!(plugin = %plugin_name, "Extension disabled via disabled_plugins config");
909                continue;
910            }
911            if let Some(message) = installed_plugin_setup_failure(&plugin_name) {
912                tracing::warn!(plugin = %plugin_name, error = %message, "Skipping extension with failed post-install setup");
913                failed.push(ExtensionLoadFailure::new(
914                    plugin_name,
915                    None,
916                    format!("Post-install setup failed: {message}"),
917                    "Open /plugins, reinstall or update the plugin after fixing setup; extension load is disabled until setup succeeds",
918                ));
919                continue;
920            }
921            let manifest_path = plugin_dir.join(".synaps-plugin").join("plugin.json");
922            if !manifest_path.exists() {
923                continue;
924            }
925
926            let content = match std::fs::read_to_string(&manifest_path) {
927                Ok(c) => c,
928                Err(e) => {
929                    let reason = format!("Failed to read plugin manifest: {e}");
930                    tracing::warn!(plugin = %plugin_name, manifest = %manifest_path.display(), error = %e, "Failed to read plugin manifest");
931                    failed.push(ExtensionLoadFailure::new(
932                        plugin_name,
933                        Some(manifest_path),
934                        reason,
935                        "Check manifest file permissions, then run `plugin validate <plugin-dir>`",
936                    ));
937                    continue;
938                }
939            };
940
941            let json: serde_json::Value = match serde_json::from_str(&content) {
942                Ok(v) => v,
943                Err(e) => {
944                    let reason = format!("Invalid plugin manifest JSON: {e}");
945                    tracing::warn!(plugin = %plugin_name, manifest = %manifest_path.display(), error = %e, "Invalid plugin manifest JSON");
946                    failed.push(ExtensionLoadFailure::new(
947                        plugin_name,
948                        Some(manifest_path),
949                        reason,
950                        "Fix JSON syntax, then run `plugin validate <plugin-dir>`",
951                    ));
952                    continue;
953                }
954            };
955
956            let ext_value = match json.get("extension") {
957                Some(v) => v.clone(),
958                None => continue,
959            };
960
961            let ext_manifest: ExtensionManifest = match serde_json::from_value(ext_value) {
962                Ok(m) => m,
963                Err(e) => {
964                    let reason = format!("Failed to parse extension manifest: {e}");
965                    tracing::warn!(plugin = %plugin_name, manifest = %manifest_path.display(), error = %e, "Failed to parse extension manifest");
966                    failed.push(ExtensionLoadFailure::new(
967                        plugin_name,
968                        Some(manifest_path),
969                        reason,
970                        "Check the `extension` block shape against docs/extensions/contract.json, then run `plugin validate <plugin-dir>`",
971                    ));
972                    continue;
973                }
974            };
975
976            #[allow(clippy::if_same_then_else)]
977            let command = if std::path::Path::new(&ext_manifest.command).is_absolute() {
978                ext_manifest.command.clone()
979            } else if !ext_manifest.command.contains(std::path::MAIN_SEPARATOR) && !ext_manifest.command.contains('/') {
980                ext_manifest.command.clone()
981            } else {
982                plugin_dir.join(&ext_manifest.command)
983                    .to_string_lossy().to_string()
984            };
985
986            let args: Vec<String> = ext_manifest.args.iter().map(|arg| {
987                let arg_path = plugin_dir.join(arg);
988                if arg_path.exists() {
989                    if let (Ok(canonical), Ok(plugin_canonical)) = (
990                        arg_path.canonicalize(),
991                        plugin_dir.canonicalize(),
992                    ) {
993                        if canonical.starts_with(&plugin_canonical) {
994                            return canonical.to_string_lossy().to_string();
995                        }
996                    }
997                }
998                arg.clone()
999            }).collect();
1000
1001            let resolved = ExtensionManifest {
1002                command,
1003                args,
1004                ..ext_manifest
1005            };
1006
1007            match self.load_with_cwd(&plugin_name, &resolved, Some(plugin_dir.clone())).await {
1008                Ok(()) => {
1009                    tracing::info!(plugin = %plugin_name, path = %plugin_dir.display(), "Extension loaded from plugins/");
1010                    loaded.push(plugin_name.clone());
1011                    progress(crate::extensions::loader::ExtensionLoaderEvent::Loaded {
1012                        plugin: plugin_name,
1013                        loaded: loaded.len(),
1014                        failed: failed.len(),
1015                    });
1016                }
1017                Err(e) => {
1018                    tracing::warn!(plugin = %plugin_name, manifest = %manifest_path.display(), error = %e, "Failed to load extension");
1019                    let setup_script = json
1020                        .pointer("/extension/setup")
1021                        .and_then(|v| v.as_str())
1022                        .or_else(|| json.pointer("/provides/sidecar/setup").and_then(|v| v.as_str()));
1023                    let hint = compute_extension_load_hint(&e, &plugin_dir, setup_script);
1024                    let failure = ExtensionLoadFailure::new(
1025                        plugin_name,
1026                        Some(manifest_path),
1027                        e,
1028                        hint,
1029                    );
1030                    failed.push(failure.clone());
1031                    progress(crate::extensions::loader::ExtensionLoaderEvent::Failed {
1032                        failure,
1033                        loaded: loaded.len(),
1034                        failed: failed.len(),
1035                    });
1036                }
1037            }
1038        }
1039
1040        (loaded, failed)
1041    }
1042}
1043
1044#[cfg(test)]
1045mod tests {
1046    use super::*;
1047
1048    #[tokio::test]
1049    async fn capability_snapshots_empty_when_no_extensions() {
1050        let bus = Arc::new(HookBus::new());
1051        let mgr = ExtensionManager::new(bus);
1052        assert!(mgr.capability_snapshots().await.is_empty());
1053    }
1054
1055    #[tokio::test]
1056    async fn capability_snapshot_lists_hooks_for_loaded_extension() {
1057        let bus = Arc::new(HookBus::new());
1058        let mut mgr = ExtensionManager::new(bus.clone());
1059        let manifest = ExtensionManifest {
1060            protocol_version: 1,
1061            runtime: crate::extensions::manifest::ExtensionRuntime::Process,
1062            command: "python3".to_string(),
1063            setup: None,
1064            prebuilt: ::std::collections::HashMap::new(),
1065            args: vec![
1066                "tests/fixtures/process_extension.py".to_string(),
1067                "normal".to_string(),
1068                "/tmp/synaps-capability-test.log".to_string(),
1069            ],
1070            permissions: vec!["tools.intercept".to_string()],
1071            hooks: vec![crate::extensions::manifest::HookSubscription {
1072                hook: "before_tool_call".to_string(),
1073                tool: Some("bash".to_string()),
1074                matcher: None,
1075            }],
1076            config: vec![],
1077        };
1078
1079        mgr.load("cap-snap", &manifest).await.unwrap();
1080
1081        let snaps = mgr.capability_snapshots().await;
1082        assert_eq!(snaps.len(), 1);
1083        let snap = &snaps[0];
1084        assert_eq!(snap.id, "cap-snap");
1085        assert_eq!(snap.hooks.len(), 1);
1086        assert_eq!(snap.hooks[0].kind, "before_tool_call");
1087        assert_eq!(snap.hooks[0].tool_filter.as_deref(), Some("bash"));
1088        assert!(snap.tools.is_empty());
1089        assert!(snap.providers.is_empty());
1090        assert!(snap.future.is_empty());
1091
1092        mgr.shutdown_all().await;
1093    }
1094
1095    #[tokio::test]
1096    async fn capability_snapshot_surfaces_seeded_capabilities() {
1097        let bus = Arc::new(HookBus::new());
1098        let mut mgr = ExtensionManager::new(bus.clone());
1099        let manifest = ExtensionManifest {
1100            protocol_version: 1,
1101            runtime: crate::extensions::manifest::ExtensionRuntime::Process,
1102            command: "python3".to_string(),
1103            setup: None,
1104            prebuilt: ::std::collections::HashMap::new(),
1105            args: vec![
1106                "tests/fixtures/process_extension.py".to_string(),
1107                "normal".to_string(),
1108                "/tmp/synaps-capability-snapshot-test.log".to_string(),
1109            ],
1110            permissions: vec!["tools.intercept".to_string()],
1111            hooks: vec![crate::extensions::manifest::HookSubscription {
1112                hook: "before_tool_call".to_string(),
1113                tool: Some("bash".to_string()),
1114                matcher: None,
1115            }],
1116            config: vec![],
1117        };
1118
1119        mgr.load("multi-cap", &manifest).await.unwrap();
1120
1121        // Seed two capabilities of *different* kinds — proves the
1122        // snapshot rendering iterates a generic list and uses the
1123        // plugin-supplied `kind` rather than hardcoding any modality.
1124        mgr.test_seed_capabilities(
1125            "multi-cap",
1126            vec![
1127                crate::extensions::runtime::process::CapabilityDeclaration {
1128                    kind: "capture".to_string(),
1129                    name: "Local Sample STT".to_string(),
1130                    permissions: vec!["audio.input".to_string()],
1131                    params: serde_json::Value::Null,
1132                },
1133                crate::extensions::runtime::process::CapabilityDeclaration {
1134                    kind: "ocr".to_string(),
1135                    name: "Tesseract".to_string(),
1136                    permissions: vec![],
1137                    params: serde_json::Value::Null,
1138                },
1139            ],
1140        );
1141
1142        let snaps = mgr.capability_snapshots().await;
1143        let snap = snaps
1144            .iter()
1145            .find(|s| s.id == "multi-cap")
1146            .expect("multi-cap snapshot");
1147        assert_eq!(snap.future.len(), 2);
1148        let kinds: Vec<&str> = snap.future.iter().map(|e| e.kind.as_str()).collect();
1149        assert!(kinds.contains(&"capture"), "got kinds {:?}", kinds);
1150        assert!(kinds.contains(&"ocr"), "got kinds {:?}", kinds);
1151        let names: Vec<&str> = snap.future.iter().map(|e| e.name.as_str()).collect();
1152        assert!(names.contains(&"Local Sample STT"), "got {:?}", names);
1153        assert!(names.contains(&"Tesseract"), "got {:?}", names);
1154
1155        mgr.unload("multi-cap").await.unwrap();
1156        let snaps = mgr.capability_snapshots().await;
1157        assert!(snaps.iter().all(|s| s.id != "multi-cap"));
1158
1159        mgr.shutdown_all().await;
1160    }
1161
1162    #[tokio::test]
1163    async fn new_manager_has_no_extensions() {
1164        let bus = Arc::new(HookBus::new());
1165        let mgr = ExtensionManager::new(bus);
1166        assert_eq!(mgr.count(), 0);
1167        assert!(mgr.list().is_empty());
1168    }
1169
1170    #[tokio::test]
1171    async fn unload_nonexistent_returns_error() {
1172        let bus = Arc::new(HookBus::new());
1173        let mut mgr = ExtensionManager::new(bus);
1174        let result = mgr.unload("nope").await;
1175        assert!(result.is_err());
1176    }
1177
1178    #[tokio::test]
1179    async fn reload_unsubscribes_old_handler_before_loading_new_one() {
1180        let bus = Arc::new(HookBus::new());
1181        let mut mgr = ExtensionManager::new(bus.clone());
1182        let manifest = ExtensionManifest {
1183            protocol_version: 1,
1184            runtime: crate::extensions::manifest::ExtensionRuntime::Process,
1185            command: "python3".to_string(),
1186            setup: None,
1187            prebuilt: ::std::collections::HashMap::new(),
1188            args: vec!["tests/fixtures/process_extension.py".to_string(), "normal".to_string(), "/tmp/synaps-reload-test.log".to_string()],
1189            permissions: vec!["tools.intercept".to_string()],
1190            hooks: vec![crate::extensions::manifest::HookSubscription {
1191                hook: "before_tool_call".to_string(),
1192                tool: Some("bash".to_string()),
1193                matcher: None,
1194            }],
1195            config: vec![],
1196        };
1197
1198        mgr.load("reload-test", &manifest).await.unwrap();
1199        assert_eq!(bus.handler_count().await, 1);
1200
1201        mgr.reload("reload-test", &manifest, None).await.unwrap();
1202
1203        assert_eq!(mgr.count(), 1);
1204        assert_eq!(bus.handler_count().await, 1);
1205        mgr.shutdown_all().await;
1206    }
1207
1208    #[tokio::test]
1209    async fn reload_failure_leaves_previous_instance_unloaded() {
1210        let bus = Arc::new(HookBus::new());
1211        let mut mgr = ExtensionManager::new(bus.clone());
1212        let good = ExtensionManifest {
1213            protocol_version: 1,
1214            runtime: crate::extensions::manifest::ExtensionRuntime::Process,
1215            command: "python3".to_string(),
1216            setup: None,
1217            prebuilt: ::std::collections::HashMap::new(),
1218            args: vec!["tests/fixtures/process_extension.py".to_string(), "normal".to_string(), "/tmp/synaps-reload-failure-test.log".to_string()],
1219            permissions: vec!["tools.intercept".to_string()],
1220            hooks: vec![crate::extensions::manifest::HookSubscription {
1221                hook: "before_tool_call".to_string(),
1222                tool: Some("bash".to_string()),
1223                matcher: None,
1224            }],
1225            config: vec![],
1226        };
1227        let bad = ExtensionManifest {
1228            command: "/definitely/not/a/real/extension-binary".to_string(),
1229            setup: None,
1230            prebuilt: ::std::collections::HashMap::new(),
1231            ..good.clone()
1232        };
1233
1234        mgr.load("reload-failure-test", &good).await.unwrap();
1235        let err = mgr.reload("reload-failure-test", &bad, None).await.unwrap_err();
1236
1237        assert!(err.contains("Failed to spawn extension"), "{err}");
1238        assert_eq!(mgr.count(), 0);
1239        assert_eq!(bus.handler_count().await, 0);
1240    }
1241
1242    #[test]
1243    fn project_plugins_disable_env_parser_accepts_truthy_values() {
1244        for value in ["1", "true", "TRUE", "yes", "on"] {
1245            std::env::set_var("SYNAPS_DISABLE_PROJECT_PLUGINS", value);
1246            assert!(project_plugins_disabled());
1247        }
1248        for value in ["", "0", "false", "off", "no"] {
1249            std::env::set_var("SYNAPS_DISABLE_PROJECT_PLUGINS", value);
1250            assert!(!project_plugins_disabled());
1251        }
1252        std::env::remove_var("SYNAPS_DISABLE_PROJECT_PLUGINS");
1253    }
1254
1255    fn with_temp_base_dir<T>(path: &std::path::Path, f: impl FnOnce() -> T) -> T {
1256        let old_base_dir = std::env::var("SYNAPS_BASE_DIR").ok();
1257        crate::config::set_base_dir_for_tests(path.to_path_buf());
1258        let out = f();
1259        match old_base_dir {
1260            Some(old) => std::env::set_var("SYNAPS_BASE_DIR", old),
1261            None => std::env::remove_var("SYNAPS_BASE_DIR"),
1262        }
1263        out
1264    }
1265
1266    #[test]
1267    fn resolve_config_prefers_plugin_namespaced_config_before_legacy_global_key() {
1268        let dir = tempfile::tempdir().unwrap();
1269        with_temp_base_dir(dir.path(), || {
1270            crate::extensions::config_store::write_plugin_config("sample-sidecar", "backend", "cpu")
1271                .unwrap();
1272            crate::config::write_config_value("extension.sample-sidecar.backend", "auto").unwrap();
1273
1274            let resolved = ExtensionManager::resolve_config(
1275                "sample-sidecar",
1276                &[ExtensionConfigEntry {
1277                    key: "backend".to_string(),
1278                    value_type: None,
1279                    description: None,
1280                    required: true,
1281                    default: None,
1282                    secret_env: None,
1283                }],
1284            )
1285            .unwrap();
1286
1287            assert_eq!(resolved["backend"], serde_json::Value::String("cpu".to_string()));
1288        });
1289    }
1290
1291    #[test]
1292    fn resolve_config_keeps_legacy_global_extension_key_as_fallback() {
1293        let dir = tempfile::tempdir().unwrap();
1294        with_temp_base_dir(dir.path(), || {
1295            crate::config::write_config_value("extension.sample-sidecar.backend", "auto").unwrap();
1296
1297            let resolved = ExtensionManager::resolve_config(
1298                "sample-sidecar",
1299                &[ExtensionConfigEntry {
1300                    key: "backend".to_string(),
1301                    value_type: None,
1302                    description: None,
1303                    required: true,
1304                    default: None,
1305                    secret_env: None,
1306                }],
1307            )
1308            .unwrap();
1309
1310            assert_eq!(resolved["backend"], serde_json::Value::String("auto".to_string()));
1311        });
1312    }
1313
1314    #[tokio::test]
1315    async fn config_diagnostics_returns_none_for_unknown_extension() {
1316        let bus = Arc::new(HookBus::new());
1317        let mgr = ExtensionManager::new(bus);
1318        assert!(mgr.config_diagnostics("nope").is_none());
1319        assert!(mgr.all_config_diagnostics().is_empty());
1320    }
1321
1322    #[tokio::test]
1323    async fn config_diagnostics_reports_loaded_manifest_entries() {
1324        let bus = Arc::new(HookBus::new());
1325        let mut mgr = ExtensionManager::new(bus);
1326        let manifest = ExtensionManifest {
1327            protocol_version: 1,
1328            runtime: crate::extensions::manifest::ExtensionRuntime::Process,
1329            command: "python3".to_string(),
1330            setup: None,
1331            prebuilt: ::std::collections::HashMap::new(),
1332            args: vec![
1333                "tests/fixtures/process_extension.py".to_string(),
1334                "normal".to_string(),
1335                "/tmp/synaps-config-diag-test.log".to_string(),
1336            ],
1337            permissions: vec!["tools.intercept".to_string()],
1338            hooks: vec![crate::extensions::manifest::HookSubscription {
1339                hook: "before_tool_call".to_string(),
1340                tool: Some("bash".to_string()),
1341                matcher: None,
1342            }],
1343            config: vec![crate::extensions::manifest::ExtensionConfigEntry {
1344                key: "region".to_string(),
1345                value_type: None,
1346                description: Some("AWS region".to_string()),
1347                required: false,
1348                default: Some(serde_json::Value::String("us-east-1".to_string())),
1349                secret_env: None,
1350            }],
1351        };
1352
1353        mgr.load("config-diag-test", &manifest).await.unwrap();
1354
1355        let diag = mgr
1356            .config_diagnostics("config-diag-test")
1357            .expect("diagnostics should be available for loaded extension");
1358        assert_eq!(diag.extension_id, "config-diag-test");
1359        assert_eq!(diag.entries.len(), 1);
1360        assert_eq!(diag.entries[0].key, "region");
1361        assert!(diag.entries[0].has_value);
1362        assert!(diag.provider_missing.is_empty());
1363
1364        let all = mgr.all_config_diagnostics();
1365        assert_eq!(all.len(), 1);
1366        assert_eq!(all[0].extension_id, "config-diag-test");
1367
1368        mgr.shutdown_all().await;
1369        // After shutdown, manifest config storage is cleared.
1370        assert!(mgr.config_diagnostics("config-diag-test").is_none());
1371    }
1372
1373    #[tokio::test]
1374    async fn provider_trust_view_is_empty_for_no_providers() {
1375        let bus = Arc::new(HookBus::new());
1376        let mgr = ExtensionManager::new(bus);
1377        let view = mgr.provider_trust_view();
1378        assert!(view.is_empty());
1379    }
1380
1381    #[tokio::test]
1382    async fn provider_tool_use_runtime_ids_lists_only_tool_use_capable() {
1383        use crate::extensions::runtime::process::{RegisteredProviderModelSpec, RegisteredProviderSpec};
1384        let bus = Arc::new(HookBus::new());
1385        let mut mgr = ExtensionManager::new(bus);
1386        // Tool-use capable provider.
1387        let tool_spec = RegisteredProviderSpec {
1388            id: "alpha".into(),
1389            display_name: "Alpha".into(),
1390            description: "tool-use".into(),
1391            models: vec![RegisteredProviderModelSpec {
1392                id: "m1".into(),
1393                display_name: None,
1394                capabilities: serde_json::json!({"tool_use": true}),
1395                context_window: None,
1396            }],
1397            config_schema: None,
1398        };
1399        // Plain provider, no tool_use.
1400        let plain_spec = RegisteredProviderSpec {
1401            id: "beta".into(),
1402            display_name: "Beta".into(),
1403            description: "plain".into(),
1404            models: vec![RegisteredProviderModelSpec {
1405                id: "m1".into(),
1406                display_name: None,
1407                capabilities: serde_json::json!({"streaming": true}),
1408                context_window: None,
1409            }],
1410            config_schema: None,
1411        };
1412        mgr.providers.register("plug", tool_spec).unwrap();
1413        mgr.providers.register("plug", plain_spec).unwrap();
1414        let ids = mgr.provider_tool_use_runtime_ids();
1415        assert_eq!(ids, vec!["plug:alpha".to_string()]);
1416    }
1417
1418    // ---- compute_extension_load_hint --------------------------------
1419
1420    #[test]
1421    fn hint_missing_binary_with_declared_setup_points_at_script() {
1422        let hint = compute_extension_load_hint(
1423            "Failed to spawn extension 'sample-sidecar': No such file or directory (os error 2)",
1424            std::path::Path::new("/home/u/.synaps-cli/plugins/sample-sidecar"),
1425            Some("scripts/setup.sh"),
1426        );
1427        assert!(
1428            hint.contains("Extension binary missing"),
1429            "missing-binary case should be flagged: {hint}"
1430        );
1431        assert!(
1432            hint.contains("/home/u/.synaps-cli/plugins/sample-sidecar"),
1433            "hint should include the plugin dir: {hint}"
1434        );
1435        assert!(
1436            hint.contains("setup=scripts/setup.sh"),
1437            "hint should show sanitized setup path without copy-paste shell command: {hint}"
1438        );
1439    }
1440
1441    #[test]
1442    fn hint_missing_binary_without_declared_setup_falls_back_to_generic() {
1443        let hint = compute_extension_load_hint(
1444            "Failed to spawn extension 'foo': No such file or directory (os error 2)",
1445            std::path::Path::new("/x/y"),
1446            None,
1447        );
1448        assert!(
1449            hint.contains("plugin validate"),
1450            "no setup declared → generic hint: {hint}"
1451        );
1452        assert!(
1453            !hint.contains("Extension binary missing"),
1454            "should not falsely promise a setup script: {hint}"
1455        );
1456    }
1457
1458    #[test]
1459    fn hint_other_error_with_declared_setup_falls_back_to_generic() {
1460        let hint = compute_extension_load_hint(
1461            "Extension 'foo' must subscribe to at least one hook or request a registration permission",
1462            std::path::Path::new("/x/y"),
1463            Some("scripts/setup.sh"),
1464        );
1465        // Setup script is declared, but the error is *not* a missing
1466        // binary — running the script wouldn't help. Fall back to the
1467        // generic hint so we don't mislead the user.
1468        assert!(hint.contains("plugin validate"), "got {hint}");
1469        assert!(!hint.contains("Extension binary missing"), "got {hint}");
1470    }
1471
1472    #[test]
1473    fn hint_recognises_os_error_2_format() {
1474        // Older / cross-platform error formats may include the kernel
1475        // errno but not the "No such file or directory" English text.
1476        let hint = compute_extension_load_hint(
1477            "spawn failed (os error 2)",
1478            std::path::Path::new("/p"),
1479            Some("setup.sh"),
1480        );
1481        assert!(hint.contains("Extension binary missing"), "got {hint}");
1482    }
1483}