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