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::capability::{
8    ExtensionCapabilitySnapshot, FutureCapabilityEntry, HookCapabilityEntry, ToolCapabilityEntry,
9};
10use super::config::{diagnose_extension_config, ExtensionConfigDiagnostics};
11use super::context_provider::{
12    dormant_context_provider_descriptors, resolve_context_provider, ContextProviderId,
13    RegisteredContextProviderDescriptor, SharedContextProviderCatalog,
14};
15use super::hooks::HookBus;
16use super::info::PluginInfo;
17use super::manifest::{ExtensionConfigEntry, ExtensionManifest};
18use super::providers::{ProviderRegistry, RegisteredProvider, RegisteredProviderSummary};
19use super::runtime::process::ProcessExtension;
20use super::runtime::{ExtensionHandler, ExtensionHealth};
21use serde_json::{Map, Value};
22
23fn project_plugins_disabled() -> bool {
24    std::env::var("SYNAPS_DISABLE_PROJECT_PLUGINS")
25        .map(|value| {
26            let normalized = value.trim().to_ascii_lowercase();
27            matches!(normalized.as_str(), "1" | "true" | "yes" | "on")
28        })
29        .unwrap_or(false)
30}
31
32/// Whether a `plugins/` directory entry name denotes an INERT copy of a
33/// plugin — a backup, disabled, or temporary directory — rather than an
34/// installed plugin. Inert directories are excluded from discovery so a
35/// stale copy can never register tools or execute alongside the real one
36/// (user incident: `axel-memory-manager.backup.<TS>` dirs were discovered
37/// as live plugins and their binaries spawned next to the real v0.3).
38///
39/// Name-based and conservative: only well-known suffix conventions (plus
40/// the timestamped `.backup.<...>` infix) are excluded, so legitimate
41/// dotted/hyphenated plugin names (`com.example.notes`, `backup-manager`)
42/// stay discoverable. Exact plugin IDs of surviving plugins are untouched.
43fn is_inert_plugin_dir_name(name: &str) -> bool {
44    let lower = name.to_ascii_lowercase();
45    // Timestamped backups: `<plugin>.backup.20260720101416`.
46    if lower.contains(".backup.") {
47        return true;
48    }
49    const INERT_SUFFIXES: &[&str] = &[
50        ".backup",
51        ".update-backup", // written by the plugin updater
52        ".bak",
53        ".disabled",
54        ".old",
55        ".orig",
56        ".tmp",
57        ".temp",
58    ];
59    INERT_SUFFIXES.iter().any(|s| lower.ends_with(s))
60}
61
62fn installed_plugin_setup_failure_in(
63    state: &crate::skills::state::PluginsState,
64    plugin_name: &str,
65) -> Option<String> {
66    let plugin = state.installed.iter().find(|p| p.name == plugin_name)?;
67    match &plugin.setup_status {
68        crate::skills::state::SetupStatus::Failed { message, .. } => Some(message.clone()),
69        _ => None,
70    }
71}
72
73fn sanitize_hint_fragment(input: &str) -> String {
74    input
75        .chars()
76        .map(|ch| if ch.is_control() { '?' } else { ch })
77        .collect::<String>()
78}
79
80/// Actionable discovery/load failure for an installed plugin extension.
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct ExtensionLoadFailure {
83    pub plugin: String,
84    pub manifest_path: Option<PathBuf>,
85    pub reason: String,
86    pub hint: String,
87}
88
89impl ExtensionLoadFailure {
90    fn new(
91        plugin: impl Into<String>,
92        manifest_path: Option<PathBuf>,
93        reason: impl Into<String>,
94        hint: impl Into<String>,
95    ) -> Self {
96        Self {
97            plugin: plugin.into(),
98            manifest_path,
99            reason: reason.into(),
100            hint: hint.into(),
101        }
102    }
103
104    pub fn concise_message(&self) -> String {
105        match &self.manifest_path {
106            Some(path) => format!(
107                "{} (manifest: {}; hint: {})",
108                self.reason,
109                path.display(),
110                self.hint
111            ),
112            None => format!("{} (hint: {})", self.reason, self.hint),
113        }
114    }
115}
116
117/// Snapshot of a loaded extension's runtime status.
118#[derive(Debug, Clone, PartialEq, Eq)]
119pub struct ExtensionStatus {
120    pub id: String,
121    pub health: ExtensionHealth,
122    pub restart_count: usize,
123}
124
125/// Compute the hint for an extension load failure.
126///
127/// Two cases:
128/// 1. **Missing extension binary AND plugin declares
129///    `provides.sidecar.setup`** — the plugin ships source only (the
130///    binary is typically gitignored) and the setup script needs to
131///    be run. The hint points the user at the exact command. This is
132///    the common case for fresh marketplace installs of plugins that
133///    build their extension binary from source.
134/// 2. **Anything else** — the generic "run plugin validate" hint.
135///
136/// Pure function for unit-testability. Lives here (not in
137/// `ExtensionLoadFailure`) because the sidecar/setup convention is a
138/// plugin-layer concern.
139pub fn compute_extension_load_hint(
140    error: &str,
141    plugin_dir: &std::path::Path,
142    declared_setup: Option<&str>,
143) -> String {
144    let missing_binary =
145        error.contains("No such file or directory") || error.contains("os error 2");
146    match (missing_binary, declared_setup) {
147        (true, Some(setup)) => format!(
148            "Extension binary missing — this plugin ships source only. Run the setup script from the plugin directory, then reload. plugin_dir={}, setup={}",
149            sanitize_hint_fragment(&plugin_dir.display().to_string()),
150            sanitize_hint_fragment(setup),
151        ),
152        _ => "Run `plugin validate <plugin-dir>` and confirm the extension command is installed"
153            .to_string(),
154    }
155}
156
157/// Manages the lifecycle of all loaded extensions.
158pub struct ExtensionManager {
159    /// The shared hook bus.
160    hook_bus: Arc<HookBus>,
161    /// Optional shared tool registry for extension-provided tools.
162    tools: Option<Arc<tokio::sync::RwLock<crate::ToolRegistry>>>,
163    /// Provider metadata registered by loaded extensions. Routing is not wired yet.
164    providers: ProviderRegistry,
165    /// Running extensions keyed by ID.
166    extensions: HashMap<String, Arc<dyn ExtensionHandler>>,
167    /// Declared manifest config entries per loaded extension, kept so we can
168    /// produce diagnostics without re-reading the manifest.
169    manifest_configs: HashMap<String, Vec<ExtensionConfigEntry>>,
170    /// Capability declarations per loaded extension. Each plugin may
171    /// declare zero or more capabilities (kind is plugin-defined; core
172    /// does not enumerate). Populated on load.
173    capabilities: HashMap<String, Vec<crate::extensions::runtime::process::CapabilityDeclaration>>,
174    /// Optional plugin-reported info from the `info.get` RPC.
175    plugin_info: HashMap<String, PluginInfo>,
176    /// Theme tokens declared in each loaded extension's manifest (P19.2),
177    /// kept so the TUI can merge them into the theme under
178    /// `ext.<plugin-id>.<token>` without re-reading manifests. Extensions
179    /// that declare none simply have no entry.
180    manifest_theme_tokens: HashMap<String, std::collections::BTreeMap<String, String>>,
181    /// Task 20: when true (progressive disclosure), tool-only manifests
182    /// with validated passive declarations are NOT spawned at load — their
183    /// dormant deferred tools are cataloged instead and the launch record
184    /// is retained for exact-activation lease acquisition.
185    progressive_deferral: bool,
186    /// Launch records for deferred tool-only extensions (manifest + cwd +
187    /// resolved config), keyed by plugin id, behind a SHARED handle: the
188    /// [`crate::extensions::lease::ExtensionRuntimeManager`] reads the
189    /// CURRENT record at every lease acquisition (sync lock, map ops only,
190    /// never held across I/O). Consumed by the lease lifecycle only.
191    deferred_tool_only: SharedDeferredRecords,
192    /// Lazily-created shared extension runtime lease manager (Task 20
193    /// Commit B). One instance shared by this manager (unload revocation)
194    /// and the `Runtime` (per-session capabilities + session-end scope).
195    extension_runtime: std::sync::OnceLock<Arc<crate::extensions::lease::ExtensionRuntimeManager>>,
196    /// Task A6: DORMANT context-provider descriptors declared by loaded
197    /// extensions (`deferred.context_providers`, task A3) — the OWNED
198    /// catalog behind [`Self::context_providers`]/[`Self::context_provider`],
199    /// mirroring how `providers` backs the model-provider catalog reads.
200    /// Pure metadata: cataloging a descriptor spawns nothing (discovery/
201    /// status spawn zero processes); the only activation path is a future
202    /// exact memory-context lease routed through the lease manager.
203    context_providers: Vec<RegisteredContextProviderDescriptor>,
204    /// Published snapshot of `context_providers`, shared with the lease
205    /// manager exactly like `deferred_tool_only`: every catalog mutation
206    /// republishes via [`Self::publish_context_providers`], and the
207    /// `Runtime` memory-context enable path reads the CURRENT snapshot
208    /// through `ExtensionRuntimeManager::resolve_context_provider` (sync
209    /// lock, slice ops only, never held across I/O, never spawning).
210    context_provider_catalog: SharedContextProviderCatalog,
211    /// HOST-OWNED trusted project root, recorded ONCE per manager on first
212    /// use (canonical `ProjectScope::discover` walk from the host's own
213    /// working directory). Injected into resolved config for manifest
214    /// entries declaring `host_context: "project_root"`; never sourced
215    /// from env overrides, user config, or model input, and never
216    /// forwarded as an environment variable.
217    host_project_root: std::sync::OnceLock<Option<String>>,
218}
219
220/// Shared handle to the retained deferred launch records. Crate-private:
221/// records hold RESOLVED (potentially secret) config.
222pub(crate) type SharedDeferredRecords =
223    Arc<std::sync::Mutex<HashMap<String, DeferredExtensionRecord>>>;
224
225/// Retained launch expectation for a spawn-deferred tool-only extension.
226///
227/// INTERNAL AND OPAQUE (review fix A3): `config` holds RESOLVED values —
228/// including `secret_env` material — so this record is crate-private,
229/// exposes no public accessor, and deliberately implements neither
230/// `Debug` nor `Serialize`. Public diagnostics may reveal only booleans/
231/// counts (see [`ExtensionManager::is_deferred_tool_only`]); runtime
232/// acquisition stays inside the manager/lease lifecycle API.
233#[derive(Clone)]
234pub(crate) struct DeferredExtensionRecord {
235    pub(crate) manifest: ExtensionManifest,
236    pub(crate) cwd: Option<std::path::PathBuf>,
237    pub(crate) config: Value,
238}
239
240impl ExtensionManager {
241    /// Create a new manager with a shared hook bus.
242    pub fn new(hook_bus: Arc<HookBus>) -> Self {
243        Self {
244            hook_bus,
245            tools: None,
246            providers: ProviderRegistry::new(),
247            extensions: HashMap::new(),
248            manifest_configs: HashMap::new(),
249            capabilities: HashMap::new(),
250            plugin_info: HashMap::new(),
251            manifest_theme_tokens: HashMap::new(),
252            progressive_deferral: false,
253            deferred_tool_only: Arc::new(std::sync::Mutex::new(HashMap::new())),
254            extension_runtime: std::sync::OnceLock::new(),
255            context_providers: Vec::new(),
256            context_provider_catalog: Arc::new(std::sync::Mutex::new(Vec::new())),
257            host_project_root: std::sync::OnceLock::new(),
258        }
259    }
260
261    /// Create a new manager with shared hook bus and tool registry.
262    pub fn new_with_tools(
263        hook_bus: Arc<HookBus>,
264        tools: Arc<tokio::sync::RwLock<crate::ToolRegistry>>,
265    ) -> Self {
266        Self {
267            hook_bus,
268            tools: Some(tools),
269            providers: ProviderRegistry::new(),
270            extensions: HashMap::new(),
271            manifest_configs: HashMap::new(),
272            capabilities: HashMap::new(),
273            plugin_info: HashMap::new(),
274            manifest_theme_tokens: HashMap::new(),
275            progressive_deferral: false,
276            deferred_tool_only: Arc::new(std::sync::Mutex::new(HashMap::new())),
277            extension_runtime: std::sync::OnceLock::new(),
278            context_providers: Vec::new(),
279            context_provider_catalog: Arc::new(std::sync::Mutex::new(Vec::new())),
280            host_project_root: std::sync::OnceLock::new(),
281        }
282    }
283
284    /// Load and start an extension from its manifest.
285    pub async fn load(&mut self, id: &str, manifest: &ExtensionManifest) -> Result<(), String> {
286        self.load_with_cwd(id, manifest, None).await
287    }
288
289    /// Load and start an extension from its manifest with a process cwd.
290    pub async fn load_with_cwd(
291        &mut self,
292        id: &str,
293        manifest: &ExtensionManifest,
294        cwd: Option<std::path::PathBuf>,
295    ) -> Result<(), String> {
296        let config = Self::resolve_config(id, &manifest.config, self.host_project_root())?;
297        self.load_with_cwd_and_config(id, manifest, cwd, config)
298            .await
299    }
300
301    /// Enable Task 20 progressive spawn deferral (set at engine boot from
302    /// the progressive-disclosure flag; default false keeps every legacy
303    /// eager path byte-for-byte).
304    pub fn set_progressive_deferral(&mut self, enabled: bool) {
305        self.progressive_deferral = enabled;
306    }
307
308    /// The HOST-OWNED trusted project root, discovered ONCE per manager
309    /// from the host's own working directory via the canonical
310    /// `agent_core` project-scope walk (nearest `.git`/`.synaps-project`
311    /// marker, canonicalized). Recorded here so every extension load in
312    /// this manager's lifetime sees one stable value. `None` when the
313    /// working directory is unavailable or cannot be canonicalized.
314    pub fn host_project_root(&self) -> Option<&str> {
315        self.host_project_root
316            .get_or_init(|| {
317                let cwd = std::env::current_dir().ok()?;
318                agent_core::memory::store::ProjectScope::discover(&cwd)
319                    .ok()
320                    .map(|scope| scope.root().display().to_string())
321            })
322            .as_deref()
323    }
324
325    /// Whether one plugin id is currently deferred (any Task 20 class:
326    /// tool-only, provider, hook/lifecycle, UI/sidecar, mixed — zero
327    /// spawn). Boolean-only diagnostic: the underlying launch record
328    /// (which holds resolved — potentially secret — config) is never
329    /// exposed.
330    pub fn is_deferred(&self, id: &str) -> bool {
331        self.deferred_records()
332            .lock()
333            .unwrap_or_else(std::sync::PoisonError::into_inner)
334            .contains_key(id)
335    }
336
337    /// Compatibility alias for [`Self::is_deferred`] (Commit B name).
338    pub fn is_deferred_tool_only(&self, id: &str) -> bool {
339        self.is_deferred(id)
340    }
341
342    /// Number of retained deferred tool-only launch records (count-only
343    /// diagnostic; never the records).
344    pub fn deferred_tool_only_count(&self) -> usize {
345        self.deferred_records()
346            .lock()
347            .unwrap_or_else(std::sync::PoisonError::into_inner)
348            .len()
349    }
350
351    fn deferred_records(&self) -> &SharedDeferredRecords {
352        &self.deferred_tool_only
353    }
354
355    /// Republish the owned context-provider catalog into the shared
356    /// snapshot the lease manager reads (task A6). Called after EVERY
357    /// catalog mutation so `resolve_context_provider` always observes the
358    /// current loaded set — a removed extension's providers can never be
359    /// resolved from a stale snapshot.
360    fn publish_context_providers(&self) {
361        let mut published = self
362            .context_provider_catalog
363            .lock()
364            .unwrap_or_else(std::sync::PoisonError::into_inner);
365        *published = self.context_providers.clone();
366    }
367
368    /// Catalog one loaded extension's DORMANT context-provider
369    /// declarations (task A6) via the task-A3 validated builder. Pure
370    /// metadata registration — spawns nothing; invalid or ungated
371    /// declaration sets yield no descriptors (fail closed inside
372    /// [`dormant_context_provider_descriptors`]).
373    fn catalog_context_providers(&mut self, id: &str, manifest: &ExtensionManifest) {
374        let descriptors = dormant_context_provider_descriptors(id, manifest);
375        if descriptors.is_empty() {
376            return;
377        }
378        self.context_providers.extend(descriptors);
379        self.publish_context_providers();
380    }
381
382    /// Drop every cataloged context-provider descriptor owned by one
383    /// extension (unload/teardown path, task A6) and republish.
384    fn uncatalog_context_providers(&mut self, id: &str) {
385        let before = self.context_providers.len();
386        self.context_providers
387            .retain(|descriptor| descriptor.extension_id() != id);
388        if self.context_providers.len() != before {
389            self.publish_context_providers();
390        }
391    }
392
393    /// The SHARED extension runtime lease manager (Task 20 Commit B),
394    /// created on first use against this manager's retained launch
395    /// records. The engine installs the same instance on the `Runtime`
396    /// (session capabilities + durable session-end scope); this manager
397    /// uses it for unload/reload lease revocation. Runtime acquisition —
398    /// spawn, re-validation, exact declaration matching — happens ONLY
399    /// inside that manager's lease lifecycle.
400    pub fn extension_runtime(&self) -> Arc<crate::extensions::lease::ExtensionRuntimeManager> {
401        self.extension_runtime_with_idle(crate::extensions::lease::DEFAULT_IDLE_MAX)
402    }
403
404    /// As [`Self::extension_runtime`], but the FIRST caller fixes the idle
405    /// reap bound (tests use short bounds; production uses the default).
406    pub fn extension_runtime_with_idle(
407        &self,
408        idle_max: std::time::Duration,
409    ) -> Arc<crate::extensions::lease::ExtensionRuntimeManager> {
410        Arc::clone(self.extension_runtime.get_or_init(|| {
411            Arc::new(crate::extensions::lease::ExtensionRuntimeManager::new(
412                Arc::clone(&self.deferred_tool_only),
413                Arc::clone(&self.context_provider_catalog),
414                idle_max,
415            ))
416        }))
417    }
418
419    /// Test-only internal introspection: clone one retained launch record
420    /// (the lease manager reads the shared records handle directly).
421    #[cfg(test)]
422    pub(crate) fn deferred_launch_record(&self, id: &str) -> Option<DeferredExtensionRecord> {
423        self.deferred_records()
424            .lock()
425            .unwrap_or_else(std::sync::PoisonError::into_inner)
426            .get(id)
427            .cloned()
428    }
429
430    /// Test seam: tamper the retained manifest permissions of one deferred
431    /// record IN PLACE (simulates internal-state drift the public API can
432    /// never produce, to prove acquisition re-validation fails closed).
433    /// Reveals nothing.
434    #[doc(hidden)]
435    pub fn tamper_deferred_manifest_permissions_for_tests(
436        &self,
437        id: &str,
438        permissions: Vec<String>,
439    ) -> bool {
440        let mut map = self
441            .deferred_tool_only
442            .lock()
443            .unwrap_or_else(std::sync::PoisonError::into_inner);
444        match map.get_mut(id) {
445            Some(record) => {
446                record.manifest.permissions = permissions;
447                true
448            }
449            None => false,
450        }
451    }
452
453    /// Test seam: tamper the retained RESOLVED config of one deferred
454    /// record in place (simulates launch-record/config drift under a live
455    /// lease). Reveals nothing.
456    #[doc(hidden)]
457    pub fn tamper_deferred_config_for_tests(&self, id: &str, config: Value) -> bool {
458        let mut map = self
459            .deferred_tool_only
460            .lock()
461            .unwrap_or_else(std::sync::PoisonError::into_inner);
462        match map.get_mut(id) {
463            Some(record) => {
464                record.config = config;
465                true
466            }
467            None => false,
468        }
469    }
470
471    async fn load_with_cwd_and_config(
472        &mut self,
473        id: &str,
474        manifest: &ExtensionManifest,
475        cwd: Option<std::path::PathBuf>,
476        config: Value,
477    ) -> Result<(), String> {
478        // Don't load duplicates (live OR deferred).
479        if self.extensions.contains_key(id) || self.is_deferred_tool_only(id) {
480            return Err(format!("Extension '{}' is already loaded", id));
481        }
482
483        // Validate permissions and hook subscriptions before spawning the
484        // extension process. This keeps malformed manifests from leaking child
485        // processes when a later subscription step fails.
486        let validated = manifest.validate(id)?;
487        let permissions = validated.permissions;
488        let subscriptions = validated.subscriptions;
489
490        // Task 20: under progressive disclosure, EVERY manifest with a
491        // validated `deferred` block defers its spawn entirely (spec §7.5):
492        //  - tool-only: dormant descriptor tools cataloged; exact
493        //    activation acquires the lease;
494        //  - provider: declared provider METADATA registered with a lazy
495        //    handler; first selected complete/stream acquires;
496        //  - hook/lifecycle: permission-validated subscriptions registered
497        //    with a lazy handler; the first AUTHORIZED matching event
498        //    acquires;
499        //  - UI/sidecar: record retained only; explicit user APIs acquire;
500        //  - mixed: all of the above against ONE shared per-plugin lease
501        //    (earliest legitimate capability starts it; tool SEARCH alone
502        //    never does).
503        // Legacy manifests (no `deferred` block) and flag-off stay on the
504        // documented eager path byte-for-byte.
505        let class = crate::extensions::lifecycle::classify(manifest);
506        if self.progressive_deferral
507            && class != crate::extensions::lifecycle::ExtensionClass::LegacyEager
508        {
509            let declared_tool_names: Vec<String> = manifest
510                .deferred
511                .as_ref()
512                .map(|d| d.tools.iter().map(|t| format!("{id}:{}", t.name)).collect())
513                .unwrap_or_default();
514            let dormant = crate::extensions::lifecycle::dormant_extension_tools(id, manifest);
515            let tool_count = dormant.len();
516            if !dormant.is_empty() {
517                let Some(tools) = &self.tools else {
518                    return Err(format!(
519                        "Extension '{}' declares deferred tools but no tool registry is available",
520                        id
521                    ));
522                };
523                tools
524                    .write()
525                    .await
526                    .try_register_batch(dormant)
527                    .map_err(|e| {
528                        format!("Extension '{}' deferred tool catalog rejected: {}", id, e)
529                    })?;
530            }
531            // Retain the launch record BEFORE registering lazy capability
532            // surfaces so a capability firing immediately can acquire.
533            self.deferred_tool_only
534                .lock()
535                .unwrap_or_else(std::sync::PoisonError::into_inner)
536                .insert(
537                    id.to_string(),
538                    DeferredExtensionRecord {
539                        manifest: manifest.clone(),
540                        cwd,
541                        config: config.clone(),
542                    },
543                );
544            // Bounded unwind shared by the failure paths below: remove the
545            // record, the dormant batch, and any provider metadata.
546            macro_rules! unwind_deferred {
547                () => {{
548                    self.deferred_tool_only
549                        .lock()
550                        .unwrap_or_else(std::sync::PoisonError::into_inner)
551                        .remove(id);
552                    self.providers.unregister_plugin(id);
553                    if let Some(tools) = &self.tools {
554                        let _ = tools.write().await.try_disable(&declared_tool_names);
555                    }
556                    self.hook_bus.unsubscribe_all(id).await;
557                }};
558            }
559            let lazy: Option<Arc<dyn ExtensionHandler>> = {
560                // Created only when a lazy capability surface exists, so a
561                // tool-only load never pre-initializes the runtime manager
562                // (its idle policy stays first-caller-configurable).
563                let needs_lazy = manifest
564                    .deferred
565                    .as_ref()
566                    .map(|d| !d.providers.is_empty())
567                    .unwrap_or(false)
568                    || !manifest.hooks.is_empty();
569                needs_lazy.then(|| {
570                    Arc::new(crate::extensions::lease::LazyExtensionHandler::new(
571                        id,
572                        self.extension_runtime(),
573                    )) as Arc<dyn ExtensionHandler>
574                })
575            };
576            // Declared provider METADATA (validated shape) registered with
577            // the lazy handler; routing re-validates the live runtime's
578            // declarations against the manifest before any call.
579            let declared_providers: Vec<_> = manifest
580                .deferred
581                .as_ref()
582                .map(|d| d.providers.clone())
583                .unwrap_or_default();
584            let mut registered_provider_ids = Vec::new();
585            for declared in &declared_providers {
586                let spec = declared.to_registered_spec();
587                if let Err(error) = Self::validate_provider_config_requirements(id, &spec, &config)
588                {
589                    unwind_deferred!();
590                    return Err(error);
591                }
592                let handler = lazy.clone().expect("providers imply a lazy handler");
593                match self
594                    .providers
595                    .register_with_handler(id, spec, Some(handler))
596                {
597                    Ok(runtime_id) => registered_provider_ids.push(runtime_id),
598                    Err(error) => {
599                        unwind_deferred!();
600                        return Err(error);
601                    }
602                }
603            }
604            // Tool-use audit parity with the eager path.
605            for runtime_id in &registered_provider_ids {
606                if let Some(provider) = self.providers.get(runtime_id) {
607                    let tool_use = provider.spec.models.iter().any(|m| {
608                        m.capabilities
609                            .get("tool_use")
610                            .and_then(|v| v.as_bool())
611                            .unwrap_or(false)
612                    });
613                    if tool_use {
614                        tracing::warn!(
615                            "Provider '{}' is tool-use capable: it can request Synaps tools through provider mediation. Use `/extensions trust disable {}` to block routing.",
616                            runtime_id,
617                            runtime_id,
618                        );
619                    }
620                }
621            }
622            // Permission-validated hook subscriptions with the lazy
623            // handler: the first AUTHORIZED matching event acquires.
624            for (kind, tool_filter, matcher) in subscriptions {
625                let handler = lazy.clone().expect("subscriptions imply a lazy handler");
626                if let Err(error) = self
627                    .hook_bus
628                    .subscribe(kind, handler, tool_filter, matcher, permissions.clone())
629                    .await
630                {
631                    unwind_deferred!();
632                    return Err(error);
633                }
634            }
635            self.manifest_configs
636                .insert(id.to_string(), manifest.config.clone());
637            if !manifest.theme_tokens.is_empty() {
638                self.manifest_theme_tokens
639                    .insert(id.to_string(), manifest.theme_tokens.clone());
640            }
641            // Task A6: catalog DORMANT context-provider declarations LAST —
642            // every fallible registration step above has succeeded, so the
643            // unwind path never needs to reason about a half-published
644            // catalog. Metadata only; nothing spawns.
645            self.catalog_context_providers(id, manifest);
646            tracing::info!(extension = %id, class = ?class, tools = tool_count,
647                providers = registered_provider_ids.len(), hooks = manifest.hooks.len(),
648                context_providers = self.context_providers.iter().filter(|d| d.extension_id() == id).count(),
649                "Extension deferred: passive surfaces registered, no process started");
650            return Ok(());
651        }
652
653        // Spawn the extension process only after the manifest is known-good.
654        let process =
655            ProcessExtension::spawn_with_cwd(id, &manifest.command, &manifest.args, cwd.clone())
656                .await?;
657        // Publish permissions to the inbound-request dispatcher so memory.*
658        // calls during initialize can be authorized correctly.
659        process.set_permissions(permissions.clone()).await;
660        let capabilities = match process.initialize(cwd.clone(), config.clone()).await {
661            Ok(capabilities) => capabilities,
662            Err(error) => {
663                process.shutdown().await;
664                return Err(error);
665            }
666        };
667        let registered_tools = capabilities.tools;
668        let registered_providers = capabilities.providers;
669        let capability_declarations = capabilities.capabilities;
670        let should_probe_info = !registered_tools.is_empty()
671            || !registered_providers.is_empty()
672            || !capability_declarations.is_empty();
673        let handler: Arc<dyn ExtensionHandler> = Arc::new(process);
674        if !registered_tools.is_empty()
675            && !permissions.has(crate::extensions::permissions::Permission::ToolsRegister)
676        {
677            handler.shutdown().await;
678            return Err(format!(
679                "Extension '{}' registered tools but lacks permission 'tools.register'",
680                id
681            ));
682        }
683        if !registered_providers.is_empty()
684            && !permissions.has(crate::extensions::permissions::Permission::ProvidersRegister)
685        {
686            handler.shutdown().await;
687            return Err(format!(
688                "Extension '{}' registered providers but lacks permission 'providers.register'",
689                id
690            ));
691        }
692        for decl in &capability_declarations {
693            if let Err(err) =
694                crate::extensions::runtime::process::validate_capability(decl, &permissions)
695            {
696                handler.shutdown().await;
697                return Err(format!(
698                    "Extension '{}' capability '{}' invalid: {}",
699                    id, decl.kind, err
700                ));
701            }
702        }
703        if !registered_providers.is_empty() {
704            let mut registered_ids = Vec::new();
705            for provider in registered_providers {
706                if let Err(error) =
707                    Self::validate_provider_config_requirements(id, &provider, &config)
708                {
709                    self.providers.unregister_plugin(id);
710                    handler.shutdown().await;
711                    return Err(error);
712                }
713                match self
714                    .providers
715                    .register_with_handler(id, provider, Some(handler.clone()))
716                {
717                    Ok(runtime_id) => registered_ids.push(runtime_id),
718                    Err(error) => {
719                        self.providers.unregister_plugin(id);
720                        handler.shutdown().await;
721                        return Err(error);
722                    }
723                }
724            }
725            tracing::info!(extension = %id, providers = ?registered_ids, "Extension provider metadata registered");
726            // Warn for tool-use-capable providers so authors and users can audit them.
727            for runtime_id in &registered_ids {
728                if let Some(provider) = self.providers.get(runtime_id) {
729                    let tool_use = provider.spec.models.iter().any(|m| {
730                        m.capabilities
731                            .get("tool_use")
732                            .and_then(|v| v.as_bool())
733                            .unwrap_or(false)
734                    });
735                    if tool_use {
736                        tracing::warn!(
737                            "Provider '{}' is tool-use capable: it can request Synaps tools through provider mediation. Use `/extensions trust disable {}` to block routing.",
738                            runtime_id,
739                            runtime_id,
740                        );
741                    }
742                }
743            }
744        }
745        if !registered_tools.is_empty() {
746            let Some(tools) = &self.tools else {
747                handler.shutdown().await;
748                return Err(format!(
749                    "Extension '{}' registered tools but no tool registry is available",
750                    id
751                ));
752            };
753            let mut registry = tools.write().await;
754            for spec in registered_tools {
755                let tool_name = spec.name.clone();
756                if let Err(e) = registry.try_register(Arc::new(crate::tools::ExtensionTool::new(
757                    id,
758                    spec,
759                    handler.clone(),
760                ))) {
761                    tracing::warn!(
762                        extension = %id,
763                        tool = %tool_name,
764                        error = %e,
765                        "Refusing to expose extension tool the capability catalog could not record"
766                    );
767                }
768            }
769        }
770
771        // Do not probe optional info.get for legacy hook-only extensions. The
772        // best-effort call can race with simple fixtures that exit after
773        // shutdown/EOF and is only needed for richer extension-capability
774        // surfaces (providers/tools/plugin-defined capabilities).
775        let info = if should_probe_info {
776            match handler.get_info().await {
777                Ok(info) => Some(info),
778                Err(error) => {
779                    if error.contains("method not found") || error.contains("unknown method") {
780                        tracing::debug!(
781                            extension = %id,
782                            error = %error,
783                            "Extension did not provide optional info.get metadata",
784                        );
785                        None
786                    } else {
787                        tracing::warn!(
788                            extension = %id,
789                            error = %error,
790                            "Ignoring invalid optional info.get metadata",
791                        );
792                        None
793                    }
794                }
795            }
796        } else {
797            None
798        };
799
800        // Register hook subscriptions
801        for (kind, tool_filter, matcher) in subscriptions {
802            self.hook_bus
803                .subscribe(
804                    kind,
805                    handler.clone(),
806                    tool_filter,
807                    matcher,
808                    permissions.clone(),
809                )
810                .await?;
811        }
812
813        self.extensions.insert(id.to_string(), handler);
814        self.manifest_configs
815            .insert(id.to_string(), manifest.config.clone());
816        if !manifest.theme_tokens.is_empty() {
817            self.manifest_theme_tokens
818                .insert(id.to_string(), manifest.theme_tokens.clone());
819        }
820        if !capability_declarations.is_empty() {
821            self.capabilities
822                .insert(id.to_string(), capability_declarations);
823        }
824        if let Some(info) = info {
825            self.plugin_info.insert(id.to_string(), info);
826        }
827        // Task A6: catalog validated DORMANT `deferred.context_providers`
828        // declarations for eagerly-loaded extensions too, so the context-
829        // provider catalog reflects EVERY loaded extension regardless of
830        // lifecycle class. Metadata only — the process above was spawned by
831        // the legacy eager path, never by this catalog write.
832        self.catalog_context_providers(id, manifest);
833        tracing::info!(extension = %id, hooks = manifest.hooks.len(), "Extension loaded");
834        Ok(())
835    }
836
837    fn validate_provider_config_requirements(
838        id: &str,
839        provider: &crate::extensions::runtime::process::RegisteredProviderSpec,
840        config: &Value,
841    ) -> Result<(), String> {
842        let Some(required) = provider
843            .config_schema
844            .as_ref()
845            .and_then(|schema| schema.get("required"))
846            .and_then(Value::as_array)
847        else {
848            return Ok(());
849        };
850        for key in required {
851            let Some(key) = key.as_str() else {
852                return Err(format!(
853                    "Extension '{}' provider '{}' config_schema.required must contain only strings",
854                    id, provider.id,
855                ));
856            };
857            let present = config
858                .as_object()
859                .map(|map| map.contains_key(key))
860                .unwrap_or(false);
861            if !present {
862                return Err(format!(
863                    "Extension '{}' provider '{}' missing required provider config '{}'",
864                    id, provider.id, key,
865                ));
866            }
867        }
868        Ok(())
869    }
870
871    /// Env vars an extension may never pull in through `secret_env`.
872    ///
873    /// Security (#207 MED-2): `secret_env` names an arbitrary process
874    /// environment variable, and [`Self::resolve_config`] reads it verbatim
875    /// into the config handed to the extension at `initialize`. Nothing
876    /// constrained that name, so a manifest could declare the HOST's own
877    /// provider credential — `secret_env: "ANTHROPIC_API_KEY"` — and a plugin
878    /// installed for something unrelated would be handed the user's API key.
879    ///
880    /// Default-deny the credentials the host itself holds, plus the whole
881    /// `SYNAPS_*` host namespace except this extension's own scoped override.
882    /// Third-party secrets an extension legitimately needs (`GITHUB_TOKEN`,
883    /// and friends) are unaffected.
884    fn denied_secret_env(var: &str, id: &str) -> Option<&'static str> {
885        const HOST_CREDENTIALS: &[&str] = &[
886            "ANTHROPIC_API_KEY",
887            "OPENAI_API_KEY",
888            "GEMINI_API_KEY",
889            "GOOGLE_API_KEY",
890            "XAI_API_KEY",
891            "GROQ_API_KEY",
892            "OPENROUTER_API_KEY",
893            "DEEPSEEK_API_KEY",
894            "MISTRAL_API_KEY",
895            "AWS_SECRET_ACCESS_KEY",
896            "AWS_SESSION_TOKEN",
897        ];
898        let up = var.trim().to_ascii_uppercase();
899        if up.is_empty() {
900            return Some("secret_env must not be empty");
901        }
902        if HOST_CREDENTIALS.contains(&up.as_str()) {
903            return Some(
904                "that variable holds a host credential; extensions are never handed the host's own provider keys",
905            );
906        }
907        let own_prefix = format!(
908            "SYNAPS_EXTENSION_{}_",
909            id.replace('-', "_").to_ascii_uppercase()
910        );
911        if up.starts_with("SYNAPS_") && !up.starts_with(&own_prefix) {
912            return Some(
913                "SYNAPS_* is the host namespace; declare your own SYNAPS_EXTENSION_<ID>_<KEY> variable instead",
914            );
915        }
916        None
917    }
918
919    fn resolve_config(
920        id: &str,
921        entries: &[ExtensionConfigEntry],
922        host_project_root: Option<&str>,
923    ) -> Result<Value, String> {
924        let mut out = Map::new();
925        for entry in entries {
926            let key = entry.key.trim();
927            if key.is_empty() {
928                return Err(format!("Extension '{}' declares config with empty key", id));
929            }
930            if key.contains('.') || key.contains('/') || key.contains(' ') {
931                return Err(format!(
932                    "Extension '{}' config key '{}' must not contain dots, slashes, or spaces",
933                    id, key,
934                ));
935            }
936            // RESERVED host-context entries: the value comes EXCLUSIVELY
937            // from trusted host state recorded once by this manager. Env
938            // overrides, secret_env, plugin config, and legacy config keys
939            // are all deliberately skipped — user or model input can never
940            // supply or widen a host-context value.
941            if let Some(host_key) = entry.host_context {
942                if entry.secret_env.is_some() {
943                    return Err(format!(
944                        "Extension '{}' config key '{}' declares both host_context and secret_env; host-context values never carry secrets",
945                        id, key,
946                    ));
947                }
948                match host_key {
949                    crate::extensions::manifest::HostContextKey::ProjectRoot => {
950                        if let Some(root) = host_project_root {
951                            out.insert(key.to_string(), Value::String(root.to_string()));
952                        } else if let Some(default) = &entry.default {
953                            out.insert(key.to_string(), default.clone());
954                        } else if entry.required {
955                            return Err(format!(
956                                "Extension '{}' requires host context 'project_root' but the host could not resolve a trusted project root",
957                                id,
958                            ));
959                        }
960                    }
961                }
962                continue;
963            }
964            let config_key = format!("extension.{}.{}", id, key);
965            // Security (#207 MED-2): validate the declared env var name BEFORE
966            // any resolution path can read it. Fails at load, loudly, rather
967            // than silently handing a host credential to the extension.
968            if let Some(secret_env) = &entry.secret_env {
969                if let Some(reason) = Self::denied_secret_env(secret_env, id) {
970                    return Err(format!(
971                        "Extension '{}' config key '{}' declares secret_env '{}': {}",
972                        id, key, secret_env, reason
973                    ));
974                }
975            }
976            if let Ok(value) = std::env::var(format!(
977                "SYNAPS_EXTENSION_{}_{}",
978                id.replace('-', "_").to_ascii_uppercase(),
979                key.replace('-', "_").to_ascii_uppercase()
980            )) {
981                out.insert(key.to_string(), Value::String(value));
982                continue;
983            }
984            if let Some(secret_env) = &entry.secret_env {
985                if let Ok(value) = std::env::var(secret_env) {
986                    out.insert(key.to_string(), Value::String(value));
987                    continue;
988                }
989            }
990            if let Some(value) = crate::extensions::config_store::read_plugin_config(id, key) {
991                out.insert(key.to_string(), Value::String(value));
992                continue;
993            }
994            if let Some(value) = crate::config::read_config_value(&config_key) {
995                out.insert(key.to_string(), Value::String(value));
996                continue;
997            }
998            if let Some(default) = &entry.default {
999                out.insert(key.to_string(), default.clone());
1000                continue;
1001            }
1002            if entry.required {
1003                let hint = if let Some(secret_env) = &entry.secret_env {
1004                    format!(
1005                        "set environment variable '{}' or config key '{}'",
1006                        secret_env, config_key
1007                    )
1008                } else {
1009                    format!("set config key '{}'", config_key)
1010                };
1011                return Err(format!(
1012                    "Extension '{}' missing required config '{}': {}",
1013                    id, key, hint
1014                ));
1015            }
1016        }
1017        Ok(Value::Object(out))
1018    }
1019
1020    /// Test-only seeder: synthetically insert capability declarations
1021    /// for an extension id. Used to exercise capability snapshot
1022    /// rendering without spinning up a real plugin process.
1023    #[cfg(test)]
1024    pub(crate) fn test_seed_capabilities(
1025        &mut self,
1026        id: &str,
1027        decls: Vec<crate::extensions::runtime::process::CapabilityDeclaration>,
1028    ) {
1029        self.capabilities.insert(id.to_string(), decls);
1030    }
1031
1032    /// Unload an extension — unsubscribe hooks and shut down the process.
1033    ///
1034    /// A DEFERRED tool-only extension (Task 20) has no live handler:
1035    /// unloading it removes the retained launch record, deregisters its
1036    /// dormant catalog batch (advancing the catalog generation so stale
1037    /// grants cannot survive), and terminates every session's runtime
1038    /// lease for the plugin.
1039    pub async fn unload(&mut self, id: &str) -> Result<(), String> {
1040        let deferred = self
1041            .deferred_tool_only
1042            .lock()
1043            .unwrap_or_else(std::sync::PoisonError::into_inner)
1044            .remove(id);
1045        if let Some(record) = deferred {
1046            // Record removed FIRST: any concurrent lease acquisition now
1047            // fails closed (NotDeferred) regardless of the steps below.
1048            // Context providers go with it (task A6): once unloading has
1049            // begun, a memory-context enable can no longer resolve this
1050            // extension's declared providers.
1051            self.uncatalog_context_providers(id);
1052            if let Some(runtime) = self.extension_runtime.get() {
1053                let revoked = runtime.revoke_plugin_all_sessions(id);
1054                if revoked > 0 {
1055                    tracing::info!(extension = %id, leases = revoked,
1056                        "Terminated live runtime leases of unloaded deferred extension");
1057                }
1058            }
1059            self.hook_bus.unsubscribe_all(id).await;
1060            self.providers.unregister_plugin(id);
1061            if let Some(tools) = &self.tools {
1062                let names: Vec<String> = record
1063                    .manifest
1064                    .deferred
1065                    .as_ref()
1066                    .map(|d| {
1067                        d.tools
1068                            .iter()
1069                            .map(|t| format!("{}:{}", id, t.name))
1070                            .collect()
1071                    })
1072                    .unwrap_or_default();
1073                if let Err(error) = tools.write().await.try_disable(&names) {
1074                    // Fail-closed either way: with the record gone, dormant
1075                    // descriptors can never execute; surface the partial
1076                    // cleanup instead of hiding it.
1077                    return Err(format!(
1078                        "Extension '{}' unloaded (record removed, leases revoked) but its dormant catalog batch could not be deregistered: {}",
1079                        id, error
1080                    ));
1081                }
1082            }
1083            self.manifest_configs.remove(id);
1084            self.manifest_theme_tokens.remove(id);
1085            tracing::info!(extension = %id, "Deferred extension unloaded");
1086            return Ok(());
1087        }
1088        let handler = self
1089            .extensions
1090            .remove(id)
1091            .ok_or_else(|| format!("Extension '{}' not found", id))?;
1092        // Task A6: cataloged context providers of a live extension are
1093        // removed with it.
1094        self.uncatalog_context_providers(id);
1095
1096        self.hook_bus.unsubscribe_all(id).await;
1097        self.providers.unregister_plugin(id);
1098        self.manifest_configs.remove(id);
1099        self.capabilities.remove(id);
1100        self.plugin_info.remove(id);
1101        self.manifest_theme_tokens.remove(id);
1102        handler.shutdown().await;
1103
1104        tracing::info!(extension = %id, "Extension unloaded");
1105        Ok(())
1106    }
1107
1108    /// Reload one extension by unloading any existing instance first, then loading
1109    /// the supplied manifest. If the new load fails, the previous instance remains
1110    /// unloaded so duplicate handlers cannot survive a broken reload.
1111    pub async fn reload(
1112        &mut self,
1113        id: &str,
1114        manifest: &ExtensionManifest,
1115        cwd: Option<std::path::PathBuf>,
1116    ) -> Result<(), String> {
1117        if self.extensions.contains_key(id) || self.is_deferred_tool_only(id) {
1118            self.unload(id).await?;
1119        }
1120        self.load_with_cwd(id, manifest, cwd).await
1121    }
1122
1123    /// Shut down all extensions gracefully.
1124    pub async fn shutdown_all(&mut self) {
1125        let ids: Vec<String> = self.extensions.keys().cloned().collect();
1126        for id in ids {
1127            let _ = self.unload(&id).await;
1128        }
1129        // Deferred extensions (Task 20): unload each retained record so
1130        // dormant catalog batches and any live runtime leases go with it.
1131        let deferred_ids: Vec<String> = {
1132            let map = self
1133                .deferred_tool_only
1134                .lock()
1135                .unwrap_or_else(std::sync::PoisonError::into_inner);
1136            map.keys().cloned().collect()
1137        };
1138        for id in deferred_ids {
1139            let _ = self.unload(&id).await;
1140        }
1141        if let Some(runtime) = self.extension_runtime.get() {
1142            runtime.terminate_all();
1143        }
1144    }
1145
1146    /// Start shutting down all extensions in the background.
1147    ///
1148    /// This is intended for process exit: the UI should not hang waiting for
1149    /// extension child processes to acknowledge shutdown. Dropping the join handle
1150    /// lets Tokio abort remaining work when the runtime exits.
1151    pub fn shutdown_all_detached(
1152        manager: Arc<tokio::sync::RwLock<Self>>,
1153    ) -> tokio::task::JoinHandle<()> {
1154        tokio::spawn(async move {
1155            manager.write().await.shutdown_all().await;
1156        })
1157    }
1158
1159    /// List running extension IDs.
1160    pub fn list(&self) -> Vec<&str> {
1161        self.extensions.keys().map(|s| s.as_str()).collect()
1162    }
1163
1164    /// Return Arc references to all running extension handlers, sorted by ID.
1165    /// Intended for background notification watchers that need to hold onto
1166    /// handlers beyond the lifetime of a manager lock.
1167    pub fn handlers(&self) -> Vec<(String, Arc<dyn super::runtime::ExtensionHandler>)> {
1168        let mut out: Vec<_> = self
1169            .extensions
1170            .iter()
1171            .map(|(id, h)| (id.clone(), Arc::clone(h)))
1172            .collect();
1173        out.sort_by(|a, b| a.0.cmp(&b.0));
1174        out
1175    }
1176
1177    /// Number of running extensions.
1178    pub fn count(&self) -> usize {
1179        self.extensions.len()
1180    }
1181
1182    /// Return health snapshots for all loaded extensions, sorted by ID.
1183    pub async fn statuses(&self) -> Vec<ExtensionStatus> {
1184        let mut handlers: Vec<(String, Arc<dyn ExtensionHandler>)> = self
1185            .extensions
1186            .iter()
1187            .map(|(id, handler)| (id.clone(), handler.clone()))
1188            .collect();
1189        handlers.sort_by(|a, b| a.0.cmp(&b.0));
1190
1191        let mut statuses = Vec::with_capacity(handlers.len());
1192        for (id, handler) in handlers {
1193            statuses.push(ExtensionStatus {
1194                id,
1195                health: handler.health().await,
1196                restart_count: handler.restart_count().await,
1197            });
1198        }
1199        statuses
1200    }
1201
1202    /// Return registered provider metadata sorted by runtime id.
1203    pub fn providers(&self) -> Vec<&RegisteredProvider> {
1204        self.providers.list()
1205    }
1206
1207    /// Return registered provider metadata by runtime id.
1208    pub fn provider(&self, runtime_id: &str) -> Option<&RegisteredProvider> {
1209        self.providers.get(runtime_id)
1210    }
1211
1212    /// Return DORMANT context-provider descriptors declared by loaded
1213    /// extensions, sorted by runtime address (task A6; mirrors
1214    /// [`Self::providers`] for the UNRELATED model-provider catalog).
1215    /// Pure catalog read — discovery/status spawn zero processes, and
1216    /// holding a descriptor grants nothing.
1217    pub fn context_providers(&self) -> Vec<&RegisteredContextProviderDescriptor> {
1218        let mut out: Vec<&RegisteredContextProviderDescriptor> =
1219            self.context_providers.iter().collect();
1220        out.sort_by_key(|descriptor| descriptor.runtime_address());
1221        out
1222    }
1223
1224    /// Exact context-provider lookup by declared id (task A6; mirrors
1225    /// [`Self::provider`]). `Some` iff EXACTLY ONE loaded extension
1226    /// declares `provider_id` — zero matches and overlapping declarations
1227    /// (the same id declared by multiple installed extensions) both
1228    /// resolve to `None`, fail closed, no partial matches. Pure catalog
1229    /// read — never spawns.
1230    pub fn context_provider(
1231        &self,
1232        provider_id: &ContextProviderId,
1233    ) -> Option<&RegisteredContextProviderDescriptor> {
1234        resolve_context_provider(&self.context_providers, Some(provider_id)).ok()
1235    }
1236
1237    /// Return optional cached plugin info reported by `info.get`.
1238    pub fn plugin_info(&self, id: &str) -> Option<&PluginInfo> {
1239        self.plugin_info.get(id)
1240    }
1241
1242    /// Manifest-declared theme tokens for every loaded extension, sorted by
1243    /// extension ID (P19.2). Each entry is `(plugin-id, token -> hex color)`;
1244    /// the TUI namespaces these as `ext.<plugin-id>.<token>` when merging
1245    /// into the active theme. Extensions without `theme_tokens` don't appear.
1246    pub fn theme_tokens(&self) -> Vec<(String, std::collections::BTreeMap<String, String>)> {
1247        let mut out: Vec<_> = self
1248            .manifest_theme_tokens
1249            .iter()
1250            .map(|(id, tokens)| (id.clone(), tokens.clone()))
1251            .collect();
1252        out.sort_by(|a, b| a.0.cmp(&b.0));
1253        out
1254    }
1255
1256    /// Ask a plugin for its sidecar spawn arguments. Best-effort —
1257    /// plugins that don't host a sidecar (or pre-Phase-7 plugins that
1258    /// haven't implemented the RPC yet) return `Err`. Callers are
1259    /// expected to treat that as "no overrides; use manifest defaults".
1260    /// Handler for explicit USER-triggered plugin APIs (sidecar spawn,
1261    /// interactive commands, settings editors). Live extensions use their
1262    /// running handler; a DEFERRED extension (Task 20) gets a lazy handler
1263    /// whose first use acquires the shared per-plugin runtime lease — a
1264    /// user action is a legitimate activation trigger. Discovery/search/
1265    /// diagnostics never call this.
1266    fn user_action_handler(
1267        &self,
1268        id: &str,
1269    ) -> Result<Arc<dyn super::runtime::ExtensionHandler>, String> {
1270        if let Some(handler) = self.extensions.get(id) {
1271            return Ok(handler.clone());
1272        }
1273        if self.is_deferred(id) {
1274            return Ok(Arc::new(
1275                crate::extensions::lease::LazyExtensionHandler::new(id, self.extension_runtime()),
1276            ));
1277        }
1278        Err(format!("unknown extension '{}'", id))
1279    }
1280
1281    pub async fn sidecar_spawn_args(
1282        &self,
1283        id: &str,
1284    ) -> Result<crate::sidecar::spawn::SidecarSpawnArgs, String> {
1285        let handler = self.user_action_handler(id)?;
1286        handler.sidecar_spawn_args().await
1287    }
1288
1289    /// Invoke an interactive plugin command on extension `id`. Streams
1290    /// `command.output` (matching `request_id`) and `task.*` notifications
1291    /// to `sink`. Returns the final JSON-RPC response value.
1292    ///
1293    /// CP-11 fix-3: `sink` is the bounded, metered producer half of
1294    /// [`crate::extensions::invoke_output::invoke_event_channel`]; the
1295    /// caller MUST drive the matching collector concurrently with this
1296    /// call (an awaited bounded send with no consumer would stall the
1297    /// invocation loop until the 120 s timeout). Prefer
1298    /// [`Self::invoke_command_collected`], which wires that correctly.
1299    pub async fn invoke_command(
1300        &self,
1301        id: &str,
1302        command: &str,
1303        args: Vec<String>,
1304        request_id: &str,
1305        sink: crate::extensions::invoke_output::InvokeEventSink,
1306    ) -> Result<serde_json::Value, String> {
1307        let handler = self.user_action_handler(id)?;
1308        handler
1309            .invoke_command(command, args, request_id, sink)
1310            .await
1311    }
1312
1313    /// Invoke an interactive plugin command and collect its budgeted
1314    /// output. This is THE production entry point for user-facing
1315    /// surfaces: it joins the invocation with an eagerly concurrent
1316    /// collector so hostile `command.output` floods are paced and capped
1317    /// at production time (never parked unbounded), then returns the
1318    /// final response alongside the retained events and exact
1319    /// produced/consumed/retained/truncated/dropped accounting.
1320    ///
1321    /// Dropping the returned future cancels the invocation, drops the
1322    /// sink, and releases the collector plus any blocked producers — no
1323    /// detached tasks are spawned.
1324    pub async fn invoke_command_collected(
1325        &self,
1326        id: &str,
1327        command: &str,
1328        args: Vec<String>,
1329        request_id: &str,
1330    ) -> (
1331        Result<serde_json::Value, String>,
1332        crate::extensions::invoke_output::InvokeOutputReport,
1333    ) {
1334        let (sink, collector) = crate::extensions::invoke_output::invoke_event_channel(
1335            crate::extensions::invoke_output::InvokeOutputBudget::default(),
1336        );
1337        let handler = match self.user_action_handler(id) {
1338            Ok(handler) => handler,
1339            Err(error) => {
1340                drop(sink);
1341                return (Err(error), collector.collect().await);
1342            }
1343        };
1344        let invoke = handler.invoke_command(command, args, request_id, sink);
1345        let (result, report) = tokio::join!(invoke, collector.collect());
1346        (result, report)
1347    }
1348
1349    pub async fn settings_editor_open(
1350        &self,
1351        id: &str,
1352        category: &str,
1353        field: &str,
1354    ) -> Result<serde_json::Value, String> {
1355        let handler = self.user_action_handler(id)?;
1356        handler.settings_editor_open(category, field).await
1357    }
1358
1359    pub async fn settings_editor_key(
1360        &self,
1361        id: &str,
1362        category: &str,
1363        field: &str,
1364        key: &str,
1365    ) -> Result<serde_json::Value, String> {
1366        let handler = self.user_action_handler(id)?;
1367        handler.settings_editor_key(category, field, key).await
1368    }
1369
1370    pub async fn settings_editor_commit(
1371        &self,
1372        id: &str,
1373        category: &str,
1374        field: &str,
1375        value: serde_json::Value,
1376    ) -> Result<serde_json::Value, String> {
1377        let handler = self.user_action_handler(id)?;
1378        handler.settings_editor_commit(category, field, value).await
1379    }
1380
1381    /// Return all cached plugin info sorted by extension id.
1382    pub fn plugin_infos(&self) -> Vec<(&str, &PluginInfo)> {
1383        let mut entries: Vec<_> = self
1384            .plugin_info
1385            .iter()
1386            .map(|(id, info)| (id.as_str(), info))
1387            .collect();
1388        entries.sort_by(|a, b| a.0.cmp(b.0));
1389        entries
1390    }
1391
1392    /// Return provider status summaries sorted by provider runtime id.
1393    pub fn provider_summaries(&self) -> Vec<RegisteredProviderSummary> {
1394        self.providers.summaries()
1395    }
1396
1397    /// Unified capability snapshot per loaded extension, sorted by id.
1398    ///
1399    /// Aggregates hook subscriptions, extension-provided tools, and registered
1400    /// providers. `future` carries plugin-defined capability kinds and
1401    /// capabilities land.
1402    pub async fn capability_snapshots(&self) -> Vec<ExtensionCapabilitySnapshot> {
1403        let mut handlers: Vec<(String, Arc<dyn ExtensionHandler>)> = self
1404            .extensions
1405            .iter()
1406            .map(|(id, handler)| (id.clone(), handler.clone()))
1407            .collect();
1408        handlers.sort_by(|a, b| a.0.cmp(&b.0));
1409
1410        let provider_summaries = self.providers.summaries();
1411        let plugin_id_lookup: std::collections::HashMap<String, String> = self
1412            .providers
1413            .list()
1414            .into_iter()
1415            .map(|p| (p.runtime_id.clone(), p.plugin_id.clone()))
1416            .collect();
1417
1418        let mut out = Vec::with_capacity(handlers.len());
1419        for (id, handler) in handlers {
1420            let health = handler.health().await;
1421            let restart_count = handler.restart_count().await;
1422
1423            let hook_pairs = self.hook_bus.subscriptions_for(&id).await;
1424            let hooks: Vec<HookCapabilityEntry> = hook_pairs
1425                .into_iter()
1426                .map(|(kind, tool_filter)| HookCapabilityEntry {
1427                    kind: kind.as_str().to_string(),
1428                    tool_filter,
1429                })
1430                .collect();
1431
1432            let tools: Vec<ToolCapabilityEntry> = if let Some(tools) = &self.tools {
1433                let registry = tools.read().await;
1434                registry
1435                    .tool_names_for_extension(&id)
1436                    .into_iter()
1437                    .map(|name| ToolCapabilityEntry { name })
1438                    .collect()
1439            } else {
1440                Vec::new()
1441            };
1442
1443            let providers: Vec<RegisteredProviderSummary> = provider_summaries
1444                .iter()
1445                .filter(|summary| {
1446                    plugin_id_lookup
1447                        .get(&summary.runtime_id)
1448                        .map(|p| p == &id)
1449                        .unwrap_or(false)
1450                })
1451                .cloned()
1452                .collect();
1453
1454            let future: Vec<FutureCapabilityEntry> = self
1455                .capabilities
1456                .get(&id)
1457                .map(|decls| {
1458                    decls
1459                        .iter()
1460                        .map(|d| FutureCapabilityEntry {
1461                            kind: d.kind.clone(),
1462                            name: d.name.clone(),
1463                        })
1464                        .collect()
1465                })
1466                .unwrap_or_default();
1467
1468            out.push(ExtensionCapabilitySnapshot {
1469                id,
1470                health,
1471                restart_count,
1472                hooks,
1473                tools,
1474                providers,
1475                future,
1476            });
1477        }
1478        out
1479    }
1480
1481    /// Return runtime ids of registered providers that declare at least one
1482    /// tool-use-capable model. Sorted by runtime id.
1483    pub fn provider_tool_use_runtime_ids(&self) -> Vec<String> {
1484        let mut ids: Vec<String> = self
1485            .providers
1486            .list()
1487            .into_iter()
1488            .filter(|p| {
1489                p.spec.models.iter().any(|m| {
1490                    m.capabilities
1491                        .get("tool_use")
1492                        .and_then(|v| v.as_bool())
1493                        .unwrap_or(false)
1494                })
1495            })
1496            .map(|p| p.runtime_id.clone())
1497            .collect();
1498        ids.sort();
1499        ids
1500    }
1501
1502    /// Return a `runtime_id -> enabled` map for every registered provider, computed
1503    /// from the persisted trust state. Providers without an entry default to
1504    /// enabled. If the trust state file is missing, all providers are reported
1505    /// as enabled (default). If the file is corrupt, all providers are reported
1506    /// as **disabled** (fail-closed) and a warning is logged.
1507    pub fn provider_trust_view(&self) -> std::collections::BTreeMap<String, bool> {
1508        let trust = match crate::extensions::trust::load_trust_state() {
1509            Ok(t) => t,
1510            Err(e) => {
1511                tracing::warn!("trust.json corrupt or unreadable, failing closed (all providers disabled): {e}");
1512                // Return all providers as disabled
1513                return self
1514                    .providers
1515                    .list()
1516                    .into_iter()
1517                    .map(|p| (p.runtime_id.clone(), false))
1518                    .collect();
1519            }
1520        };
1521        self.providers
1522            .list()
1523            .into_iter()
1524            .map(|p| {
1525                let enabled = crate::extensions::trust::is_provider_enabled(&trust, &p.runtime_id);
1526                (p.runtime_id.clone(), enabled)
1527            })
1528            .collect()
1529    }
1530
1531    /// Compute config diagnostics for a loaded extension by id.
1532    /// Returns `None` if the extension is not loaded.
1533    pub fn config_diagnostics(&self, id: &str) -> Option<ExtensionConfigDiagnostics> {
1534        let manifest_config = self.manifest_configs.get(id)?;
1535
1536        // Collect provider required keys from registered providers' config_schema.
1537        let mut provider_required: Vec<(String, Vec<String>)> = Vec::new();
1538        for provider in self.providers.list() {
1539            if provider.plugin_id != id {
1540                continue;
1541            }
1542            let required: Vec<String> = provider
1543                .spec
1544                .config_schema
1545                .as_ref()
1546                .and_then(|schema| schema.get("required"))
1547                .and_then(Value::as_array)
1548                .map(|arr| {
1549                    arr.iter()
1550                        .filter_map(|v| v.as_str().map(|s| s.to_string()))
1551                        .collect()
1552                })
1553                .unwrap_or_default();
1554            provider_required.push((provider.provider_id.clone(), required));
1555        }
1556        provider_required.sort_by(|a, b| a.0.cmp(&b.0));
1557
1558        let env_lookup = |name: &str| std::env::var(name).ok();
1559        let plugin_config_lookup =
1560            |key: &str| crate::extensions::config_store::read_plugin_config(id, key);
1561        let legacy_config_lookup = |key: &str| crate::config::read_config_value(key);
1562
1563        Some(diagnose_extension_config(
1564            id,
1565            manifest_config,
1566            &provider_required,
1567            &env_lookup,
1568            &plugin_config_lookup,
1569            &legacy_config_lookup,
1570        ))
1571    }
1572
1573    /// Diagnostics for all loaded extensions, sorted alphabetically by id.
1574    pub fn all_config_diagnostics(&self) -> Vec<ExtensionConfigDiagnostics> {
1575        let mut ids: Vec<&String> = self.manifest_configs.keys().collect();
1576        ids.sort();
1577        ids.into_iter()
1578            .filter_map(|id| self.config_diagnostics(id))
1579            .collect()
1580    }
1581
1582    /// Get the shared hook bus.
1583    pub fn hook_bus(&self) -> &Arc<HookBus> {
1584        &self.hook_bus
1585    }
1586
1587    /// Get the shared tool registry, when this manager was constructed with one.
1588    pub fn tools_shared(&self) -> Option<Arc<tokio::sync::RwLock<crate::ToolRegistry>>> {
1589        self.tools.clone()
1590    }
1591
1592    /// Test-only: register a provider spec + in-process handler directly,
1593    /// without spawning an extension process. Used by trace wiring tests.
1594    #[cfg(test)]
1595    pub(crate) fn register_provider_handler_for_tests(
1596        &mut self,
1597        plugin_id: &str,
1598        spec: crate::extensions::runtime::process::RegisteredProviderSpec,
1599        handler: Arc<dyn ExtensionHandler>,
1600    ) -> Result<String, String> {
1601        self.providers
1602            .register_with_handler(plugin_id, spec, Some(handler))
1603    }
1604
1605    /// Discover and load all extensions from the user and project plugin directories.
1606    ///
1607    /// Scans `~/.synaps-cli/plugins/*/.synaps-plugin/plugin.json` and
1608    /// `./.synaps/plugins/*/.synaps-plugin/plugin.json` for manifests that contain
1609    /// an `extension` field. Project-local plugins override user plugins with the
1610    /// same directory name.
1611    pub async fn discover_and_load(&mut self) -> (Vec<String>, Vec<ExtensionLoadFailure>) {
1612        self.discover_and_load_with_progress(|_| {}).await
1613    }
1614
1615    /// Discover and load all extensions, invoking `progress` after each load
1616    /// attempt. Used by the async UI loader to update startup toasts without
1617    /// blocking first paint.
1618    pub async fn discover_and_load_with_progress<F>(
1619        &mut self,
1620        mut progress: F,
1621    ) -> (Vec<String>, Vec<ExtensionLoadFailure>)
1622    where
1623        F: FnMut(crate::extensions::loader::ExtensionLoaderEvent),
1624    {
1625        let mut plugin_roots = vec![crate::config::base_dir().join("plugins")];
1626        if !project_plugins_disabled() {
1627            if let Ok(cwd) = std::env::current_dir() {
1628                let project_plugins = cwd.join(".synaps").join("plugins");
1629                if project_plugins != plugin_roots[0] {
1630                    plugin_roots.push(project_plugins);
1631                }
1632            }
1633        }
1634
1635        let mut plugin_dirs: HashMap<String, PathBuf> = HashMap::new();
1636        let mut failed: Vec<ExtensionLoadFailure> = Vec::new();
1637
1638        for plugins_dir in plugin_roots {
1639            if !plugins_dir.exists() {
1640                continue;
1641            }
1642
1643            let entries = match std::fs::read_dir(&plugins_dir) {
1644                Ok(e) => e,
1645                Err(e) => {
1646                    tracing::warn!(path = %plugins_dir.display(), error = %e, "Failed to read plugins directory");
1647                    failed.push(ExtensionLoadFailure::new(
1648                        "plugins",
1649                        Some(plugins_dir.clone()),
1650                        format!("Failed to read plugins directory: {e}"),
1651                        "Check directory permissions and retry",
1652                    ));
1653                    continue;
1654                }
1655            };
1656
1657            for entry in entries.flatten() {
1658                let plugin_name = entry.file_name().to_string_lossy().to_string();
1659                if is_inert_plugin_dir_name(&plugin_name) {
1660                    tracing::debug!(
1661                        plugin = %plugin_name,
1662                        path = %entry.path().display(),
1663                        "Skipping backup/disabled/temp plugin directory"
1664                    );
1665                    continue;
1666                }
1667                plugin_dirs.insert(plugin_name, entry.path());
1668            }
1669        }
1670
1671        let mut plugin_dirs: Vec<(String, PathBuf)> = plugin_dirs.into_iter().collect();
1672        plugin_dirs.sort_by(|a, b| a.0.cmp(&b.0));
1673
1674        let mut loaded = Vec::new();
1675        let disabled_plugins = crate::config::load_config().disabled_plugins;
1676        // Hoist plugins.json read out of the per-plugin loop — was N reads + parses
1677        // of the same file (one per discovered plugin). Read it once here.
1678        let plugins_state = {
1679            let state_path = crate::skills::state::PluginsState::default_path();
1680            crate::skills::state::PluginsState::load_from(&state_path).unwrap_or_default()
1681        };
1682        for (plugin_name, plugin_dir) in plugin_dirs {
1683            if disabled_plugins.iter().any(|d| d == &plugin_name) {
1684                tracing::debug!(plugin = %plugin_name, "Extension disabled via disabled_plugins config");
1685                continue;
1686            }
1687            if let Some(message) = installed_plugin_setup_failure_in(&plugins_state, &plugin_name) {
1688                tracing::warn!(plugin = %plugin_name, error = %message, "Skipping extension with failed post-install setup");
1689                failed.push(ExtensionLoadFailure::new(
1690                    plugin_name,
1691                    None,
1692                    format!("Post-install setup failed: {message}"),
1693                    "Open /plugins, reinstall or update the plugin after fixing setup; extension load is disabled until setup succeeds",
1694                ));
1695                continue;
1696            }
1697            let manifest_path = plugin_dir.join(".synaps-plugin").join("plugin.json");
1698            if !manifest_path.exists() {
1699                continue;
1700            }
1701
1702            let content = match std::fs::read_to_string(&manifest_path) {
1703                Ok(c) => c,
1704                Err(e) => {
1705                    let reason = format!("Failed to read plugin manifest: {e}");
1706                    tracing::warn!(plugin = %plugin_name, manifest = %manifest_path.display(), error = %e, "Failed to read plugin manifest");
1707                    failed.push(ExtensionLoadFailure::new(
1708                        plugin_name,
1709                        Some(manifest_path),
1710                        reason,
1711                        "Check manifest file permissions, then run `plugin validate <plugin-dir>`",
1712                    ));
1713                    continue;
1714                }
1715            };
1716
1717            let json: serde_json::Value = match serde_json::from_str(&content) {
1718                Ok(v) => v,
1719                Err(e) => {
1720                    let reason = format!("Invalid plugin manifest JSON: {e}");
1721                    tracing::warn!(plugin = %plugin_name, manifest = %manifest_path.display(), error = %e, "Invalid plugin manifest JSON");
1722                    failed.push(ExtensionLoadFailure::new(
1723                        plugin_name,
1724                        Some(manifest_path),
1725                        reason,
1726                        "Fix JSON syntax, then run `plugin validate <plugin-dir>`",
1727                    ));
1728                    continue;
1729                }
1730            };
1731
1732            let ext_value = match json.get("extension") {
1733                Some(v) => v.clone(),
1734                None => continue,
1735            };
1736
1737            let ext_manifest: ExtensionManifest = match serde_json::from_value(ext_value) {
1738                Ok(m) => m,
1739                Err(e) => {
1740                    let reason = format!("Failed to parse extension manifest: {e}");
1741                    tracing::warn!(plugin = %plugin_name, manifest = %manifest_path.display(), error = %e, "Failed to parse extension manifest");
1742                    failed.push(ExtensionLoadFailure::new(
1743                        plugin_name,
1744                        Some(manifest_path),
1745                        reason,
1746                        "Check the `extension` block shape against docs/extensions/contract.json, then run `plugin validate <plugin-dir>`",
1747                    ));
1748                    continue;
1749                }
1750            };
1751
1752            #[allow(clippy::if_same_then_else)]
1753            let command = if std::path::Path::new(&ext_manifest.command).is_absolute() {
1754                ext_manifest.command.clone()
1755            } else if !ext_manifest.command.contains(std::path::MAIN_SEPARATOR)
1756                && !ext_manifest.command.contains('/')
1757            {
1758                ext_manifest.command.clone()
1759            } else {
1760                plugin_dir
1761                    .join(&ext_manifest.command)
1762                    .to_string_lossy()
1763                    .to_string()
1764            };
1765
1766            let args: Vec<String> = ext_manifest
1767                .args
1768                .iter()
1769                .map(|arg| {
1770                    let arg_path = plugin_dir.join(arg);
1771                    if arg_path.exists() {
1772                        if let (Ok(canonical), Ok(plugin_canonical)) =
1773                            (arg_path.canonicalize(), plugin_dir.canonicalize())
1774                        {
1775                            if canonical.starts_with(&plugin_canonical) {
1776                                return canonical.to_string_lossy().to_string();
1777                            }
1778                        }
1779                    }
1780                    arg.clone()
1781                })
1782                .collect();
1783
1784            let resolved = ExtensionManifest {
1785                theme_tokens: Default::default(),
1786                // Deferred declarations flow through from the installed
1787                // manifest (legacy `tools`/`activation` aliases are folded
1788                // into `deferred` at deserialization) so installed plugins
1789                // keep their zero-spawn deferred lifecycle instead of
1790                // silently loading eager.
1791                command,
1792                args,
1793                ..ext_manifest
1794            };
1795
1796            match self
1797                .load_with_cwd(&plugin_name, &resolved, Some(plugin_dir.clone()))
1798                .await
1799            {
1800                Ok(()) => {
1801                    tracing::info!(plugin = %plugin_name, path = %plugin_dir.display(), "Extension loaded from plugins/");
1802                    loaded.push(plugin_name.clone());
1803                    progress(crate::extensions::loader::ExtensionLoaderEvent::Loaded {
1804                        plugin: plugin_name,
1805                        loaded: loaded.len(),
1806                        failed: failed.len(),
1807                    });
1808                }
1809                Err(e) => {
1810                    tracing::warn!(plugin = %plugin_name, manifest = %manifest_path.display(), error = %e, "Failed to load extension");
1811                    let setup_script = json
1812                        .pointer("/extension/setup")
1813                        .and_then(|v| v.as_str())
1814                        .or_else(|| {
1815                            json.pointer("/provides/sidecar/setup")
1816                                .and_then(|v| v.as_str())
1817                        });
1818                    let hint = compute_extension_load_hint(&e, &plugin_dir, setup_script);
1819                    let failure =
1820                        ExtensionLoadFailure::new(plugin_name, Some(manifest_path), e, hint);
1821                    failed.push(failure.clone());
1822                    progress(crate::extensions::loader::ExtensionLoaderEvent::Failed {
1823                        failure,
1824                        loaded: loaded.len(),
1825                        failed: failed.len(),
1826                    });
1827                }
1828            }
1829        }
1830
1831        (loaded, failed)
1832    }
1833}
1834
1835#[cfg(test)]
1836mod invoke_command_collected_tests {
1837    //! CP-11 fix-3: prove the manager's collected entry point pairs the
1838    //! bounded sink with an eagerly concurrent collector — an in-process
1839    //! handler that floods far past the channel capacity AND the retained
1840    //! budget must neither deadlock nor grow retention, and accounting
1841    //! must be exact.
1842    use super::*;
1843    use crate::extensions::commands::CommandOutputEvent;
1844    use crate::extensions::invoke_output::INVOKE_RETAINED_BYTE_BUDGET;
1845    use crate::extensions::runtime::InvokeCommandEvent;
1846
1847    struct FloodingHandler;
1848
1849    #[async_trait::async_trait]
1850    impl super::ExtensionHandler for FloodingHandler {
1851        fn id(&self) -> &str {
1852            "flooding-fake"
1853        }
1854        async fn handle(
1855            &self,
1856            _event: &crate::extensions::hooks::events::HookEvent,
1857        ) -> crate::extensions::hooks::events::HookResult {
1858            Default::default()
1859        }
1860        async fn shutdown(&self) {}
1861
1862        async fn invoke_command(
1863            &self,
1864            _command: &str,
1865            _args: Vec<String>,
1866            _request_id: &str,
1867            sink: crate::extensions::invoke_output::InvokeEventSink,
1868        ) -> Result<Value, String> {
1869            // 2048 x 4 KiB = 8 MiB >> 256 KiB budget, and 2048 events
1870            // >> channel capacity 8: every awaited send past capacity
1871            // requires the concurrent collector to make progress.
1872            for _ in 0..2048 {
1873                let event = InvokeCommandEvent::Output(CommandOutputEvent::Text {
1874                    content: "f".repeat(4096),
1875                });
1876                if sink.send(event).await.is_err() {
1877                    return Err("collector vanished".to_string());
1878                }
1879            }
1880            let _ = sink
1881                .send(InvokeCommandEvent::Output(CommandOutputEvent::Done))
1882                .await;
1883            Ok(serde_json::json!({"status": "flooded"}))
1884        }
1885    }
1886
1887    #[tokio::test]
1888    async fn collected_invoke_bounds_in_process_flood_without_deadlock() {
1889        let bus = Arc::new(HookBus::new());
1890        let mut mgr = ExtensionManager::new(bus);
1891        mgr.extensions
1892            .insert("flooding-fake".to_string(), Arc::new(FloodingHandler));
1893
1894        let (result, report) = tokio::time::timeout(
1895            std::time::Duration::from_secs(30),
1896            mgr.invoke_command_collected("flooding-fake", "any", vec![], "req-1"),
1897        )
1898        .await
1899        .expect("bounded sink + concurrent collector must not deadlock");
1900
1901        assert_eq!(result.expect("invoke ok")["status"], "flooded");
1902        let c = &report.counters;
1903        assert_eq!(c.produced_events, 2049);
1904        assert_eq!(c.produced_payload_bytes, 2048 * 4096);
1905        assert_eq!(c.consumed_events, c.produced_events);
1906        assert_eq!(c.consumed_payload_bytes, c.produced_payload_bytes);
1907        assert_eq!(c.retained_payload_bytes, INVOKE_RETAINED_BYTE_BUDGET as u64);
1908        assert!(c.saw_done);
1909        assert_eq!(
1910            c.consumed_payload_bytes,
1911            c.retained_payload_bytes + c.truncated_payload_bytes + c.dropped_payload_bytes,
1912        );
1913        assert_eq!(c.consumed_events, c.retained_events + c.dropped_events);
1914    }
1915
1916    #[tokio::test]
1917    async fn collected_invoke_reports_unknown_extension_with_empty_report() {
1918        let bus = Arc::new(HookBus::new());
1919        let mgr = ExtensionManager::new(bus);
1920        let (result, report) = mgr
1921            .invoke_command_collected("nope", "any", vec![], "req-1")
1922            .await;
1923        assert!(result
1924            .expect_err("unknown id")
1925            .contains("unknown extension"));
1926        assert!(report.events.is_empty());
1927        assert_eq!(report.counters.produced_events, 0);
1928    }
1929}
1930
1931#[cfg(test)]
1932mod tests {
1933    use super::*;
1934
1935    /// Task A6: context-provider manifest for one plugin (no tools, no
1936    /// hooks — classifies as `ContextProvider` and defers without spawn).
1937    fn context_provider_manifest(provider_id: &str) -> ExtensionManifest {
1938        serde_json::from_value(serde_json::json!({
1939            "runtime": "process",
1940            "command": "/bin/false",
1941            "permissions": ["context_providers.register"],
1942            "deferred": {
1943                "context_providers": [{
1944                    "id": provider_id,
1945                    "capability": "project-memory",
1946                    "description": "test context provider",
1947                    "schema_version": 1
1948                }]
1949            }
1950        }))
1951        .unwrap()
1952    }
1953
1954    /// Task A6: the context-provider catalog mirrors the model-provider
1955    /// catalog shape — populated at load, exact fail-closed lookup, kept
1956    /// current across unload, shared with the lease manager — and every
1957    /// read is pure metadata: zero processes are ever started.
1958    #[tokio::test]
1959    async fn context_provider_catalog_lists_resolves_and_unloads_exactly() {
1960        let mut mgr = ExtensionManager::new(Arc::new(HookBus::new()));
1961        mgr.set_progressive_deferral(true);
1962        mgr.load("mem-a", &context_provider_manifest("alpha"))
1963            .await
1964            .unwrap();
1965        mgr.load("mem-b", &context_provider_manifest("beta"))
1966            .await
1967            .unwrap();
1968
1969        // Catalog reads: sorted by runtime address, exact ids only.
1970        let listed: Vec<String> = mgr
1971            .context_providers()
1972            .iter()
1973            .map(|d| d.runtime_address())
1974            .collect();
1975        assert_eq!(
1976            listed,
1977            vec![
1978                "extension:mem-a:alpha".to_string(),
1979                "extension:mem-b:beta".to_string(),
1980            ]
1981        );
1982        let alpha = super::super::context_provider::ContextProviderId::parse("alpha").unwrap();
1983        assert_eq!(
1984            mgr.context_provider(&alpha).map(|d| d.extension_id()),
1985            Some("mem-a")
1986        );
1987        let unknown = super::super::context_provider::ContextProviderId::parse("nope").unwrap();
1988        assert!(mgr.context_provider(&unknown).is_none(), "fail closed");
1989
1990        // The shared lease-manager handle observes the same catalog.
1991        let runtime = mgr.extension_runtime();
1992        assert_eq!(
1993            runtime
1994                .resolve_context_provider(Some(&alpha))
1995                .map(|d| d.runtime_address()),
1996            Ok("extension:mem-a:alpha".to_string())
1997        );
1998
1999        // An overlapping declaration makes the id ambiguous — fail closed
2000        // on BOTH read surfaces, never an arbitrary pick.
2001        mgr.load("mem-c", &context_provider_manifest("alpha"))
2002            .await
2003            .unwrap();
2004        assert!(mgr.context_provider(&alpha).is_none(), "ambiguous → None");
2005        assert_eq!(
2006            runtime.resolve_context_provider(Some(&alpha)),
2007            Err(super::super::context_provider::ContextProviderLookupError::Ambiguous)
2008        );
2009
2010        // Unload removes exactly the owner's descriptors and republishes.
2011        mgr.unload("mem-c").await.unwrap();
2012        assert_eq!(
2013            mgr.context_provider(&alpha).map(|d| d.extension_id()),
2014            Some("mem-a")
2015        );
2016        mgr.unload("mem-a").await.unwrap();
2017        assert_eq!(
2018            runtime.resolve_context_provider(Some(&alpha)),
2019            Err(super::super::context_provider::ContextProviderLookupError::NotRegistered)
2020        );
2021        assert_eq!(mgr.context_providers().len(), 1, "mem-b remains");
2022
2023        // Pure catalog discipline: nothing above started a process.
2024        assert_eq!(mgr.count(), 0, "no live extension handlers");
2025        assert_eq!(runtime.lease_count(), 0, "no runtime leases");
2026    }
2027
2028    #[tokio::test]
2029    async fn capability_snapshots_empty_when_no_extensions() {
2030        let bus = Arc::new(HookBus::new());
2031        let mgr = ExtensionManager::new(bus);
2032        assert!(mgr.capability_snapshots().await.is_empty());
2033    }
2034
2035    /// Review fix A3: the retained deferred launch record resolves
2036    /// `secret_env` config values, so NOTHING public/debug/diagnostic may
2037    /// ever reveal them — only booleans/counts. The record type itself is
2038    /// crate-private, field-private, and implements neither `Debug` nor
2039    /// `Serialize` (compiler-enforced); this test additionally proves the
2040    /// live diagnostic surfaces never carry the resolved secret.
2041    #[tokio::test]
2042    async fn deferred_launch_record_never_leaks_secret_config_through_diagnostics() {
2043        const SECRET: &str = "task20-A3-sentinel-5f2c9d";
2044        // NOTE: deliberately NOT in the SYNAPS_* host namespace — that is denied by
2045        // `denied_secret_env` (#207 MED-2). This test is about diagnostic leakage,
2046        // not namespace policy, so it uses a realistic third-party var name.
2047        const VAR: &str = "T20_A3_THIRD_PARTY_SECRET";
2048        std::env::set_var(VAR, SECRET);
2049
2050        let manifest: ExtensionManifest = serde_json::from_value(serde_json::json!({
2051            "runtime": "process",
2052            "command": "/bin/false",
2053            "permissions": ["tools.register"],
2054            "config": [{
2055                "key": "api_key",
2056                "type": "string",
2057                "required": true,
2058                "secret_env": VAR
2059            }],
2060            "deferred": {
2061                "tools": [{
2062                    "name": "search",
2063                    "description": "deferred search",
2064                    "input_schema": {"type": "object"}
2065                }]
2066            }
2067        }))
2068        .unwrap();
2069
2070        let registry = Arc::new(tokio::sync::RwLock::new(crate::ToolRegistry::new()));
2071        let mut mgr = ExtensionManager::new_with_tools(Arc::new(HookBus::new()), registry);
2072        mgr.set_progressive_deferral(true);
2073        mgr.load("secretive", &manifest).await.unwrap();
2074
2075        // Boolean/count-only public surface for the deferral state.
2076        assert!(mgr.is_deferred_tool_only("secretive"));
2077        assert_eq!(mgr.deferred_tool_only_count(), 1);
2078        // The internal record DID resolve the secret (lease path needs it).
2079        let record = mgr.deferred_launch_record("secretive").expect("retained");
2080        assert_eq!(record.config["api_key"], serde_json::json!(SECRET));
2081
2082        // Every public/debug/diagnostic surface stays free of the value.
2083        let mut rendered = String::new();
2084        rendered.push_str(&format!("{:?}", mgr.statuses().await));
2085        rendered.push_str(&format!("{:?}", mgr.config_diagnostics("secretive")));
2086        rendered.push_str(&format!("{:?}", mgr.all_config_diagnostics()));
2087        rendered.push_str(&format!("{:?}", mgr.capability_snapshots().await));
2088        rendered.push_str(&format!("{:?}", mgr.plugin_infos()));
2089        rendered.push_str(&format!("{:?}", mgr.list()));
2090        assert!(
2091            !rendered.contains(SECRET),
2092            "resolved secret config leaked through a public diagnostic surface"
2093        );
2094
2095        std::env::remove_var(VAR);
2096    }
2097
2098    #[tokio::test]
2099    async fn capability_snapshot_lists_hooks_for_loaded_extension() {
2100        let bus = Arc::new(HookBus::new());
2101        let mut mgr = ExtensionManager::new(bus.clone());
2102        let manifest = ExtensionManifest {
2103            theme_tokens: Default::default(),
2104            deferred: None,
2105            protocol_version: 1,
2106            runtime: crate::extensions::manifest::ExtensionRuntime::Process,
2107            command: "python3".to_string(),
2108            setup: None,
2109            prebuilt: ::std::collections::HashMap::new(),
2110            args: vec![
2111                "tests/fixtures/process_extension.py".to_string(),
2112                "normal".to_string(),
2113                "/tmp/synaps-capability-test.log".to_string(),
2114            ],
2115            permissions: vec!["tools.intercept".to_string()],
2116            hooks: vec![crate::extensions::manifest::HookSubscription {
2117                hook: "before_tool_call".to_string(),
2118                tool: Some("bash".to_string()),
2119                matcher: None,
2120            }],
2121            config: vec![],
2122        };
2123
2124        mgr.load("cap-snap", &manifest).await.unwrap();
2125
2126        let snaps = mgr.capability_snapshots().await;
2127        assert_eq!(snaps.len(), 1);
2128        let snap = &snaps[0];
2129        assert_eq!(snap.id, "cap-snap");
2130        assert_eq!(snap.hooks.len(), 1);
2131        assert_eq!(snap.hooks[0].kind, "before_tool_call");
2132        assert_eq!(snap.hooks[0].tool_filter.as_deref(), Some("bash"));
2133        assert!(snap.tools.is_empty());
2134        assert!(snap.providers.is_empty());
2135        assert!(snap.future.is_empty());
2136
2137        mgr.shutdown_all().await;
2138    }
2139
2140    #[tokio::test]
2141    async fn capability_snapshot_surfaces_seeded_capabilities() {
2142        let bus = Arc::new(HookBus::new());
2143        let mut mgr = ExtensionManager::new(bus.clone());
2144        let manifest = ExtensionManifest {
2145            theme_tokens: Default::default(),
2146            deferred: None,
2147            protocol_version: 1,
2148            runtime: crate::extensions::manifest::ExtensionRuntime::Process,
2149            command: "python3".to_string(),
2150            setup: None,
2151            prebuilt: ::std::collections::HashMap::new(),
2152            args: vec![
2153                "tests/fixtures/process_extension.py".to_string(),
2154                "normal".to_string(),
2155                "/tmp/synaps-capability-snapshot-test.log".to_string(),
2156            ],
2157            permissions: vec!["tools.intercept".to_string()],
2158            hooks: vec![crate::extensions::manifest::HookSubscription {
2159                hook: "before_tool_call".to_string(),
2160                tool: Some("bash".to_string()),
2161                matcher: None,
2162            }],
2163            config: vec![],
2164        };
2165
2166        mgr.load("multi-cap", &manifest).await.unwrap();
2167
2168        // Seed two capabilities of *different* kinds — proves the
2169        // snapshot rendering iterates a generic list and uses the
2170        // plugin-supplied `kind` rather than hardcoding any modality.
2171        mgr.test_seed_capabilities(
2172            "multi-cap",
2173            vec![
2174                crate::extensions::runtime::process::CapabilityDeclaration {
2175                    kind: "capture".to_string(),
2176                    name: "Local Sample STT".to_string(),
2177                    permissions: vec!["audio.input".to_string()],
2178                    params: serde_json::Value::Null,
2179                },
2180                crate::extensions::runtime::process::CapabilityDeclaration {
2181                    kind: "ocr".to_string(),
2182                    name: "Tesseract".to_string(),
2183                    permissions: vec![],
2184                    params: serde_json::Value::Null,
2185                },
2186            ],
2187        );
2188
2189        let snaps = mgr.capability_snapshots().await;
2190        let snap = snaps
2191            .iter()
2192            .find(|s| s.id == "multi-cap")
2193            .expect("multi-cap snapshot");
2194        assert_eq!(snap.future.len(), 2);
2195        let kinds: Vec<&str> = snap.future.iter().map(|e| e.kind.as_str()).collect();
2196        assert!(kinds.contains(&"capture"), "got kinds {:?}", kinds);
2197        assert!(kinds.contains(&"ocr"), "got kinds {:?}", kinds);
2198        let names: Vec<&str> = snap.future.iter().map(|e| e.name.as_str()).collect();
2199        assert!(names.contains(&"Local Sample STT"), "got {:?}", names);
2200        assert!(names.contains(&"Tesseract"), "got {:?}", names);
2201
2202        mgr.unload("multi-cap").await.unwrap();
2203        let snaps = mgr.capability_snapshots().await;
2204        assert!(snaps.iter().all(|s| s.id != "multi-cap"));
2205
2206        mgr.shutdown_all().await;
2207    }
2208
2209    #[tokio::test]
2210    async fn new_manager_has_no_extensions() {
2211        let bus = Arc::new(HookBus::new());
2212        let mgr = ExtensionManager::new(bus);
2213        assert_eq!(mgr.count(), 0);
2214        assert!(mgr.list().is_empty());
2215    }
2216
2217    #[tokio::test]
2218    async fn unload_nonexistent_returns_error() {
2219        let bus = Arc::new(HookBus::new());
2220        let mut mgr = ExtensionManager::new(bus);
2221        let result = mgr.unload("nope").await;
2222        assert!(result.is_err());
2223    }
2224
2225    #[tokio::test]
2226    async fn reload_unsubscribes_old_handler_before_loading_new_one() {
2227        let bus = Arc::new(HookBus::new());
2228        let mut mgr = ExtensionManager::new(bus.clone());
2229        let manifest = ExtensionManifest {
2230            theme_tokens: Default::default(),
2231            deferred: None,
2232            protocol_version: 1,
2233            runtime: crate::extensions::manifest::ExtensionRuntime::Process,
2234            command: "python3".to_string(),
2235            setup: None,
2236            prebuilt: ::std::collections::HashMap::new(),
2237            args: vec![
2238                "tests/fixtures/process_extension.py".to_string(),
2239                "normal".to_string(),
2240                "/tmp/synaps-reload-test.log".to_string(),
2241            ],
2242            permissions: vec!["tools.intercept".to_string()],
2243            hooks: vec![crate::extensions::manifest::HookSubscription {
2244                hook: "before_tool_call".to_string(),
2245                tool: Some("bash".to_string()),
2246                matcher: None,
2247            }],
2248            config: vec![],
2249        };
2250
2251        mgr.load("reload-test", &manifest).await.unwrap();
2252        assert_eq!(bus.handler_count().await, 1);
2253
2254        mgr.reload("reload-test", &manifest, None).await.unwrap();
2255
2256        assert_eq!(mgr.count(), 1);
2257        assert_eq!(bus.handler_count().await, 1);
2258        mgr.shutdown_all().await;
2259    }
2260
2261    #[tokio::test]
2262    async fn reload_failure_leaves_previous_instance_unloaded() {
2263        let bus = Arc::new(HookBus::new());
2264        let mut mgr = ExtensionManager::new(bus.clone());
2265        let good = ExtensionManifest {
2266            theme_tokens: Default::default(),
2267            deferred: None,
2268            protocol_version: 1,
2269            runtime: crate::extensions::manifest::ExtensionRuntime::Process,
2270            command: "python3".to_string(),
2271            setup: None,
2272            prebuilt: ::std::collections::HashMap::new(),
2273            args: vec![
2274                "tests/fixtures/process_extension.py".to_string(),
2275                "normal".to_string(),
2276                "/tmp/synaps-reload-failure-test.log".to_string(),
2277            ],
2278            permissions: vec!["tools.intercept".to_string()],
2279            hooks: vec![crate::extensions::manifest::HookSubscription {
2280                hook: "before_tool_call".to_string(),
2281                tool: Some("bash".to_string()),
2282                matcher: None,
2283            }],
2284            config: vec![],
2285        };
2286        let bad = ExtensionManifest {
2287            theme_tokens: Default::default(),
2288            deferred: None,
2289            command: "/definitely/not/a/real/extension-binary".to_string(),
2290            setup: None,
2291            prebuilt: ::std::collections::HashMap::new(),
2292            ..good.clone()
2293        };
2294
2295        mgr.load("reload-failure-test", &good).await.unwrap();
2296        let err = mgr
2297            .reload("reload-failure-test", &bad, None)
2298            .await
2299            .unwrap_err();
2300
2301        assert!(err.contains("Failed to spawn extension"), "{err}");
2302        assert_eq!(mgr.count(), 0);
2303        assert_eq!(bus.handler_count().await, 0);
2304    }
2305
2306    #[test]
2307    fn project_plugins_disable_env_parser_accepts_truthy_values() {
2308        for value in ["1", "true", "TRUE", "yes", "on"] {
2309            std::env::set_var("SYNAPS_DISABLE_PROJECT_PLUGINS", value);
2310            assert!(project_plugins_disabled());
2311        }
2312        for value in ["", "0", "false", "off", "no"] {
2313            std::env::set_var("SYNAPS_DISABLE_PROJECT_PLUGINS", value);
2314            assert!(!project_plugins_disabled());
2315        }
2316        std::env::remove_var("SYNAPS_DISABLE_PROJECT_PLUGINS");
2317    }
2318
2319    fn with_temp_base_dir<T>(path: &std::path::Path, f: impl FnOnce() -> T) -> T {
2320        let old_base_dir = std::env::var("SYNAPS_BASE_DIR").ok();
2321        crate::config::set_base_dir_for_tests(path.to_path_buf());
2322        let out = f();
2323        match old_base_dir {
2324            Some(old) => std::env::set_var("SYNAPS_BASE_DIR", old),
2325            None => std::env::remove_var("SYNAPS_BASE_DIR"),
2326        }
2327        out
2328    }
2329
2330    #[test]
2331    #[serial_test::serial(synaps_base_dir)]
2332    fn resolve_config_prefers_plugin_namespaced_config_before_legacy_global_key() {
2333        let dir = tempfile::tempdir().unwrap();
2334        with_temp_base_dir(dir.path(), || {
2335            crate::extensions::config_store::write_plugin_config(
2336                "sample-sidecar",
2337                "backend",
2338                "cpu",
2339            )
2340            .unwrap();
2341            crate::config::write_config_value("extension.sample-sidecar.backend", "auto").unwrap();
2342
2343            let resolved = ExtensionManager::resolve_config(
2344                "sample-sidecar",
2345                &[ExtensionConfigEntry {
2346                    key: "backend".to_string(),
2347                    value_type: None,
2348                    description: None,
2349                    required: true,
2350                    default: None,
2351                    secret_env: None,
2352                    host_context: None,
2353                }],
2354                None,
2355            )
2356            .unwrap();
2357
2358            assert_eq!(
2359                resolved["backend"],
2360                serde_json::Value::String("cpu".to_string())
2361            );
2362        });
2363    }
2364
2365    #[test]
2366    #[serial_test::serial(synaps_base_dir)]
2367    fn resolve_config_keeps_legacy_global_extension_key_as_fallback() {
2368        let dir = tempfile::tempdir().unwrap();
2369        with_temp_base_dir(dir.path(), || {
2370            crate::config::write_config_value("extension.sample-sidecar.backend", "auto").unwrap();
2371
2372            let resolved = ExtensionManager::resolve_config(
2373                "sample-sidecar",
2374                &[ExtensionConfigEntry {
2375                    key: "backend".to_string(),
2376                    value_type: None,
2377                    description: None,
2378                    required: true,
2379                    default: None,
2380                    secret_env: None,
2381                    host_context: None,
2382                }],
2383                None,
2384            )
2385            .unwrap();
2386
2387            assert_eq!(
2388                resolved["backend"],
2389                serde_json::Value::String("auto".to_string())
2390            );
2391        });
2392    }
2393
2394    #[tokio::test]
2395    async fn config_diagnostics_returns_none_for_unknown_extension() {
2396        let bus = Arc::new(HookBus::new());
2397        let mgr = ExtensionManager::new(bus);
2398        assert!(mgr.config_diagnostics("nope").is_none());
2399        assert!(mgr.all_config_diagnostics().is_empty());
2400    }
2401
2402    #[tokio::test]
2403    async fn config_diagnostics_reports_loaded_manifest_entries() {
2404        let bus = Arc::new(HookBus::new());
2405        let mut mgr = ExtensionManager::new(bus);
2406        let manifest = ExtensionManifest {
2407            theme_tokens: Default::default(),
2408            deferred: None,
2409            protocol_version: 1,
2410            runtime: crate::extensions::manifest::ExtensionRuntime::Process,
2411            command: "python3".to_string(),
2412            setup: None,
2413            prebuilt: ::std::collections::HashMap::new(),
2414            args: vec![
2415                "tests/fixtures/process_extension.py".to_string(),
2416                "normal".to_string(),
2417                "/tmp/synaps-config-diag-test.log".to_string(),
2418            ],
2419            permissions: vec!["tools.intercept".to_string()],
2420            hooks: vec![crate::extensions::manifest::HookSubscription {
2421                hook: "before_tool_call".to_string(),
2422                tool: Some("bash".to_string()),
2423                matcher: None,
2424            }],
2425            config: vec![crate::extensions::manifest::ExtensionConfigEntry {
2426                key: "region".to_string(),
2427                value_type: None,
2428                description: Some("AWS region".to_string()),
2429                required: false,
2430                default: Some(serde_json::Value::String("us-east-1".to_string())),
2431                secret_env: None,
2432                host_context: None,
2433            }],
2434        };
2435
2436        mgr.load("config-diag-test", &manifest).await.unwrap();
2437
2438        let diag = mgr
2439            .config_diagnostics("config-diag-test")
2440            .expect("diagnostics should be available for loaded extension");
2441        assert_eq!(diag.extension_id, "config-diag-test");
2442        assert_eq!(diag.entries.len(), 1);
2443        assert_eq!(diag.entries[0].key, "region");
2444        assert!(diag.entries[0].has_value);
2445        assert!(diag.provider_missing.is_empty());
2446
2447        let all = mgr.all_config_diagnostics();
2448        assert_eq!(all.len(), 1);
2449        assert_eq!(all[0].extension_id, "config-diag-test");
2450
2451        mgr.shutdown_all().await;
2452        // After shutdown, manifest config storage is cleared.
2453        assert!(mgr.config_diagnostics("config-diag-test").is_none());
2454    }
2455
2456    #[tokio::test]
2457    async fn provider_trust_view_is_empty_for_no_providers() {
2458        let bus = Arc::new(HookBus::new());
2459        let mgr = ExtensionManager::new(bus);
2460        let view = mgr.provider_trust_view();
2461        assert!(view.is_empty());
2462    }
2463
2464    #[tokio::test]
2465    async fn provider_tool_use_runtime_ids_lists_only_tool_use_capable() {
2466        use crate::extensions::runtime::process::{
2467            RegisteredProviderModelSpec, RegisteredProviderSpec,
2468        };
2469        let bus = Arc::new(HookBus::new());
2470        let mut mgr = ExtensionManager::new(bus);
2471        // Tool-use capable provider.
2472        let tool_spec = RegisteredProviderSpec {
2473            id: "alpha".into(),
2474            display_name: "Alpha".into(),
2475            description: "tool-use".into(),
2476            models: vec![RegisteredProviderModelSpec {
2477                id: "m1".into(),
2478                display_name: None,
2479                capabilities: serde_json::json!({"tool_use": true}),
2480                context_window: None,
2481            }],
2482            config_schema: None,
2483        };
2484        // Plain provider, no tool_use.
2485        let plain_spec = RegisteredProviderSpec {
2486            id: "beta".into(),
2487            display_name: "Beta".into(),
2488            description: "plain".into(),
2489            models: vec![RegisteredProviderModelSpec {
2490                id: "m1".into(),
2491                display_name: None,
2492                capabilities: serde_json::json!({"streaming": true}),
2493                context_window: None,
2494            }],
2495            config_schema: None,
2496        };
2497        mgr.providers.register("plug", tool_spec).unwrap();
2498        mgr.providers.register("plug", plain_spec).unwrap();
2499        let ids = mgr.provider_tool_use_runtime_ids();
2500        assert_eq!(ids, vec!["plug:alpha".to_string()]);
2501    }
2502
2503    // ---- compute_extension_load_hint --------------------------------
2504
2505    #[test]
2506    fn hint_missing_binary_with_declared_setup_points_at_script() {
2507        let hint = compute_extension_load_hint(
2508            "Failed to spawn extension 'sample-sidecar': No such file or directory (os error 2)",
2509            std::path::Path::new("/home/u/.synaps-cli/plugins/sample-sidecar"),
2510            Some("scripts/setup.sh"),
2511        );
2512        assert!(
2513            hint.contains("Extension binary missing"),
2514            "missing-binary case should be flagged: {hint}"
2515        );
2516        assert!(
2517            hint.contains("/home/u/.synaps-cli/plugins/sample-sidecar"),
2518            "hint should include the plugin dir: {hint}"
2519        );
2520        assert!(
2521            hint.contains("setup=scripts/setup.sh"),
2522            "hint should show sanitized setup path without copy-paste shell command: {hint}"
2523        );
2524    }
2525
2526    #[test]
2527    fn hint_missing_binary_without_declared_setup_falls_back_to_generic() {
2528        let hint = compute_extension_load_hint(
2529            "Failed to spawn extension 'foo': No such file or directory (os error 2)",
2530            std::path::Path::new("/x/y"),
2531            None,
2532        );
2533        assert!(
2534            hint.contains("plugin validate"),
2535            "no setup declared → generic hint: {hint}"
2536        );
2537        assert!(
2538            !hint.contains("Extension binary missing"),
2539            "should not falsely promise a setup script: {hint}"
2540        );
2541    }
2542
2543    #[test]
2544    fn hint_other_error_with_declared_setup_falls_back_to_generic() {
2545        let hint = compute_extension_load_hint(
2546            "Extension 'foo' must subscribe to at least one hook or request a registration permission",
2547            std::path::Path::new("/x/y"),
2548            Some("scripts/setup.sh"),
2549        );
2550        // Setup script is declared, but the error is *not* a missing
2551        // binary — running the script wouldn't help. Fall back to the
2552        // generic hint so we don't mislead the user.
2553        assert!(hint.contains("plugin validate"), "got {hint}");
2554        assert!(!hint.contains("Extension binary missing"), "got {hint}");
2555    }
2556
2557    #[test]
2558    fn hint_recognises_os_error_2_format() {
2559        // Older / cross-platform error formats may include the kernel
2560        // errno but not the "No such file or directory" English text.
2561        let hint = compute_extension_load_hint(
2562            "spawn failed (os error 2)",
2563            std::path::Path::new("/p"),
2564            Some("setup.sh"),
2565        );
2566        assert!(hint.contains("Extension binary missing"), "got {hint}");
2567    }
2568
2569    // ── bugfix: installed-plugin backup discovery ───────────────────────────
2570
2571    /// Minimal deferred tool-only plugin.json (Axel-shaped: `memory_fetch`
2572    /// takes a single `{id}`), written under `<dir>/.synaps-plugin/`.
2573    fn write_installed_plugin(plugins_root: &std::path::Path, dir_name: &str) {
2574        let plugin_dir = plugins_root.join(dir_name).join(".synaps-plugin");
2575        std::fs::create_dir_all(&plugin_dir).unwrap();
2576        let manifest = serde_json::json!({
2577            "extension": {
2578                "runtime": "process",
2579                "command": "/bin/false",
2580                "permissions": ["tools.register"],
2581                "deferred": {
2582                    "tools": [{
2583                        "name": "memory_fetch",
2584                        "description": "Fetch one memory by exact ID.",
2585                        "input_schema": {
2586                            "type": "object",
2587                            "properties": {"id": {"type": "string"}},
2588                            "required": ["id"]
2589                        }
2590                    }]
2591                }
2592            }
2593        });
2594        std::fs::write(
2595            plugin_dir.join("plugin.json"),
2596            serde_json::to_string_pretty(&manifest).unwrap(),
2597        )
2598        .unwrap();
2599    }
2600
2601    /// Regression (user incident, session 20260720-141844-fb5a): `plugins/`
2602    /// held the installed `axel-memory-manager` AND timestamped copies
2603    /// (`axel-memory-manager.backup.<TS>`). Discovery treated every
2604    /// directory with a valid manifest as a live plugin, so the backups
2605    /// were loaded and their binaries executed alongside the real one.
2606    /// Backup/disabled/temp directory names must never be discovered: only
2607    /// active plugins load, backup tools are never cataloged, and (since a
2608    /// backup that is never loaded owns no lease) a backup process can
2609    /// never be spawned.
2610    #[tokio::test]
2611    #[serial_test::serial(synaps_base_dir)]
2612    async fn discover_and_load_skips_backup_and_disabled_plugin_directories() {
2613        let base = crate::test_env::BaseDirGuard::new();
2614        let _no_project_plugins =
2615            crate::test_env::EnvVarGuard::set("SYNAPS_DISABLE_PROJECT_PLUGINS", "1");
2616        let plugins_root = base.path().join("plugins");
2617
2618        write_installed_plugin(&plugins_root, "axel-memory-manager");
2619        // The two timestamped backups from the incident, the updater's own
2620        // `.update-backup` convention, and common manual conventions.
2621        for inert in [
2622            "axel-memory-manager.backup.20260719210053",
2623            "axel-memory-manager.backup.20260720101416",
2624            "axel-memory-manager.update-backup",
2625            "axel-memory-manager.backup",
2626            "axel-memory-manager.bak",
2627            "axel-memory-manager.disabled",
2628            "axel-memory-manager.old",
2629            "axel-memory-manager.orig",
2630            "axel-memory-manager.tmp",
2631        ] {
2632            write_installed_plugin(&plugins_root, inert);
2633        }
2634        // Dotted names that are NOT backups stay discoverable.
2635        write_installed_plugin(&plugins_root, "com.example.notes");
2636
2637        let registry = Arc::new(tokio::sync::RwLock::new(
2638            crate::ToolRegistry::without_subagent(),
2639        ));
2640        let mut mgr = ExtensionManager::new_with_tools(Arc::new(HookBus::new()), registry.clone());
2641        mgr.set_progressive_deferral(true);
2642
2643        let (mut loaded, failed) = mgr.discover_and_load().await;
2644        loaded.sort();
2645
2646        assert!(failed.is_empty(), "no failures expected: {failed:?}");
2647        assert_eq!(
2648            loaded,
2649            vec![
2650                "axel-memory-manager".to_string(),
2651                "com.example.notes".to_string()
2652            ],
2653            "backup/disabled/temp plugin directories must not be discovered"
2654        );
2655
2656        let reg = registry.read().await;
2657        assert!(reg.get("axel-memory-manager:memory_fetch").is_some());
2658        assert!(reg.get("com.example.notes:memory_fetch").is_some());
2659        assert!(
2660            reg.get("axel-memory-manager.backup.20260719210053:memory_fetch")
2661                .is_none(),
2662            "backup plugin tools must never be cataloged"
2663        );
2664        assert!(reg
2665            .get("axel-memory-manager.backup.20260720101416:memory_fetch")
2666            .is_none());
2667        drop(reg);
2668
2669        // Deferred lifecycle + no backup load ⇒ zero live extension
2670        // processes were started by discovery.
2671        assert_eq!(mgr.count(), 0, "discovery must spawn nothing");
2672    }
2673
2674    /// The exclusion helper is name-based and conservative: it must catch
2675    /// known backup/disabled/temp conventions without swallowing
2676    /// legitimate dotted or hyphenated plugin names.
2677    #[test]
2678    fn inert_plugin_dir_name_filter_is_exact() {
2679        for inert in [
2680            "axel-memory-manager.backup.20260720101416",
2681            "axel-memory-manager.BACKUP.1",
2682            "axel-memory-manager.backup",
2683            "axel-memory-manager.update-backup",
2684            "axel-memory-manager.bak",
2685            "axel-memory-manager.disabled",
2686            "axel-memory-manager.old",
2687            "axel-memory-manager.orig",
2688            "axel-memory-manager.tmp",
2689            "axel-memory-manager.temp",
2690        ] {
2691            assert!(
2692                is_inert_plugin_dir_name(inert),
2693                "{inert} should be excluded from discovery"
2694            );
2695        }
2696        for live in [
2697            "axel-memory-manager",
2698            "com.example.notes",
2699            "backup-manager",
2700            "old-school-tools",
2701            "tmpfs-inspector",
2702            "my.backups.viewer",
2703        ] {
2704            assert!(
2705                !is_inert_plugin_dir_name(live),
2706                "{live} is a legitimate plugin name and must stay discoverable"
2707            );
2708        }
2709    }
2710}
2711
2712#[cfg(test)]
2713mod secret_env_allowlist_tests {
2714    use super::*;
2715
2716    fn entry(key: &str, secret_env: &str) -> ExtensionConfigEntry {
2717        serde_json::from_value(serde_json::json!({
2718            "key": key,
2719            "type": "string",
2720            "secret_env": secret_env,
2721        }))
2722        .unwrap()
2723    }
2724
2725    /// #207 MED-2: a manifest must not be able to name the host's own
2726    /// provider credential and have it handed over at initialize.
2727    #[test]
2728    fn host_provider_credentials_are_rejected() {
2729        for var in [
2730            "ANTHROPIC_API_KEY",
2731            "OPENAI_API_KEY",
2732            "AWS_SECRET_ACCESS_KEY",
2733            "anthropic_api_key", // case-insensitive
2734        ] {
2735            let err = ExtensionManager::resolve_config("evil", &[entry("k", var)], None)
2736                .expect_err(&format!("{var} must be rejected"));
2737            assert!(err.contains("host credential"), "unexpected error: {err}");
2738        }
2739    }
2740
2741    /// The whole SYNAPS_* namespace is the host's, except this extension's
2742    /// own scoped override.
2743    #[test]
2744    fn host_namespace_is_rejected_but_own_scope_is_allowed() {
2745        let err = ExtensionManager::resolve_config("evil", &[entry("k", "SYNAPS_BROKER_TOKEN")], None)
2746            .expect_err("host namespace must be rejected");
2747        assert!(err.contains("host namespace"), "unexpected error: {err}");
2748
2749        // an extension's own scoped var is fine
2750        assert!(
2751            ExtensionManager::denied_secret_env("SYNAPS_EXTENSION_MY_PLUGIN_TOKEN", "my-plugin")
2752                .is_none()
2753        );
2754    }
2755
2756    /// Third-party secrets an extension legitimately needs still work.
2757    #[test]
2758    fn third_party_secrets_are_still_allowed() {
2759        for var in ["GITHUB_TOKEN", "STRIPE_KEY", "MY_SERVICE_TOKEN"] {
2760            assert!(
2761                ExtensionManager::denied_secret_env(var, "some-ext").is_none(),
2762                "{var} should be allowed"
2763            );
2764        }
2765    }
2766
2767    #[test]
2768    fn empty_secret_env_is_rejected() {
2769        assert!(ExtensionManager::denied_secret_env("   ", "x").is_some());
2770    }
2771}