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