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