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    fn resolve_config(
872        id: &str,
873        entries: &[ExtensionConfigEntry],
874        host_project_root: Option<&str>,
875    ) -> Result<Value, String> {
876        let mut out = Map::new();
877        for entry in entries {
878            let key = entry.key.trim();
879            if key.is_empty() {
880                return Err(format!("Extension '{}' declares config with empty key", id));
881            }
882            if key.contains('.') || key.contains('/') || key.contains(' ') {
883                return Err(format!(
884                    "Extension '{}' config key '{}' must not contain dots, slashes, or spaces",
885                    id, key,
886                ));
887            }
888            // RESERVED host-context entries: the value comes EXCLUSIVELY
889            // from trusted host state recorded once by this manager. Env
890            // overrides, secret_env, plugin config, and legacy config keys
891            // are all deliberately skipped — user or model input can never
892            // supply or widen a host-context value.
893            if let Some(host_key) = entry.host_context {
894                if entry.secret_env.is_some() {
895                    return Err(format!(
896                        "Extension '{}' config key '{}' declares both host_context and secret_env; host-context values never carry secrets",
897                        id, key,
898                    ));
899                }
900                match host_key {
901                    crate::extensions::manifest::HostContextKey::ProjectRoot => {
902                        if let Some(root) = host_project_root {
903                            out.insert(key.to_string(), Value::String(root.to_string()));
904                        } else if let Some(default) = &entry.default {
905                            out.insert(key.to_string(), default.clone());
906                        } else if entry.required {
907                            return Err(format!(
908                                "Extension '{}' requires host context 'project_root' but the host could not resolve a trusted project root",
909                                id,
910                            ));
911                        }
912                    }
913                }
914                continue;
915            }
916            let config_key = format!("extension.{}.{}", id, key);
917            if let Ok(value) = std::env::var(format!(
918                "SYNAPS_EXTENSION_{}_{}",
919                id.replace('-', "_").to_ascii_uppercase(),
920                key.replace('-', "_").to_ascii_uppercase()
921            )) {
922                out.insert(key.to_string(), Value::String(value));
923                continue;
924            }
925            if let Some(secret_env) = &entry.secret_env {
926                if let Ok(value) = std::env::var(secret_env) {
927                    out.insert(key.to_string(), Value::String(value));
928                    continue;
929                }
930            }
931            if let Some(value) = crate::extensions::config_store::read_plugin_config(id, key) {
932                out.insert(key.to_string(), Value::String(value));
933                continue;
934            }
935            if let Some(value) = crate::config::read_config_value(&config_key) {
936                out.insert(key.to_string(), Value::String(value));
937                continue;
938            }
939            if let Some(default) = &entry.default {
940                out.insert(key.to_string(), default.clone());
941                continue;
942            }
943            if entry.required {
944                let hint = if let Some(secret_env) = &entry.secret_env {
945                    format!(
946                        "set environment variable '{}' or config key '{}'",
947                        secret_env, config_key
948                    )
949                } else {
950                    format!("set config key '{}'", config_key)
951                };
952                return Err(format!(
953                    "Extension '{}' missing required config '{}': {}",
954                    id, key, hint
955                ));
956            }
957        }
958        Ok(Value::Object(out))
959    }
960
961    /// Test-only seeder: synthetically insert capability declarations
962    /// for an extension id. Used to exercise capability snapshot
963    /// rendering without spinning up a real plugin process.
964    #[cfg(test)]
965    pub(crate) fn test_seed_capabilities(
966        &mut self,
967        id: &str,
968        decls: Vec<crate::extensions::runtime::process::CapabilityDeclaration>,
969    ) {
970        self.capabilities.insert(id.to_string(), decls);
971    }
972
973    /// Unload an extension — unsubscribe hooks and shut down the process.
974    ///
975    /// A DEFERRED tool-only extension (Task 20) has no live handler:
976    /// unloading it removes the retained launch record, deregisters its
977    /// dormant catalog batch (advancing the catalog generation so stale
978    /// grants cannot survive), and terminates every session's runtime
979    /// lease for the plugin.
980    pub async fn unload(&mut self, id: &str) -> Result<(), String> {
981        let deferred = self
982            .deferred_tool_only
983            .lock()
984            .unwrap_or_else(std::sync::PoisonError::into_inner)
985            .remove(id);
986        if let Some(record) = deferred {
987            // Record removed FIRST: any concurrent lease acquisition now
988            // fails closed (NotDeferred) regardless of the steps below.
989            // Context providers go with it (task A6): once unloading has
990            // begun, a memory-context enable can no longer resolve this
991            // extension's declared providers.
992            self.uncatalog_context_providers(id);
993            if let Some(runtime) = self.extension_runtime.get() {
994                let revoked = runtime.revoke_plugin_all_sessions(id);
995                if revoked > 0 {
996                    tracing::info!(extension = %id, leases = revoked,
997                        "Terminated live runtime leases of unloaded deferred extension");
998                }
999            }
1000            self.hook_bus.unsubscribe_all(id).await;
1001            self.providers.unregister_plugin(id);
1002            if let Some(tools) = &self.tools {
1003                let names: Vec<String> = record
1004                    .manifest
1005                    .deferred
1006                    .as_ref()
1007                    .map(|d| {
1008                        d.tools
1009                            .iter()
1010                            .map(|t| format!("{}:{}", id, t.name))
1011                            .collect()
1012                    })
1013                    .unwrap_or_default();
1014                if let Err(error) = tools.write().await.try_disable(&names) {
1015                    // Fail-closed either way: with the record gone, dormant
1016                    // descriptors can never execute; surface the partial
1017                    // cleanup instead of hiding it.
1018                    return Err(format!(
1019                        "Extension '{}' unloaded (record removed, leases revoked) but its dormant catalog batch could not be deregistered: {}",
1020                        id, error
1021                    ));
1022                }
1023            }
1024            self.manifest_configs.remove(id);
1025            self.manifest_theme_tokens.remove(id);
1026            tracing::info!(extension = %id, "Deferred extension unloaded");
1027            return Ok(());
1028        }
1029        let handler = self
1030            .extensions
1031            .remove(id)
1032            .ok_or_else(|| format!("Extension '{}' not found", id))?;
1033        // Task A6: cataloged context providers of a live extension are
1034        // removed with it.
1035        self.uncatalog_context_providers(id);
1036
1037        self.hook_bus.unsubscribe_all(id).await;
1038        self.providers.unregister_plugin(id);
1039        self.manifest_configs.remove(id);
1040        self.capabilities.remove(id);
1041        self.plugin_info.remove(id);
1042        self.manifest_theme_tokens.remove(id);
1043        handler.shutdown().await;
1044
1045        tracing::info!(extension = %id, "Extension unloaded");
1046        Ok(())
1047    }
1048
1049    /// Reload one extension by unloading any existing instance first, then loading
1050    /// the supplied manifest. If the new load fails, the previous instance remains
1051    /// unloaded so duplicate handlers cannot survive a broken reload.
1052    pub async fn reload(
1053        &mut self,
1054        id: &str,
1055        manifest: &ExtensionManifest,
1056        cwd: Option<std::path::PathBuf>,
1057    ) -> Result<(), String> {
1058        if self.extensions.contains_key(id) || self.is_deferred_tool_only(id) {
1059            self.unload(id).await?;
1060        }
1061        self.load_with_cwd(id, manifest, cwd).await
1062    }
1063
1064    /// Shut down all extensions gracefully.
1065    pub async fn shutdown_all(&mut self) {
1066        let ids: Vec<String> = self.extensions.keys().cloned().collect();
1067        for id in ids {
1068            let _ = self.unload(&id).await;
1069        }
1070        // Deferred extensions (Task 20): unload each retained record so
1071        // dormant catalog batches and any live runtime leases go with it.
1072        let deferred_ids: Vec<String> = {
1073            let map = self
1074                .deferred_tool_only
1075                .lock()
1076                .unwrap_or_else(std::sync::PoisonError::into_inner);
1077            map.keys().cloned().collect()
1078        };
1079        for id in deferred_ids {
1080            let _ = self.unload(&id).await;
1081        }
1082        if let Some(runtime) = self.extension_runtime.get() {
1083            runtime.terminate_all();
1084        }
1085    }
1086
1087    /// Start shutting down all extensions in the background.
1088    ///
1089    /// This is intended for process exit: the UI should not hang waiting for
1090    /// extension child processes to acknowledge shutdown. Dropping the join handle
1091    /// lets Tokio abort remaining work when the runtime exits.
1092    pub fn shutdown_all_detached(
1093        manager: Arc<tokio::sync::RwLock<Self>>,
1094    ) -> tokio::task::JoinHandle<()> {
1095        tokio::spawn(async move {
1096            manager.write().await.shutdown_all().await;
1097        })
1098    }
1099
1100    /// List running extension IDs.
1101    pub fn list(&self) -> Vec<&str> {
1102        self.extensions.keys().map(|s| s.as_str()).collect()
1103    }
1104
1105    /// Return Arc references to all running extension handlers, sorted by ID.
1106    /// Intended for background notification watchers that need to hold onto
1107    /// handlers beyond the lifetime of a manager lock.
1108    pub fn handlers(&self) -> Vec<(String, Arc<dyn super::runtime::ExtensionHandler>)> {
1109        let mut out: Vec<_> = self
1110            .extensions
1111            .iter()
1112            .map(|(id, h)| (id.clone(), Arc::clone(h)))
1113            .collect();
1114        out.sort_by(|a, b| a.0.cmp(&b.0));
1115        out
1116    }
1117
1118    /// Number of running extensions.
1119    pub fn count(&self) -> usize {
1120        self.extensions.len()
1121    }
1122
1123    /// Return health snapshots for all loaded extensions, sorted by ID.
1124    pub async fn statuses(&self) -> Vec<ExtensionStatus> {
1125        let mut handlers: Vec<(String, Arc<dyn ExtensionHandler>)> = self
1126            .extensions
1127            .iter()
1128            .map(|(id, handler)| (id.clone(), handler.clone()))
1129            .collect();
1130        handlers.sort_by(|a, b| a.0.cmp(&b.0));
1131
1132        let mut statuses = Vec::with_capacity(handlers.len());
1133        for (id, handler) in handlers {
1134            statuses.push(ExtensionStatus {
1135                id,
1136                health: handler.health().await,
1137                restart_count: handler.restart_count().await,
1138            });
1139        }
1140        statuses
1141    }
1142
1143    /// Return registered provider metadata sorted by runtime id.
1144    pub fn providers(&self) -> Vec<&RegisteredProvider> {
1145        self.providers.list()
1146    }
1147
1148    /// Return registered provider metadata by runtime id.
1149    pub fn provider(&self, runtime_id: &str) -> Option<&RegisteredProvider> {
1150        self.providers.get(runtime_id)
1151    }
1152
1153    /// Return DORMANT context-provider descriptors declared by loaded
1154    /// extensions, sorted by runtime address (task A6; mirrors
1155    /// [`Self::providers`] for the UNRELATED model-provider catalog).
1156    /// Pure catalog read — discovery/status spawn zero processes, and
1157    /// holding a descriptor grants nothing.
1158    pub fn context_providers(&self) -> Vec<&RegisteredContextProviderDescriptor> {
1159        let mut out: Vec<&RegisteredContextProviderDescriptor> =
1160            self.context_providers.iter().collect();
1161        out.sort_by_key(|descriptor| descriptor.runtime_address());
1162        out
1163    }
1164
1165    /// Exact context-provider lookup by declared id (task A6; mirrors
1166    /// [`Self::provider`]). `Some` iff EXACTLY ONE loaded extension
1167    /// declares `provider_id` — zero matches and overlapping declarations
1168    /// (the same id declared by multiple installed extensions) both
1169    /// resolve to `None`, fail closed, no partial matches. Pure catalog
1170    /// read — never spawns.
1171    pub fn context_provider(
1172        &self,
1173        provider_id: &ContextProviderId,
1174    ) -> Option<&RegisteredContextProviderDescriptor> {
1175        resolve_context_provider(&self.context_providers, Some(provider_id)).ok()
1176    }
1177
1178    /// Return optional cached plugin info reported by `info.get`.
1179    pub fn plugin_info(&self, id: &str) -> Option<&PluginInfo> {
1180        self.plugin_info.get(id)
1181    }
1182
1183    /// Manifest-declared theme tokens for every loaded extension, sorted by
1184    /// extension ID (P19.2). Each entry is `(plugin-id, token -> hex color)`;
1185    /// the TUI namespaces these as `ext.<plugin-id>.<token>` when merging
1186    /// into the active theme. Extensions without `theme_tokens` don't appear.
1187    pub fn theme_tokens(&self) -> Vec<(String, std::collections::BTreeMap<String, String>)> {
1188        let mut out: Vec<_> = self
1189            .manifest_theme_tokens
1190            .iter()
1191            .map(|(id, tokens)| (id.clone(), tokens.clone()))
1192            .collect();
1193        out.sort_by(|a, b| a.0.cmp(&b.0));
1194        out
1195    }
1196
1197    /// Ask a plugin for its sidecar spawn arguments. Best-effort —
1198    /// plugins that don't host a sidecar (or pre-Phase-7 plugins that
1199    /// haven't implemented the RPC yet) return `Err`. Callers are
1200    /// expected to treat that as "no overrides; use manifest defaults".
1201    /// Handler for explicit USER-triggered plugin APIs (sidecar spawn,
1202    /// interactive commands, settings editors). Live extensions use their
1203    /// running handler; a DEFERRED extension (Task 20) gets a lazy handler
1204    /// whose first use acquires the shared per-plugin runtime lease — a
1205    /// user action is a legitimate activation trigger. Discovery/search/
1206    /// diagnostics never call this.
1207    fn user_action_handler(
1208        &self,
1209        id: &str,
1210    ) -> Result<Arc<dyn super::runtime::ExtensionHandler>, String> {
1211        if let Some(handler) = self.extensions.get(id) {
1212            return Ok(handler.clone());
1213        }
1214        if self.is_deferred(id) {
1215            return Ok(Arc::new(
1216                crate::extensions::lease::LazyExtensionHandler::new(id, self.extension_runtime()),
1217            ));
1218        }
1219        Err(format!("unknown extension '{}'", id))
1220    }
1221
1222    pub async fn sidecar_spawn_args(
1223        &self,
1224        id: &str,
1225    ) -> Result<crate::sidecar::spawn::SidecarSpawnArgs, String> {
1226        let handler = self.user_action_handler(id)?;
1227        handler.sidecar_spawn_args().await
1228    }
1229
1230    /// Invoke an interactive plugin command on extension `id`. Streams
1231    /// `command.output` (matching `request_id`) and `task.*` notifications
1232    /// to `sink`. Returns the final JSON-RPC response value.
1233    ///
1234    /// CP-11 fix-3: `sink` is the bounded, metered producer half of
1235    /// [`crate::extensions::invoke_output::invoke_event_channel`]; the
1236    /// caller MUST drive the matching collector concurrently with this
1237    /// call (an awaited bounded send with no consumer would stall the
1238    /// invocation loop until the 120 s timeout). Prefer
1239    /// [`Self::invoke_command_collected`], which wires that correctly.
1240    pub async fn invoke_command(
1241        &self,
1242        id: &str,
1243        command: &str,
1244        args: Vec<String>,
1245        request_id: &str,
1246        sink: crate::extensions::invoke_output::InvokeEventSink,
1247    ) -> Result<serde_json::Value, String> {
1248        let handler = self.user_action_handler(id)?;
1249        handler
1250            .invoke_command(command, args, request_id, sink)
1251            .await
1252    }
1253
1254    /// Invoke an interactive plugin command and collect its budgeted
1255    /// output. This is THE production entry point for user-facing
1256    /// surfaces: it joins the invocation with an eagerly concurrent
1257    /// collector so hostile `command.output` floods are paced and capped
1258    /// at production time (never parked unbounded), then returns the
1259    /// final response alongside the retained events and exact
1260    /// produced/consumed/retained/truncated/dropped accounting.
1261    ///
1262    /// Dropping the returned future cancels the invocation, drops the
1263    /// sink, and releases the collector plus any blocked producers — no
1264    /// detached tasks are spawned.
1265    pub async fn invoke_command_collected(
1266        &self,
1267        id: &str,
1268        command: &str,
1269        args: Vec<String>,
1270        request_id: &str,
1271    ) -> (
1272        Result<serde_json::Value, String>,
1273        crate::extensions::invoke_output::InvokeOutputReport,
1274    ) {
1275        let (sink, collector) = crate::extensions::invoke_output::invoke_event_channel(
1276            crate::extensions::invoke_output::InvokeOutputBudget::default(),
1277        );
1278        let handler = match self.user_action_handler(id) {
1279            Ok(handler) => handler,
1280            Err(error) => {
1281                drop(sink);
1282                return (Err(error), collector.collect().await);
1283            }
1284        };
1285        let invoke = handler.invoke_command(command, args, request_id, sink);
1286        let (result, report) = tokio::join!(invoke, collector.collect());
1287        (result, report)
1288    }
1289
1290    pub async fn settings_editor_open(
1291        &self,
1292        id: &str,
1293        category: &str,
1294        field: &str,
1295    ) -> Result<serde_json::Value, String> {
1296        let handler = self.user_action_handler(id)?;
1297        handler.settings_editor_open(category, field).await
1298    }
1299
1300    pub async fn settings_editor_key(
1301        &self,
1302        id: &str,
1303        category: &str,
1304        field: &str,
1305        key: &str,
1306    ) -> Result<serde_json::Value, String> {
1307        let handler = self.user_action_handler(id)?;
1308        handler.settings_editor_key(category, field, key).await
1309    }
1310
1311    pub async fn settings_editor_commit(
1312        &self,
1313        id: &str,
1314        category: &str,
1315        field: &str,
1316        value: serde_json::Value,
1317    ) -> Result<serde_json::Value, String> {
1318        let handler = self.user_action_handler(id)?;
1319        handler.settings_editor_commit(category, field, value).await
1320    }
1321
1322    /// Return all cached plugin info sorted by extension id.
1323    pub fn plugin_infos(&self) -> Vec<(&str, &PluginInfo)> {
1324        let mut entries: Vec<_> = self
1325            .plugin_info
1326            .iter()
1327            .map(|(id, info)| (id.as_str(), info))
1328            .collect();
1329        entries.sort_by(|a, b| a.0.cmp(b.0));
1330        entries
1331    }
1332
1333    /// Return provider status summaries sorted by provider runtime id.
1334    pub fn provider_summaries(&self) -> Vec<RegisteredProviderSummary> {
1335        self.providers.summaries()
1336    }
1337
1338    /// Unified capability snapshot per loaded extension, sorted by id.
1339    ///
1340    /// Aggregates hook subscriptions, extension-provided tools, and registered
1341    /// providers. `future` carries plugin-defined capability kinds and
1342    /// capabilities land.
1343    pub async fn capability_snapshots(&self) -> Vec<ExtensionCapabilitySnapshot> {
1344        let mut handlers: Vec<(String, Arc<dyn ExtensionHandler>)> = self
1345            .extensions
1346            .iter()
1347            .map(|(id, handler)| (id.clone(), handler.clone()))
1348            .collect();
1349        handlers.sort_by(|a, b| a.0.cmp(&b.0));
1350
1351        let provider_summaries = self.providers.summaries();
1352        let plugin_id_lookup: std::collections::HashMap<String, String> = self
1353            .providers
1354            .list()
1355            .into_iter()
1356            .map(|p| (p.runtime_id.clone(), p.plugin_id.clone()))
1357            .collect();
1358
1359        let mut out = Vec::with_capacity(handlers.len());
1360        for (id, handler) in handlers {
1361            let health = handler.health().await;
1362            let restart_count = handler.restart_count().await;
1363
1364            let hook_pairs = self.hook_bus.subscriptions_for(&id).await;
1365            let hooks: Vec<HookCapabilityEntry> = hook_pairs
1366                .into_iter()
1367                .map(|(kind, tool_filter)| HookCapabilityEntry {
1368                    kind: kind.as_str().to_string(),
1369                    tool_filter,
1370                })
1371                .collect();
1372
1373            let tools: Vec<ToolCapabilityEntry> = if let Some(tools) = &self.tools {
1374                let registry = tools.read().await;
1375                registry
1376                    .tool_names_for_extension(&id)
1377                    .into_iter()
1378                    .map(|name| ToolCapabilityEntry { name })
1379                    .collect()
1380            } else {
1381                Vec::new()
1382            };
1383
1384            let providers: Vec<RegisteredProviderSummary> = provider_summaries
1385                .iter()
1386                .filter(|summary| {
1387                    plugin_id_lookup
1388                        .get(&summary.runtime_id)
1389                        .map(|p| p == &id)
1390                        .unwrap_or(false)
1391                })
1392                .cloned()
1393                .collect();
1394
1395            let future: Vec<FutureCapabilityEntry> = self
1396                .capabilities
1397                .get(&id)
1398                .map(|decls| {
1399                    decls
1400                        .iter()
1401                        .map(|d| FutureCapabilityEntry {
1402                            kind: d.kind.clone(),
1403                            name: d.name.clone(),
1404                        })
1405                        .collect()
1406                })
1407                .unwrap_or_default();
1408
1409            out.push(ExtensionCapabilitySnapshot {
1410                id,
1411                health,
1412                restart_count,
1413                hooks,
1414                tools,
1415                providers,
1416                future,
1417            });
1418        }
1419        out
1420    }
1421
1422    /// Return runtime ids of registered providers that declare at least one
1423    /// tool-use-capable model. Sorted by runtime id.
1424    pub fn provider_tool_use_runtime_ids(&self) -> Vec<String> {
1425        let mut ids: Vec<String> = self
1426            .providers
1427            .list()
1428            .into_iter()
1429            .filter(|p| {
1430                p.spec.models.iter().any(|m| {
1431                    m.capabilities
1432                        .get("tool_use")
1433                        .and_then(|v| v.as_bool())
1434                        .unwrap_or(false)
1435                })
1436            })
1437            .map(|p| p.runtime_id.clone())
1438            .collect();
1439        ids.sort();
1440        ids
1441    }
1442
1443    /// Return a `runtime_id -> enabled` map for every registered provider, computed
1444    /// from the persisted trust state. Providers without an entry default to
1445    /// enabled. If the trust state file is missing, all providers are reported
1446    /// as enabled (default). If the file is corrupt, all providers are reported
1447    /// as **disabled** (fail-closed) and a warning is logged.
1448    pub fn provider_trust_view(&self) -> std::collections::BTreeMap<String, bool> {
1449        let trust = match crate::extensions::trust::load_trust_state() {
1450            Ok(t) => t,
1451            Err(e) => {
1452                tracing::warn!("trust.json corrupt or unreadable, failing closed (all providers disabled): {e}");
1453                // Return all providers as disabled
1454                return self
1455                    .providers
1456                    .list()
1457                    .into_iter()
1458                    .map(|p| (p.runtime_id.clone(), false))
1459                    .collect();
1460            }
1461        };
1462        self.providers
1463            .list()
1464            .into_iter()
1465            .map(|p| {
1466                let enabled = crate::extensions::trust::is_provider_enabled(&trust, &p.runtime_id);
1467                (p.runtime_id.clone(), enabled)
1468            })
1469            .collect()
1470    }
1471
1472    /// Compute config diagnostics for a loaded extension by id.
1473    /// Returns `None` if the extension is not loaded.
1474    pub fn config_diagnostics(&self, id: &str) -> Option<ExtensionConfigDiagnostics> {
1475        let manifest_config = self.manifest_configs.get(id)?;
1476
1477        // Collect provider required keys from registered providers' config_schema.
1478        let mut provider_required: Vec<(String, Vec<String>)> = Vec::new();
1479        for provider in self.providers.list() {
1480            if provider.plugin_id != id {
1481                continue;
1482            }
1483            let required: Vec<String> = provider
1484                .spec
1485                .config_schema
1486                .as_ref()
1487                .and_then(|schema| schema.get("required"))
1488                .and_then(Value::as_array)
1489                .map(|arr| {
1490                    arr.iter()
1491                        .filter_map(|v| v.as_str().map(|s| s.to_string()))
1492                        .collect()
1493                })
1494                .unwrap_or_default();
1495            provider_required.push((provider.provider_id.clone(), required));
1496        }
1497        provider_required.sort_by(|a, b| a.0.cmp(&b.0));
1498
1499        let env_lookup = |name: &str| std::env::var(name).ok();
1500        let plugin_config_lookup =
1501            |key: &str| crate::extensions::config_store::read_plugin_config(id, key);
1502        let legacy_config_lookup = |key: &str| crate::config::read_config_value(key);
1503
1504        Some(diagnose_extension_config(
1505            id,
1506            manifest_config,
1507            &provider_required,
1508            &env_lookup,
1509            &plugin_config_lookup,
1510            &legacy_config_lookup,
1511        ))
1512    }
1513
1514    /// Diagnostics for all loaded extensions, sorted alphabetically by id.
1515    pub fn all_config_diagnostics(&self) -> Vec<ExtensionConfigDiagnostics> {
1516        let mut ids: Vec<&String> = self.manifest_configs.keys().collect();
1517        ids.sort();
1518        ids.into_iter()
1519            .filter_map(|id| self.config_diagnostics(id))
1520            .collect()
1521    }
1522
1523    /// Get the shared hook bus.
1524    pub fn hook_bus(&self) -> &Arc<HookBus> {
1525        &self.hook_bus
1526    }
1527
1528    /// Get the shared tool registry, when this manager was constructed with one.
1529    pub fn tools_shared(&self) -> Option<Arc<tokio::sync::RwLock<crate::ToolRegistry>>> {
1530        self.tools.clone()
1531    }
1532
1533    /// Test-only: register a provider spec + in-process handler directly,
1534    /// without spawning an extension process. Used by trace wiring tests.
1535    #[cfg(test)]
1536    pub(crate) fn register_provider_handler_for_tests(
1537        &mut self,
1538        plugin_id: &str,
1539        spec: crate::extensions::runtime::process::RegisteredProviderSpec,
1540        handler: Arc<dyn ExtensionHandler>,
1541    ) -> Result<String, String> {
1542        self.providers
1543            .register_with_handler(plugin_id, spec, Some(handler))
1544    }
1545
1546    /// Discover and load all extensions from the user and project plugin directories.
1547    ///
1548    /// Scans `~/.synaps-cli/plugins/*/.synaps-plugin/plugin.json` and
1549    /// `./.synaps/plugins/*/.synaps-plugin/plugin.json` for manifests that contain
1550    /// an `extension` field. Project-local plugins override user plugins with the
1551    /// same directory name.
1552    pub async fn discover_and_load(&mut self) -> (Vec<String>, Vec<ExtensionLoadFailure>) {
1553        self.discover_and_load_with_progress(|_| {}).await
1554    }
1555
1556    /// Discover and load all extensions, invoking `progress` after each load
1557    /// attempt. Used by the async UI loader to update startup toasts without
1558    /// blocking first paint.
1559    pub async fn discover_and_load_with_progress<F>(
1560        &mut self,
1561        mut progress: F,
1562    ) -> (Vec<String>, Vec<ExtensionLoadFailure>)
1563    where
1564        F: FnMut(crate::extensions::loader::ExtensionLoaderEvent),
1565    {
1566        let mut plugin_roots = vec![crate::config::base_dir().join("plugins")];
1567        if !project_plugins_disabled() {
1568            if let Ok(cwd) = std::env::current_dir() {
1569                let project_plugins = cwd.join(".synaps").join("plugins");
1570                if project_plugins != plugin_roots[0] {
1571                    plugin_roots.push(project_plugins);
1572                }
1573            }
1574        }
1575
1576        let mut plugin_dirs: HashMap<String, PathBuf> = HashMap::new();
1577        let mut failed: Vec<ExtensionLoadFailure> = Vec::new();
1578
1579        for plugins_dir in plugin_roots {
1580            if !plugins_dir.exists() {
1581                continue;
1582            }
1583
1584            let entries = match std::fs::read_dir(&plugins_dir) {
1585                Ok(e) => e,
1586                Err(e) => {
1587                    tracing::warn!(path = %plugins_dir.display(), error = %e, "Failed to read plugins directory");
1588                    failed.push(ExtensionLoadFailure::new(
1589                        "plugins",
1590                        Some(plugins_dir.clone()),
1591                        format!("Failed to read plugins directory: {e}"),
1592                        "Check directory permissions and retry",
1593                    ));
1594                    continue;
1595                }
1596            };
1597
1598            for entry in entries.flatten() {
1599                let plugin_name = entry.file_name().to_string_lossy().to_string();
1600                if is_inert_plugin_dir_name(&plugin_name) {
1601                    tracing::debug!(
1602                        plugin = %plugin_name,
1603                        path = %entry.path().display(),
1604                        "Skipping backup/disabled/temp plugin directory"
1605                    );
1606                    continue;
1607                }
1608                plugin_dirs.insert(plugin_name, entry.path());
1609            }
1610        }
1611
1612        let mut plugin_dirs: Vec<(String, PathBuf)> = plugin_dirs.into_iter().collect();
1613        plugin_dirs.sort_by(|a, b| a.0.cmp(&b.0));
1614
1615        let mut loaded = Vec::new();
1616        let disabled_plugins = crate::config::load_config().disabled_plugins;
1617        // Hoist plugins.json read out of the per-plugin loop — was N reads + parses
1618        // of the same file (one per discovered plugin). Read it once here.
1619        let plugins_state = {
1620            let state_path = crate::skills::state::PluginsState::default_path();
1621            crate::skills::state::PluginsState::load_from(&state_path).unwrap_or_default()
1622        };
1623        for (plugin_name, plugin_dir) in plugin_dirs {
1624            if disabled_plugins.iter().any(|d| d == &plugin_name) {
1625                tracing::debug!(plugin = %plugin_name, "Extension disabled via disabled_plugins config");
1626                continue;
1627            }
1628            if let Some(message) = installed_plugin_setup_failure_in(&plugins_state, &plugin_name) {
1629                tracing::warn!(plugin = %plugin_name, error = %message, "Skipping extension with failed post-install setup");
1630                failed.push(ExtensionLoadFailure::new(
1631                    plugin_name,
1632                    None,
1633                    format!("Post-install setup failed: {message}"),
1634                    "Open /plugins, reinstall or update the plugin after fixing setup; extension load is disabled until setup succeeds",
1635                ));
1636                continue;
1637            }
1638            let manifest_path = plugin_dir.join(".synaps-plugin").join("plugin.json");
1639            if !manifest_path.exists() {
1640                continue;
1641            }
1642
1643            let content = match std::fs::read_to_string(&manifest_path) {
1644                Ok(c) => c,
1645                Err(e) => {
1646                    let reason = format!("Failed to read plugin manifest: {e}");
1647                    tracing::warn!(plugin = %plugin_name, manifest = %manifest_path.display(), error = %e, "Failed to read plugin manifest");
1648                    failed.push(ExtensionLoadFailure::new(
1649                        plugin_name,
1650                        Some(manifest_path),
1651                        reason,
1652                        "Check manifest file permissions, then run `plugin validate <plugin-dir>`",
1653                    ));
1654                    continue;
1655                }
1656            };
1657
1658            let json: serde_json::Value = match serde_json::from_str(&content) {
1659                Ok(v) => v,
1660                Err(e) => {
1661                    let reason = format!("Invalid plugin manifest JSON: {e}");
1662                    tracing::warn!(plugin = %plugin_name, manifest = %manifest_path.display(), error = %e, "Invalid plugin manifest JSON");
1663                    failed.push(ExtensionLoadFailure::new(
1664                        plugin_name,
1665                        Some(manifest_path),
1666                        reason,
1667                        "Fix JSON syntax, then run `plugin validate <plugin-dir>`",
1668                    ));
1669                    continue;
1670                }
1671            };
1672
1673            let ext_value = match json.get("extension") {
1674                Some(v) => v.clone(),
1675                None => continue,
1676            };
1677
1678            let ext_manifest: ExtensionManifest = match serde_json::from_value(ext_value) {
1679                Ok(m) => m,
1680                Err(e) => {
1681                    let reason = format!("Failed to parse extension manifest: {e}");
1682                    tracing::warn!(plugin = %plugin_name, manifest = %manifest_path.display(), error = %e, "Failed to parse extension manifest");
1683                    failed.push(ExtensionLoadFailure::new(
1684                        plugin_name,
1685                        Some(manifest_path),
1686                        reason,
1687                        "Check the `extension` block shape against docs/extensions/contract.json, then run `plugin validate <plugin-dir>`",
1688                    ));
1689                    continue;
1690                }
1691            };
1692
1693            #[allow(clippy::if_same_then_else)]
1694            let command = if std::path::Path::new(&ext_manifest.command).is_absolute() {
1695                ext_manifest.command.clone()
1696            } else if !ext_manifest.command.contains(std::path::MAIN_SEPARATOR)
1697                && !ext_manifest.command.contains('/')
1698            {
1699                ext_manifest.command.clone()
1700            } else {
1701                plugin_dir
1702                    .join(&ext_manifest.command)
1703                    .to_string_lossy()
1704                    .to_string()
1705            };
1706
1707            let args: Vec<String> = ext_manifest
1708                .args
1709                .iter()
1710                .map(|arg| {
1711                    let arg_path = plugin_dir.join(arg);
1712                    if arg_path.exists() {
1713                        if let (Ok(canonical), Ok(plugin_canonical)) =
1714                            (arg_path.canonicalize(), plugin_dir.canonicalize())
1715                        {
1716                            if canonical.starts_with(&plugin_canonical) {
1717                                return canonical.to_string_lossy().to_string();
1718                            }
1719                        }
1720                    }
1721                    arg.clone()
1722                })
1723                .collect();
1724
1725            let resolved = ExtensionManifest {
1726                theme_tokens: Default::default(),
1727                // Deferred declarations flow through from the installed
1728                // manifest (legacy `tools`/`activation` aliases are folded
1729                // into `deferred` at deserialization) so installed plugins
1730                // keep their zero-spawn deferred lifecycle instead of
1731                // silently loading eager.
1732                command,
1733                args,
1734                ..ext_manifest
1735            };
1736
1737            match self
1738                .load_with_cwd(&plugin_name, &resolved, Some(plugin_dir.clone()))
1739                .await
1740            {
1741                Ok(()) => {
1742                    tracing::info!(plugin = %plugin_name, path = %plugin_dir.display(), "Extension loaded from plugins/");
1743                    loaded.push(plugin_name.clone());
1744                    progress(crate::extensions::loader::ExtensionLoaderEvent::Loaded {
1745                        plugin: plugin_name,
1746                        loaded: loaded.len(),
1747                        failed: failed.len(),
1748                    });
1749                }
1750                Err(e) => {
1751                    tracing::warn!(plugin = %plugin_name, manifest = %manifest_path.display(), error = %e, "Failed to load extension");
1752                    let setup_script = json
1753                        .pointer("/extension/setup")
1754                        .and_then(|v| v.as_str())
1755                        .or_else(|| {
1756                            json.pointer("/provides/sidecar/setup")
1757                                .and_then(|v| v.as_str())
1758                        });
1759                    let hint = compute_extension_load_hint(&e, &plugin_dir, setup_script);
1760                    let failure =
1761                        ExtensionLoadFailure::new(plugin_name, Some(manifest_path), e, hint);
1762                    failed.push(failure.clone());
1763                    progress(crate::extensions::loader::ExtensionLoaderEvent::Failed {
1764                        failure,
1765                        loaded: loaded.len(),
1766                        failed: failed.len(),
1767                    });
1768                }
1769            }
1770        }
1771
1772        (loaded, failed)
1773    }
1774}
1775
1776#[cfg(test)]
1777mod invoke_command_collected_tests {
1778    //! CP-11 fix-3: prove the manager's collected entry point pairs the
1779    //! bounded sink with an eagerly concurrent collector — an in-process
1780    //! handler that floods far past the channel capacity AND the retained
1781    //! budget must neither deadlock nor grow retention, and accounting
1782    //! must be exact.
1783    use super::*;
1784    use crate::extensions::commands::CommandOutputEvent;
1785    use crate::extensions::invoke_output::INVOKE_RETAINED_BYTE_BUDGET;
1786    use crate::extensions::runtime::InvokeCommandEvent;
1787
1788    struct FloodingHandler;
1789
1790    #[async_trait::async_trait]
1791    impl super::ExtensionHandler for FloodingHandler {
1792        fn id(&self) -> &str {
1793            "flooding-fake"
1794        }
1795        async fn handle(
1796            &self,
1797            _event: &crate::extensions::hooks::events::HookEvent,
1798        ) -> crate::extensions::hooks::events::HookResult {
1799            Default::default()
1800        }
1801        async fn shutdown(&self) {}
1802
1803        async fn invoke_command(
1804            &self,
1805            _command: &str,
1806            _args: Vec<String>,
1807            _request_id: &str,
1808            sink: crate::extensions::invoke_output::InvokeEventSink,
1809        ) -> Result<Value, String> {
1810            // 2048 x 4 KiB = 8 MiB >> 256 KiB budget, and 2048 events
1811            // >> channel capacity 8: every awaited send past capacity
1812            // requires the concurrent collector to make progress.
1813            for _ in 0..2048 {
1814                let event = InvokeCommandEvent::Output(CommandOutputEvent::Text {
1815                    content: "f".repeat(4096),
1816                });
1817                if sink.send(event).await.is_err() {
1818                    return Err("collector vanished".to_string());
1819                }
1820            }
1821            let _ = sink
1822                .send(InvokeCommandEvent::Output(CommandOutputEvent::Done))
1823                .await;
1824            Ok(serde_json::json!({"status": "flooded"}))
1825        }
1826    }
1827
1828    #[tokio::test]
1829    async fn collected_invoke_bounds_in_process_flood_without_deadlock() {
1830        let bus = Arc::new(HookBus::new());
1831        let mut mgr = ExtensionManager::new(bus);
1832        mgr.extensions
1833            .insert("flooding-fake".to_string(), Arc::new(FloodingHandler));
1834
1835        let (result, report) = tokio::time::timeout(
1836            std::time::Duration::from_secs(30),
1837            mgr.invoke_command_collected("flooding-fake", "any", vec![], "req-1"),
1838        )
1839        .await
1840        .expect("bounded sink + concurrent collector must not deadlock");
1841
1842        assert_eq!(result.expect("invoke ok")["status"], "flooded");
1843        let c = &report.counters;
1844        assert_eq!(c.produced_events, 2049);
1845        assert_eq!(c.produced_payload_bytes, 2048 * 4096);
1846        assert_eq!(c.consumed_events, c.produced_events);
1847        assert_eq!(c.consumed_payload_bytes, c.produced_payload_bytes);
1848        assert_eq!(c.retained_payload_bytes, INVOKE_RETAINED_BYTE_BUDGET as u64);
1849        assert!(c.saw_done);
1850        assert_eq!(
1851            c.consumed_payload_bytes,
1852            c.retained_payload_bytes + c.truncated_payload_bytes + c.dropped_payload_bytes,
1853        );
1854        assert_eq!(c.consumed_events, c.retained_events + c.dropped_events);
1855    }
1856
1857    #[tokio::test]
1858    async fn collected_invoke_reports_unknown_extension_with_empty_report() {
1859        let bus = Arc::new(HookBus::new());
1860        let mgr = ExtensionManager::new(bus);
1861        let (result, report) = mgr
1862            .invoke_command_collected("nope", "any", vec![], "req-1")
1863            .await;
1864        assert!(result
1865            .expect_err("unknown id")
1866            .contains("unknown extension"));
1867        assert!(report.events.is_empty());
1868        assert_eq!(report.counters.produced_events, 0);
1869    }
1870}
1871
1872#[cfg(test)]
1873mod tests {
1874    use super::*;
1875
1876    /// Task A6: context-provider manifest for one plugin (no tools, no
1877    /// hooks — classifies as `ContextProvider` and defers without spawn).
1878    fn context_provider_manifest(provider_id: &str) -> ExtensionManifest {
1879        serde_json::from_value(serde_json::json!({
1880            "runtime": "process",
1881            "command": "/bin/false",
1882            "permissions": ["context_providers.register"],
1883            "deferred": {
1884                "context_providers": [{
1885                    "id": provider_id,
1886                    "capability": "project-memory",
1887                    "description": "test context provider",
1888                    "schema_version": 1
1889                }]
1890            }
1891        }))
1892        .unwrap()
1893    }
1894
1895    /// Task A6: the context-provider catalog mirrors the model-provider
1896    /// catalog shape — populated at load, exact fail-closed lookup, kept
1897    /// current across unload, shared with the lease manager — and every
1898    /// read is pure metadata: zero processes are ever started.
1899    #[tokio::test]
1900    async fn context_provider_catalog_lists_resolves_and_unloads_exactly() {
1901        let mut mgr = ExtensionManager::new(Arc::new(HookBus::new()));
1902        mgr.set_progressive_deferral(true);
1903        mgr.load("mem-a", &context_provider_manifest("alpha"))
1904            .await
1905            .unwrap();
1906        mgr.load("mem-b", &context_provider_manifest("beta"))
1907            .await
1908            .unwrap();
1909
1910        // Catalog reads: sorted by runtime address, exact ids only.
1911        let listed: Vec<String> = mgr
1912            .context_providers()
1913            .iter()
1914            .map(|d| d.runtime_address())
1915            .collect();
1916        assert_eq!(
1917            listed,
1918            vec![
1919                "extension:mem-a:alpha".to_string(),
1920                "extension:mem-b:beta".to_string(),
1921            ]
1922        );
1923        let alpha = super::super::context_provider::ContextProviderId::parse("alpha").unwrap();
1924        assert_eq!(
1925            mgr.context_provider(&alpha).map(|d| d.extension_id()),
1926            Some("mem-a")
1927        );
1928        let unknown = super::super::context_provider::ContextProviderId::parse("nope").unwrap();
1929        assert!(mgr.context_provider(&unknown).is_none(), "fail closed");
1930
1931        // The shared lease-manager handle observes the same catalog.
1932        let runtime = mgr.extension_runtime();
1933        assert_eq!(
1934            runtime
1935                .resolve_context_provider(Some(&alpha))
1936                .map(|d| d.runtime_address()),
1937            Ok("extension:mem-a:alpha".to_string())
1938        );
1939
1940        // An overlapping declaration makes the id ambiguous — fail closed
1941        // on BOTH read surfaces, never an arbitrary pick.
1942        mgr.load("mem-c", &context_provider_manifest("alpha"))
1943            .await
1944            .unwrap();
1945        assert!(mgr.context_provider(&alpha).is_none(), "ambiguous → None");
1946        assert_eq!(
1947            runtime.resolve_context_provider(Some(&alpha)),
1948            Err(super::super::context_provider::ContextProviderLookupError::Ambiguous)
1949        );
1950
1951        // Unload removes exactly the owner's descriptors and republishes.
1952        mgr.unload("mem-c").await.unwrap();
1953        assert_eq!(
1954            mgr.context_provider(&alpha).map(|d| d.extension_id()),
1955            Some("mem-a")
1956        );
1957        mgr.unload("mem-a").await.unwrap();
1958        assert_eq!(
1959            runtime.resolve_context_provider(Some(&alpha)),
1960            Err(super::super::context_provider::ContextProviderLookupError::NotRegistered)
1961        );
1962        assert_eq!(mgr.context_providers().len(), 1, "mem-b remains");
1963
1964        // Pure catalog discipline: nothing above started a process.
1965        assert_eq!(mgr.count(), 0, "no live extension handlers");
1966        assert_eq!(runtime.lease_count(), 0, "no runtime leases");
1967    }
1968
1969    #[tokio::test]
1970    async fn capability_snapshots_empty_when_no_extensions() {
1971        let bus = Arc::new(HookBus::new());
1972        let mgr = ExtensionManager::new(bus);
1973        assert!(mgr.capability_snapshots().await.is_empty());
1974    }
1975
1976    /// Review fix A3: the retained deferred launch record resolves
1977    /// `secret_env` config values, so NOTHING public/debug/diagnostic may
1978    /// ever reveal them — only booleans/counts. The record type itself is
1979    /// crate-private, field-private, and implements neither `Debug` nor
1980    /// `Serialize` (compiler-enforced); this test additionally proves the
1981    /// live diagnostic surfaces never carry the resolved secret.
1982    #[tokio::test]
1983    async fn deferred_launch_record_never_leaks_secret_config_through_diagnostics() {
1984        const SECRET: &str = "task20-A3-sentinel-5f2c9d";
1985        const VAR: &str = "SYNAPS_TEST_T20_A3_SECRET";
1986        std::env::set_var(VAR, SECRET);
1987
1988        let manifest: ExtensionManifest = serde_json::from_value(serde_json::json!({
1989            "runtime": "process",
1990            "command": "/bin/false",
1991            "permissions": ["tools.register"],
1992            "config": [{
1993                "key": "api_key",
1994                "type": "string",
1995                "required": true,
1996                "secret_env": VAR
1997            }],
1998            "deferred": {
1999                "tools": [{
2000                    "name": "search",
2001                    "description": "deferred search",
2002                    "input_schema": {"type": "object"}
2003                }]
2004            }
2005        }))
2006        .unwrap();
2007
2008        let registry = Arc::new(tokio::sync::RwLock::new(crate::ToolRegistry::new()));
2009        let mut mgr = ExtensionManager::new_with_tools(Arc::new(HookBus::new()), registry);
2010        mgr.set_progressive_deferral(true);
2011        mgr.load("secretive", &manifest).await.unwrap();
2012
2013        // Boolean/count-only public surface for the deferral state.
2014        assert!(mgr.is_deferred_tool_only("secretive"));
2015        assert_eq!(mgr.deferred_tool_only_count(), 1);
2016        // The internal record DID resolve the secret (lease path needs it).
2017        let record = mgr.deferred_launch_record("secretive").expect("retained");
2018        assert_eq!(record.config["api_key"], serde_json::json!(SECRET));
2019
2020        // Every public/debug/diagnostic surface stays free of the value.
2021        let mut rendered = String::new();
2022        rendered.push_str(&format!("{:?}", mgr.statuses().await));
2023        rendered.push_str(&format!("{:?}", mgr.config_diagnostics("secretive")));
2024        rendered.push_str(&format!("{:?}", mgr.all_config_diagnostics()));
2025        rendered.push_str(&format!("{:?}", mgr.capability_snapshots().await));
2026        rendered.push_str(&format!("{:?}", mgr.plugin_infos()));
2027        rendered.push_str(&format!("{:?}", mgr.list()));
2028        assert!(
2029            !rendered.contains(SECRET),
2030            "resolved secret config leaked through a public diagnostic surface"
2031        );
2032
2033        std::env::remove_var(VAR);
2034    }
2035
2036    #[tokio::test]
2037    async fn capability_snapshot_lists_hooks_for_loaded_extension() {
2038        let bus = Arc::new(HookBus::new());
2039        let mut mgr = ExtensionManager::new(bus.clone());
2040        let manifest = ExtensionManifest {
2041            theme_tokens: Default::default(),
2042            deferred: None,
2043            protocol_version: 1,
2044            runtime: crate::extensions::manifest::ExtensionRuntime::Process,
2045            command: "python3".to_string(),
2046            setup: None,
2047            prebuilt: ::std::collections::HashMap::new(),
2048            args: vec![
2049                "tests/fixtures/process_extension.py".to_string(),
2050                "normal".to_string(),
2051                "/tmp/synaps-capability-test.log".to_string(),
2052            ],
2053            permissions: vec!["tools.intercept".to_string()],
2054            hooks: vec![crate::extensions::manifest::HookSubscription {
2055                hook: "before_tool_call".to_string(),
2056                tool: Some("bash".to_string()),
2057                matcher: None,
2058            }],
2059            config: vec![],
2060        };
2061
2062        mgr.load("cap-snap", &manifest).await.unwrap();
2063
2064        let snaps = mgr.capability_snapshots().await;
2065        assert_eq!(snaps.len(), 1);
2066        let snap = &snaps[0];
2067        assert_eq!(snap.id, "cap-snap");
2068        assert_eq!(snap.hooks.len(), 1);
2069        assert_eq!(snap.hooks[0].kind, "before_tool_call");
2070        assert_eq!(snap.hooks[0].tool_filter.as_deref(), Some("bash"));
2071        assert!(snap.tools.is_empty());
2072        assert!(snap.providers.is_empty());
2073        assert!(snap.future.is_empty());
2074
2075        mgr.shutdown_all().await;
2076    }
2077
2078    #[tokio::test]
2079    async fn capability_snapshot_surfaces_seeded_capabilities() {
2080        let bus = Arc::new(HookBus::new());
2081        let mut mgr = ExtensionManager::new(bus.clone());
2082        let manifest = ExtensionManifest {
2083            theme_tokens: Default::default(),
2084            deferred: None,
2085            protocol_version: 1,
2086            runtime: crate::extensions::manifest::ExtensionRuntime::Process,
2087            command: "python3".to_string(),
2088            setup: None,
2089            prebuilt: ::std::collections::HashMap::new(),
2090            args: vec![
2091                "tests/fixtures/process_extension.py".to_string(),
2092                "normal".to_string(),
2093                "/tmp/synaps-capability-snapshot-test.log".to_string(),
2094            ],
2095            permissions: vec!["tools.intercept".to_string()],
2096            hooks: vec![crate::extensions::manifest::HookSubscription {
2097                hook: "before_tool_call".to_string(),
2098                tool: Some("bash".to_string()),
2099                matcher: None,
2100            }],
2101            config: vec![],
2102        };
2103
2104        mgr.load("multi-cap", &manifest).await.unwrap();
2105
2106        // Seed two capabilities of *different* kinds — proves the
2107        // snapshot rendering iterates a generic list and uses the
2108        // plugin-supplied `kind` rather than hardcoding any modality.
2109        mgr.test_seed_capabilities(
2110            "multi-cap",
2111            vec![
2112                crate::extensions::runtime::process::CapabilityDeclaration {
2113                    kind: "capture".to_string(),
2114                    name: "Local Sample STT".to_string(),
2115                    permissions: vec!["audio.input".to_string()],
2116                    params: serde_json::Value::Null,
2117                },
2118                crate::extensions::runtime::process::CapabilityDeclaration {
2119                    kind: "ocr".to_string(),
2120                    name: "Tesseract".to_string(),
2121                    permissions: vec![],
2122                    params: serde_json::Value::Null,
2123                },
2124            ],
2125        );
2126
2127        let snaps = mgr.capability_snapshots().await;
2128        let snap = snaps
2129            .iter()
2130            .find(|s| s.id == "multi-cap")
2131            .expect("multi-cap snapshot");
2132        assert_eq!(snap.future.len(), 2);
2133        let kinds: Vec<&str> = snap.future.iter().map(|e| e.kind.as_str()).collect();
2134        assert!(kinds.contains(&"capture"), "got kinds {:?}", kinds);
2135        assert!(kinds.contains(&"ocr"), "got kinds {:?}", kinds);
2136        let names: Vec<&str> = snap.future.iter().map(|e| e.name.as_str()).collect();
2137        assert!(names.contains(&"Local Sample STT"), "got {:?}", names);
2138        assert!(names.contains(&"Tesseract"), "got {:?}", names);
2139
2140        mgr.unload("multi-cap").await.unwrap();
2141        let snaps = mgr.capability_snapshots().await;
2142        assert!(snaps.iter().all(|s| s.id != "multi-cap"));
2143
2144        mgr.shutdown_all().await;
2145    }
2146
2147    #[tokio::test]
2148    async fn new_manager_has_no_extensions() {
2149        let bus = Arc::new(HookBus::new());
2150        let mgr = ExtensionManager::new(bus);
2151        assert_eq!(mgr.count(), 0);
2152        assert!(mgr.list().is_empty());
2153    }
2154
2155    #[tokio::test]
2156    async fn unload_nonexistent_returns_error() {
2157        let bus = Arc::new(HookBus::new());
2158        let mut mgr = ExtensionManager::new(bus);
2159        let result = mgr.unload("nope").await;
2160        assert!(result.is_err());
2161    }
2162
2163    #[tokio::test]
2164    async fn reload_unsubscribes_old_handler_before_loading_new_one() {
2165        let bus = Arc::new(HookBus::new());
2166        let mut mgr = ExtensionManager::new(bus.clone());
2167        let manifest = ExtensionManifest {
2168            theme_tokens: Default::default(),
2169            deferred: None,
2170            protocol_version: 1,
2171            runtime: crate::extensions::manifest::ExtensionRuntime::Process,
2172            command: "python3".to_string(),
2173            setup: None,
2174            prebuilt: ::std::collections::HashMap::new(),
2175            args: vec![
2176                "tests/fixtures/process_extension.py".to_string(),
2177                "normal".to_string(),
2178                "/tmp/synaps-reload-test.log".to_string(),
2179            ],
2180            permissions: vec!["tools.intercept".to_string()],
2181            hooks: vec![crate::extensions::manifest::HookSubscription {
2182                hook: "before_tool_call".to_string(),
2183                tool: Some("bash".to_string()),
2184                matcher: None,
2185            }],
2186            config: vec![],
2187        };
2188
2189        mgr.load("reload-test", &manifest).await.unwrap();
2190        assert_eq!(bus.handler_count().await, 1);
2191
2192        mgr.reload("reload-test", &manifest, None).await.unwrap();
2193
2194        assert_eq!(mgr.count(), 1);
2195        assert_eq!(bus.handler_count().await, 1);
2196        mgr.shutdown_all().await;
2197    }
2198
2199    #[tokio::test]
2200    async fn reload_failure_leaves_previous_instance_unloaded() {
2201        let bus = Arc::new(HookBus::new());
2202        let mut mgr = ExtensionManager::new(bus.clone());
2203        let good = ExtensionManifest {
2204            theme_tokens: Default::default(),
2205            deferred: None,
2206            protocol_version: 1,
2207            runtime: crate::extensions::manifest::ExtensionRuntime::Process,
2208            command: "python3".to_string(),
2209            setup: None,
2210            prebuilt: ::std::collections::HashMap::new(),
2211            args: vec![
2212                "tests/fixtures/process_extension.py".to_string(),
2213                "normal".to_string(),
2214                "/tmp/synaps-reload-failure-test.log".to_string(),
2215            ],
2216            permissions: vec!["tools.intercept".to_string()],
2217            hooks: vec![crate::extensions::manifest::HookSubscription {
2218                hook: "before_tool_call".to_string(),
2219                tool: Some("bash".to_string()),
2220                matcher: None,
2221            }],
2222            config: vec![],
2223        };
2224        let bad = ExtensionManifest {
2225            theme_tokens: Default::default(),
2226            deferred: None,
2227            command: "/definitely/not/a/real/extension-binary".to_string(),
2228            setup: None,
2229            prebuilt: ::std::collections::HashMap::new(),
2230            ..good.clone()
2231        };
2232
2233        mgr.load("reload-failure-test", &good).await.unwrap();
2234        let err = mgr
2235            .reload("reload-failure-test", &bad, None)
2236            .await
2237            .unwrap_err();
2238
2239        assert!(err.contains("Failed to spawn extension"), "{err}");
2240        assert_eq!(mgr.count(), 0);
2241        assert_eq!(bus.handler_count().await, 0);
2242    }
2243
2244    #[test]
2245    fn project_plugins_disable_env_parser_accepts_truthy_values() {
2246        for value in ["1", "true", "TRUE", "yes", "on"] {
2247            std::env::set_var("SYNAPS_DISABLE_PROJECT_PLUGINS", value);
2248            assert!(project_plugins_disabled());
2249        }
2250        for value in ["", "0", "false", "off", "no"] {
2251            std::env::set_var("SYNAPS_DISABLE_PROJECT_PLUGINS", value);
2252            assert!(!project_plugins_disabled());
2253        }
2254        std::env::remove_var("SYNAPS_DISABLE_PROJECT_PLUGINS");
2255    }
2256
2257    fn with_temp_base_dir<T>(path: &std::path::Path, f: impl FnOnce() -> T) -> T {
2258        let old_base_dir = std::env::var("SYNAPS_BASE_DIR").ok();
2259        crate::config::set_base_dir_for_tests(path.to_path_buf());
2260        let out = f();
2261        match old_base_dir {
2262            Some(old) => std::env::set_var("SYNAPS_BASE_DIR", old),
2263            None => std::env::remove_var("SYNAPS_BASE_DIR"),
2264        }
2265        out
2266    }
2267
2268    #[test]
2269    #[serial_test::serial(synaps_base_dir)]
2270    fn resolve_config_prefers_plugin_namespaced_config_before_legacy_global_key() {
2271        let dir = tempfile::tempdir().unwrap();
2272        with_temp_base_dir(dir.path(), || {
2273            crate::extensions::config_store::write_plugin_config(
2274                "sample-sidecar",
2275                "backend",
2276                "cpu",
2277            )
2278            .unwrap();
2279            crate::config::write_config_value("extension.sample-sidecar.backend", "auto").unwrap();
2280
2281            let resolved = ExtensionManager::resolve_config(
2282                "sample-sidecar",
2283                &[ExtensionConfigEntry {
2284                    key: "backend".to_string(),
2285                    value_type: None,
2286                    description: None,
2287                    required: true,
2288                    default: None,
2289                    secret_env: None,
2290                    host_context: None,
2291                }],
2292                None,
2293            )
2294            .unwrap();
2295
2296            assert_eq!(
2297                resolved["backend"],
2298                serde_json::Value::String("cpu".to_string())
2299            );
2300        });
2301    }
2302
2303    #[test]
2304    #[serial_test::serial(synaps_base_dir)]
2305    fn resolve_config_keeps_legacy_global_extension_key_as_fallback() {
2306        let dir = tempfile::tempdir().unwrap();
2307        with_temp_base_dir(dir.path(), || {
2308            crate::config::write_config_value("extension.sample-sidecar.backend", "auto").unwrap();
2309
2310            let resolved = ExtensionManager::resolve_config(
2311                "sample-sidecar",
2312                &[ExtensionConfigEntry {
2313                    key: "backend".to_string(),
2314                    value_type: None,
2315                    description: None,
2316                    required: true,
2317                    default: None,
2318                    secret_env: None,
2319                    host_context: None,
2320                }],
2321                None,
2322            )
2323            .unwrap();
2324
2325            assert_eq!(
2326                resolved["backend"],
2327                serde_json::Value::String("auto".to_string())
2328            );
2329        });
2330    }
2331
2332    #[tokio::test]
2333    async fn config_diagnostics_returns_none_for_unknown_extension() {
2334        let bus = Arc::new(HookBus::new());
2335        let mgr = ExtensionManager::new(bus);
2336        assert!(mgr.config_diagnostics("nope").is_none());
2337        assert!(mgr.all_config_diagnostics().is_empty());
2338    }
2339
2340    #[tokio::test]
2341    async fn config_diagnostics_reports_loaded_manifest_entries() {
2342        let bus = Arc::new(HookBus::new());
2343        let mut mgr = ExtensionManager::new(bus);
2344        let manifest = ExtensionManifest {
2345            theme_tokens: Default::default(),
2346            deferred: None,
2347            protocol_version: 1,
2348            runtime: crate::extensions::manifest::ExtensionRuntime::Process,
2349            command: "python3".to_string(),
2350            setup: None,
2351            prebuilt: ::std::collections::HashMap::new(),
2352            args: vec![
2353                "tests/fixtures/process_extension.py".to_string(),
2354                "normal".to_string(),
2355                "/tmp/synaps-config-diag-test.log".to_string(),
2356            ],
2357            permissions: vec!["tools.intercept".to_string()],
2358            hooks: vec![crate::extensions::manifest::HookSubscription {
2359                hook: "before_tool_call".to_string(),
2360                tool: Some("bash".to_string()),
2361                matcher: None,
2362            }],
2363            config: vec![crate::extensions::manifest::ExtensionConfigEntry {
2364                key: "region".to_string(),
2365                value_type: None,
2366                description: Some("AWS region".to_string()),
2367                required: false,
2368                default: Some(serde_json::Value::String("us-east-1".to_string())),
2369                secret_env: None,
2370                host_context: None,
2371            }],
2372        };
2373
2374        mgr.load("config-diag-test", &manifest).await.unwrap();
2375
2376        let diag = mgr
2377            .config_diagnostics("config-diag-test")
2378            .expect("diagnostics should be available for loaded extension");
2379        assert_eq!(diag.extension_id, "config-diag-test");
2380        assert_eq!(diag.entries.len(), 1);
2381        assert_eq!(diag.entries[0].key, "region");
2382        assert!(diag.entries[0].has_value);
2383        assert!(diag.provider_missing.is_empty());
2384
2385        let all = mgr.all_config_diagnostics();
2386        assert_eq!(all.len(), 1);
2387        assert_eq!(all[0].extension_id, "config-diag-test");
2388
2389        mgr.shutdown_all().await;
2390        // After shutdown, manifest config storage is cleared.
2391        assert!(mgr.config_diagnostics("config-diag-test").is_none());
2392    }
2393
2394    #[tokio::test]
2395    async fn provider_trust_view_is_empty_for_no_providers() {
2396        let bus = Arc::new(HookBus::new());
2397        let mgr = ExtensionManager::new(bus);
2398        let view = mgr.provider_trust_view();
2399        assert!(view.is_empty());
2400    }
2401
2402    #[tokio::test]
2403    async fn provider_tool_use_runtime_ids_lists_only_tool_use_capable() {
2404        use crate::extensions::runtime::process::{
2405            RegisteredProviderModelSpec, RegisteredProviderSpec,
2406        };
2407        let bus = Arc::new(HookBus::new());
2408        let mut mgr = ExtensionManager::new(bus);
2409        // Tool-use capable provider.
2410        let tool_spec = RegisteredProviderSpec {
2411            id: "alpha".into(),
2412            display_name: "Alpha".into(),
2413            description: "tool-use".into(),
2414            models: vec![RegisteredProviderModelSpec {
2415                id: "m1".into(),
2416                display_name: None,
2417                capabilities: serde_json::json!({"tool_use": true}),
2418                context_window: None,
2419            }],
2420            config_schema: None,
2421        };
2422        // Plain provider, no tool_use.
2423        let plain_spec = RegisteredProviderSpec {
2424            id: "beta".into(),
2425            display_name: "Beta".into(),
2426            description: "plain".into(),
2427            models: vec![RegisteredProviderModelSpec {
2428                id: "m1".into(),
2429                display_name: None,
2430                capabilities: serde_json::json!({"streaming": true}),
2431                context_window: None,
2432            }],
2433            config_schema: None,
2434        };
2435        mgr.providers.register("plug", tool_spec).unwrap();
2436        mgr.providers.register("plug", plain_spec).unwrap();
2437        let ids = mgr.provider_tool_use_runtime_ids();
2438        assert_eq!(ids, vec!["plug:alpha".to_string()]);
2439    }
2440
2441    // ---- compute_extension_load_hint --------------------------------
2442
2443    #[test]
2444    fn hint_missing_binary_with_declared_setup_points_at_script() {
2445        let hint = compute_extension_load_hint(
2446            "Failed to spawn extension 'sample-sidecar': No such file or directory (os error 2)",
2447            std::path::Path::new("/home/u/.synaps-cli/plugins/sample-sidecar"),
2448            Some("scripts/setup.sh"),
2449        );
2450        assert!(
2451            hint.contains("Extension binary missing"),
2452            "missing-binary case should be flagged: {hint}"
2453        );
2454        assert!(
2455            hint.contains("/home/u/.synaps-cli/plugins/sample-sidecar"),
2456            "hint should include the plugin dir: {hint}"
2457        );
2458        assert!(
2459            hint.contains("setup=scripts/setup.sh"),
2460            "hint should show sanitized setup path without copy-paste shell command: {hint}"
2461        );
2462    }
2463
2464    #[test]
2465    fn hint_missing_binary_without_declared_setup_falls_back_to_generic() {
2466        let hint = compute_extension_load_hint(
2467            "Failed to spawn extension 'foo': No such file or directory (os error 2)",
2468            std::path::Path::new("/x/y"),
2469            None,
2470        );
2471        assert!(
2472            hint.contains("plugin validate"),
2473            "no setup declared → generic hint: {hint}"
2474        );
2475        assert!(
2476            !hint.contains("Extension binary missing"),
2477            "should not falsely promise a setup script: {hint}"
2478        );
2479    }
2480
2481    #[test]
2482    fn hint_other_error_with_declared_setup_falls_back_to_generic() {
2483        let hint = compute_extension_load_hint(
2484            "Extension 'foo' must subscribe to at least one hook or request a registration permission",
2485            std::path::Path::new("/x/y"),
2486            Some("scripts/setup.sh"),
2487        );
2488        // Setup script is declared, but the error is *not* a missing
2489        // binary — running the script wouldn't help. Fall back to the
2490        // generic hint so we don't mislead the user.
2491        assert!(hint.contains("plugin validate"), "got {hint}");
2492        assert!(!hint.contains("Extension binary missing"), "got {hint}");
2493    }
2494
2495    #[test]
2496    fn hint_recognises_os_error_2_format() {
2497        // Older / cross-platform error formats may include the kernel
2498        // errno but not the "No such file or directory" English text.
2499        let hint = compute_extension_load_hint(
2500            "spawn failed (os error 2)",
2501            std::path::Path::new("/p"),
2502            Some("setup.sh"),
2503        );
2504        assert!(hint.contains("Extension binary missing"), "got {hint}");
2505    }
2506
2507    // ── bugfix: installed-plugin backup discovery ───────────────────────────
2508
2509    /// Minimal deferred tool-only plugin.json (Axel-shaped: `memory_fetch`
2510    /// takes a single `{id}`), written under `<dir>/.synaps-plugin/`.
2511    fn write_installed_plugin(plugins_root: &std::path::Path, dir_name: &str) {
2512        let plugin_dir = plugins_root.join(dir_name).join(".synaps-plugin");
2513        std::fs::create_dir_all(&plugin_dir).unwrap();
2514        let manifest = serde_json::json!({
2515            "extension": {
2516                "runtime": "process",
2517                "command": "/bin/false",
2518                "permissions": ["tools.register"],
2519                "deferred": {
2520                    "tools": [{
2521                        "name": "memory_fetch",
2522                        "description": "Fetch one memory by exact ID.",
2523                        "input_schema": {
2524                            "type": "object",
2525                            "properties": {"id": {"type": "string"}},
2526                            "required": ["id"]
2527                        }
2528                    }]
2529                }
2530            }
2531        });
2532        std::fs::write(
2533            plugin_dir.join("plugin.json"),
2534            serde_json::to_string_pretty(&manifest).unwrap(),
2535        )
2536        .unwrap();
2537    }
2538
2539    /// Regression (user incident, session 20260720-141844-fb5a): `plugins/`
2540    /// held the installed `axel-memory-manager` AND timestamped copies
2541    /// (`axel-memory-manager.backup.<TS>`). Discovery treated every
2542    /// directory with a valid manifest as a live plugin, so the backups
2543    /// were loaded and their binaries executed alongside the real one.
2544    /// Backup/disabled/temp directory names must never be discovered: only
2545    /// active plugins load, backup tools are never cataloged, and (since a
2546    /// backup that is never loaded owns no lease) a backup process can
2547    /// never be spawned.
2548    #[tokio::test]
2549    #[serial_test::serial(synaps_base_dir)]
2550    async fn discover_and_load_skips_backup_and_disabled_plugin_directories() {
2551        let base = crate::test_env::BaseDirGuard::new();
2552        let _no_project_plugins =
2553            crate::test_env::EnvVarGuard::set("SYNAPS_DISABLE_PROJECT_PLUGINS", "1");
2554        let plugins_root = base.path().join("plugins");
2555
2556        write_installed_plugin(&plugins_root, "axel-memory-manager");
2557        // The two timestamped backups from the incident, the updater's own
2558        // `.update-backup` convention, and common manual conventions.
2559        for inert in [
2560            "axel-memory-manager.backup.20260719210053",
2561            "axel-memory-manager.backup.20260720101416",
2562            "axel-memory-manager.update-backup",
2563            "axel-memory-manager.backup",
2564            "axel-memory-manager.bak",
2565            "axel-memory-manager.disabled",
2566            "axel-memory-manager.old",
2567            "axel-memory-manager.orig",
2568            "axel-memory-manager.tmp",
2569        ] {
2570            write_installed_plugin(&plugins_root, inert);
2571        }
2572        // Dotted names that are NOT backups stay discoverable.
2573        write_installed_plugin(&plugins_root, "com.example.notes");
2574
2575        let registry = Arc::new(tokio::sync::RwLock::new(
2576            crate::ToolRegistry::without_subagent(),
2577        ));
2578        let mut mgr = ExtensionManager::new_with_tools(Arc::new(HookBus::new()), registry.clone());
2579        mgr.set_progressive_deferral(true);
2580
2581        let (mut loaded, failed) = mgr.discover_and_load().await;
2582        loaded.sort();
2583
2584        assert!(failed.is_empty(), "no failures expected: {failed:?}");
2585        assert_eq!(
2586            loaded,
2587            vec![
2588                "axel-memory-manager".to_string(),
2589                "com.example.notes".to_string()
2590            ],
2591            "backup/disabled/temp plugin directories must not be discovered"
2592        );
2593
2594        let reg = registry.read().await;
2595        assert!(reg.get("axel-memory-manager:memory_fetch").is_some());
2596        assert!(reg.get("com.example.notes:memory_fetch").is_some());
2597        assert!(
2598            reg.get("axel-memory-manager.backup.20260719210053:memory_fetch")
2599                .is_none(),
2600            "backup plugin tools must never be cataloged"
2601        );
2602        assert!(reg
2603            .get("axel-memory-manager.backup.20260720101416:memory_fetch")
2604            .is_none());
2605        drop(reg);
2606
2607        // Deferred lifecycle + no backup load ⇒ zero live extension
2608        // processes were started by discovery.
2609        assert_eq!(mgr.count(), 0, "discovery must spawn nothing");
2610    }
2611
2612    /// The exclusion helper is name-based and conservative: it must catch
2613    /// known backup/disabled/temp conventions without swallowing
2614    /// legitimate dotted or hyphenated plugin names.
2615    #[test]
2616    fn inert_plugin_dir_name_filter_is_exact() {
2617        for inert in [
2618            "axel-memory-manager.backup.20260720101416",
2619            "axel-memory-manager.BACKUP.1",
2620            "axel-memory-manager.backup",
2621            "axel-memory-manager.update-backup",
2622            "axel-memory-manager.bak",
2623            "axel-memory-manager.disabled",
2624            "axel-memory-manager.old",
2625            "axel-memory-manager.orig",
2626            "axel-memory-manager.tmp",
2627            "axel-memory-manager.temp",
2628        ] {
2629            assert!(
2630                is_inert_plugin_dir_name(inert),
2631                "{inert} should be excluded from discovery"
2632            );
2633        }
2634        for live in [
2635            "axel-memory-manager",
2636            "com.example.notes",
2637            "backup-manager",
2638            "old-school-tools",
2639            "tmpfs-inspector",
2640            "my.backups.viewer",
2641        ] {
2642            assert!(
2643                !is_inert_plugin_dir_name(live),
2644                "{live} is a legitimate plugin name and must stay discoverable"
2645            );
2646        }
2647    }
2648}