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