Skip to main content

agent_engine/extensions/
lifecycle.rs

1//! Task 20 — capability-driven extension lifecycle (spec §7.5).
2//!
3//! Typed inventory/classification of extension manifests plus OPTIONAL,
4//! ADDITIVE passive `deferred` declarations. Absent declarations keep the
5//! documented legacy EAGER lifecycle byte-for-byte (no protocol version
6//! bump); present declarations are trusted local manifest expectations:
7//! they enable spawn deferral and MUST match the runtime's initialize
8//! declarations exactly (names, schemas, digests) before any registration
9//! or grant — a mismatch shuts the child down and fails closed.
10//!
11//! Nothing in this module spawns a process or touches the network: it
12//! reads bounded local manifest data and mints dormant descriptor tools.
13
14use std::collections::HashSet;
15use std::sync::Arc;
16
17use serde_json::Value;
18
19use super::manifest::ExtensionManifest;
20use super::runtime::process::RegisteredExtensionToolSpec;
21use crate::tools::catalog::SchemaDigest;
22use crate::tools::{Tool, ToolContext, ToolOrigin};
23use agent_core::BoundedText;
24
25/// Bounds for passive tool declarations (parity with the MCP descriptor
26/// cache budgets).
27pub const DECLARED_MAX_TOOLS: usize = 256;
28pub const DECLARED_MAX_PROVIDERS: usize = 16;
29pub const DECLARED_NAME_MAX_BYTES: usize = 128;
30pub const DECLARED_DESCRIPTION_MAX_BYTES: usize = 1024;
31pub const DECLARED_SCHEMA_MAX_BYTES: usize = 64 * 1024;
32/// Bound on passive memory/context contribution provider declarations
33/// (continuous-memory spec §7.1). Deliberately small: one extension has no
34/// legitimate reason to contribute many distinct context surfaces.
35pub const DECLARED_MAX_CONTEXT_PROVIDERS: usize = 8;
36
37/// OPTIONAL additive manifest block. Absent => legacy eager lifecycle.
38#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
39pub struct DeferredDeclarations {
40    /// EXACT tools the extension will register at initialize.
41    #[serde(default)]
42    pub tools: Vec<DeclaredExtensionTool>,
43    /// TYPED `RegisteredProviderSpec`-compatible provider metadata,
44    /// deeply bounded/validated here so malformed passive metadata can
45    /// never reach provider routing later.
46    #[serde(default)]
47    pub providers: Vec<DeclaredExtensionProvider>,
48    /// Optional lifecycle hint for extensions without tools/providers.
49    #[serde(default)]
50    pub lifecycle: Option<DeferredLifecycle>,
51    /// TYPED memory/context contribution provider declarations
52    /// (continuous-memory spec §7.1). ADDITIVE: absent => empty =>
53    /// byte-compatible with every existing manifest (`skip_serializing_if`
54    /// keeps serialized output identical too). COMPLETELY UNRELATED to
55    /// `providers` above (model/LLM provider metadata): a context provider
56    /// is dormant capability metadata that never spawns anything at load
57    /// and stays dormant until an exact memory-context lease (task A6) is
58    /// granted.
59    #[serde(default, skip_serializing_if = "Vec::is_empty")]
60    pub context_providers: Vec<DeclaredExtensionContextProvider>,
61}
62
63/// Passive provider declaration, field-compatible with the runtime's
64/// `RegisteredProviderSpec` so a validated declaration can later back
65/// metadata-only registration.
66#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
67pub struct DeclaredExtensionProvider {
68    pub id: String,
69    pub display_name: String,
70    #[serde(default)]
71    pub description: String,
72    #[serde(default)]
73    pub models: Vec<DeclaredExtensionProviderModel>,
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub config_schema: Option<Value>,
76}
77
78/// Field-compatible with `RegisteredProviderModelSpec`.
79#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
80pub struct DeclaredExtensionProviderModel {
81    pub id: String,
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub display_name: Option<String>,
84    #[serde(default)]
85    pub capabilities: Value,
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub context_window: Option<u64>,
88}
89
90/// Bounds for provider declarations.
91pub const DECLARED_MAX_PROVIDER_MODELS: usize = 64;
92pub const DECLARED_DISPLAY_MAX_BYTES: usize = 256;
93
94/// Passive MEMORY/CONTEXT contribution provider declaration
95/// (continuous-memory spec §7.1) — e.g. Axel's
96/// `extension:axel-memory-manager:project-memory`. DISTINCT from
97/// [`DeclaredExtensionProvider`] (model/LLM provider metadata): this
98/// declares a dormant context-contribution capability. Discovery, schema
99/// export, status inspection, and normal first prompts never spawn the
100/// owning extension; activation is exclusively a future exact
101/// memory-context lease (task A6).
102#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
103pub struct DeclaredExtensionContextProvider {
104    /// Local provider id (no ':'; the runtime address is composed as
105    /// `extension:<plugin>:<id>`). Bounded like other declared names.
106    pub id: String,
107    /// Capability label (e.g. "project-memory"), bounded like a name.
108    pub capability: String,
109    /// Human-readable description, bounded like other declared descriptions.
110    #[serde(default)]
111    pub description: String,
112    /// Declared context payload schema version; 0 is invalid (fail closed).
113    pub schema_version: u32,
114}
115
116#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
117#[serde(rename_all = "lowercase")]
118pub enum DeferredLifecycle {
119    /// Start only on the first authorized subscribed hook event.
120    Hook,
121    /// Start only on explicit user action (UI/sidecar class).
122    User,
123}
124
125/// One passively declared tool: exactly the fields the runtime will
126/// register at initialize, pinned locally so activation can validate the
127/// live declaration against operator-known expectations.
128#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
129pub struct DeclaredExtensionTool {
130    pub name: String,
131    #[serde(default)]
132    pub description: String,
133    pub input_schema: Value,
134}
135
136/// Typed extension lifecycle classes (spec §7.5).
137#[derive(Debug, Clone, Copy, PartialEq, Eq)]
138pub enum ExtensionClass {
139    /// No usable passive declarations: documented eager compatibility.
140    LegacyEager,
141    ToolOnly,
142    Provider,
143    HookLifecycle,
144    UiSidecar,
145    /// ONLY memory/context contribution providers declared
146    /// (continuous-memory spec §7.1): dormant until an exact
147    /// memory-context lease is granted (task A6).
148    ContextProvider,
149    Mixed,
150}
151
152/// Whether a deferred block declares at least one memory/context
153/// contribution provider (continuous-memory spec §7.1).
154pub fn has_context_provider(declared: &DeferredDeclarations) -> bool {
155    !declared.context_providers.is_empty()
156}
157
158/// Classify a manifest from its passive declarations. Pure and local.
159pub fn classify(manifest: &ExtensionManifest) -> ExtensionClass {
160    let Some(deferred) = &manifest.deferred else {
161        return ExtensionClass::LegacyEager;
162    };
163    let tools = !deferred.tools.is_empty();
164    let providers = !deferred.providers.is_empty();
165    let context = has_context_provider(deferred);
166    let hooks = !manifest.hooks.is_empty() || deferred.lifecycle == Some(DeferredLifecycle::Hook);
167    let user = deferred.lifecycle == Some(DeferredLifecycle::User);
168    // With `context == false` this is EXACTLY the pre-context-provider
169    // matrix: existing manifests classify byte-for-byte as before.
170    match (tools, providers, context, hooks, user) {
171        (false, false, false, false, false) => ExtensionClass::LegacyEager,
172        (true, false, false, false, false) => ExtensionClass::ToolOnly,
173        (false, true, false, false, false) => ExtensionClass::Provider,
174        (false, false, true, false, false) => ExtensionClass::ContextProvider,
175        (false, false, false, true, false) => ExtensionClass::HookLifecycle,
176        (false, false, false, false, true) => ExtensionClass::UiSidecar,
177        _ => ExtensionClass::Mixed,
178    }
179}
180
181/// The earliest legitimately required activation trigger for a manifest
182/// (spec 7.5). Mixed extensions use the EARLIEST of their components —
183/// hook events fire passively during any turn, provider selection is an
184/// explicit routing choice, exact tool activation comes last; tool SEARCH
185/// alone never triggers a start.
186#[derive(Debug, Clone, Copy, PartialEq, Eq)]
187pub enum ActivationTrigger {
188    Eager,
189    FirstAuthorizedHookEvent,
190    ProviderSelection,
191    ExactToolActivation,
192    UserAction,
193}
194
195pub fn earliest_trigger(manifest: &ExtensionManifest) -> ActivationTrigger {
196    match classify(manifest) {
197        ExtensionClass::LegacyEager => ActivationTrigger::Eager,
198        ExtensionClass::ToolOnly => ActivationTrigger::ExactToolActivation,
199        ExtensionClass::Provider => ActivationTrigger::ProviderSelection,
200        ExtensionClass::HookLifecycle => ActivationTrigger::FirstAuthorizedHookEvent,
201        ExtensionClass::UiSidecar => ActivationTrigger::UserAction,
202        // A context provider is dormant until an exact memory-context
203        // lease, which only an explicit host-authorized user action (e.g.
204        // `/memory on`, task A6) can grant — never a hook, provider
205        // selection, or tool search.
206        ExtensionClass::ContextProvider => ActivationTrigger::UserAction,
207        ExtensionClass::Mixed => {
208            let deferred = manifest.deferred.as_ref();
209            let hooks = !manifest.hooks.is_empty()
210                || deferred.and_then(|d| d.lifecycle) == Some(DeferredLifecycle::Hook);
211            let providers = deferred.map(|d| !d.providers.is_empty()).unwrap_or(false);
212            if hooks {
213                ActivationTrigger::FirstAuthorizedHookEvent
214            } else if providers {
215                ActivationTrigger::ProviderSelection
216            } else {
217                ActivationTrigger::ExactToolActivation
218            }
219        }
220    }
221}
222
223fn valid_name(name: &str) -> bool {
224    !name.is_empty() && name.len() <= DECLARED_NAME_MAX_BYTES && !name.chars().any(char::is_control)
225}
226
227/// Bounded validation of a `deferred` block. Static reasons only; any
228/// violation fails the WHOLE manifest closed (validated before any spawn).
229pub fn validate_deferred(deferred: &DeferredDeclarations) -> Result<(), &'static str> {
230    if deferred.tools.len() > DECLARED_MAX_TOOLS {
231        return Err("too_many_declared_tools");
232    }
233    let mut seen: HashSet<&str> = HashSet::new();
234    for tool in &deferred.tools {
235        validate_tool_shape(&tool.name, &tool.description, &tool.input_schema)?;
236        if !seen.insert(tool.name.as_str()) {
237            return Err("duplicate_declared_tool_name");
238        }
239    }
240    if deferred.providers.len() > DECLARED_MAX_PROVIDERS {
241        return Err("too_many_declared_providers");
242    }
243    let mut provider_ids: HashSet<&str> = HashSet::new();
244    for provider in &deferred.providers {
245        if !valid_name(&provider.id) || provider.id.contains(':') {
246            return Err("invalid_declared_provider_id");
247        }
248        if !provider_ids.insert(provider.id.as_str()) {
249            return Err("duplicate_declared_provider_id");
250        }
251        if provider.display_name.is_empty()
252            || provider.display_name.len() > DECLARED_DISPLAY_MAX_BYTES
253            || provider.display_name.chars().any(char::is_control)
254        {
255            return Err("invalid_declared_provider_display_name");
256        }
257        if provider.description.is_empty()
258            || provider.description.len() > DECLARED_DESCRIPTION_MAX_BYTES
259        {
260            return Err("invalid_declared_provider_description");
261        }
262        if provider.models.is_empty() || provider.models.len() > DECLARED_MAX_PROVIDER_MODELS {
263            return Err("invalid_declared_provider_model_count");
264        }
265        let mut model_ids: HashSet<&str> = HashSet::new();
266        for model in &provider.models {
267            if !valid_name(&model.id) {
268                return Err("invalid_declared_provider_model_id");
269            }
270            if !model_ids.insert(model.id.as_str()) {
271                return Err("duplicate_declared_provider_model_id");
272            }
273            if let Some(display) = &model.display_name {
274                if display.len() > DECLARED_DISPLAY_MAX_BYTES
275                    || display.chars().any(char::is_control)
276                {
277                    return Err("invalid_declared_provider_model_display_name");
278                }
279            }
280            // Capabilities: absent (null) or an object of boolean flags.
281            match &model.capabilities {
282                Value::Null => {}
283                Value::Object(map) => {
284                    for (key, value) in map {
285                        if !valid_name(key) || !value.is_boolean() {
286                            return Err("invalid_declared_provider_model_capabilities");
287                        }
288                    }
289                }
290                _ => return Err("invalid_declared_provider_model_capabilities"),
291            }
292        }
293        if let Some(config_schema) = &provider.config_schema {
294            let len = serde_json::to_vec(config_schema)
295                .map(|b| b.len())
296                .unwrap_or(usize::MAX);
297            if !config_schema.is_object() || len > DECLARED_SCHEMA_MAX_BYTES {
298                return Err("invalid_declared_provider_config_schema");
299            }
300        }
301    }
302    // Memory/context contribution providers (continuous-memory spec §7.1):
303    // bounded exactly like the other declared types; any violation fails
304    // the WHOLE manifest closed.
305    if deferred.context_providers.len() > DECLARED_MAX_CONTEXT_PROVIDERS {
306        return Err("too_many_declared_context_providers");
307    }
308    let mut context_provider_ids: HashSet<&str> = HashSet::new();
309    for provider in &deferred.context_providers {
310        // The validated newtype is the single id policy source: non-empty,
311        // bounded, ASCII-safe, no ':' (runtime address composability).
312        if super::context_provider::ContextProviderId::parse(&provider.id).is_err() {
313            return Err("invalid_declared_context_provider_id");
314        }
315        if !context_provider_ids.insert(provider.id.as_str()) {
316            return Err("duplicate_declared_context_provider_id");
317        }
318        if !valid_name(&provider.capability) {
319            return Err("invalid_declared_context_provider_capability");
320        }
321        if provider.description.is_empty()
322            || provider.description.len() > DECLARED_DESCRIPTION_MAX_BYTES
323        {
324            return Err("invalid_declared_context_provider_description");
325        }
326        if provider.schema_version == 0 {
327            return Err("invalid_declared_context_provider_schema_version");
328        }
329    }
330    // Lifecycle conflict policy: `user` means user-triggered ONLY — it must
331    // never silently cover active tool/provider capabilities.
332    if deferred.lifecycle == Some(DeferredLifecycle::User)
333        && (!deferred.tools.is_empty() || !deferred.providers.is_empty())
334    {
335        return Err("user_lifecycle_conflicts_with_active_capabilities");
336    }
337    Ok(())
338}
339
340/// Manifest-level coupling checks for a `deferred` block (review fix A1/
341/// A2). Passive declarations are CAPABILITY claims: cataloging a dormant
342/// tool descriptor (or provider metadata) grants the extension future
343/// runtime reach, so it MUST be backed by the exact permission the live
344/// registration would require — otherwise a manifest could obtain
345/// discoverable/activatable surface it was never authorized to register.
346/// Likewise `lifecycle = "hook"` without a real manifest hook subscription
347/// has NO authorized trigger and can never legitimately start: it fails
348/// closed here instead of lingering as unreachable-but-cataloged state.
349///
350/// Runs inside `ExtensionManifest::validate` — i.e. strictly BEFORE any
351/// spawn or catalog registration on every load path.
352pub fn validate_manifest_deferred(manifest: &ExtensionManifest) -> Result<(), &'static str> {
353    let Some(deferred) = &manifest.deferred else {
354        return Ok(());
355    };
356    validate_deferred(deferred)?;
357    let has_permission = |wire: &str| manifest.permissions.iter().any(|p| p == wire);
358    if !deferred.tools.is_empty() && !has_permission("tools.register") {
359        return Err("deferred_tools_require_tools_register_permission");
360    }
361    if !deferred.providers.is_empty() && !has_permission("providers.register") {
362        return Err("deferred_providers_require_providers_register_permission");
363    }
364    // Mirror of the provider gate above for memory/context contribution
365    // providers (continuous-memory spec §7.1): dormant descriptor metadata
366    // still grants future runtime reach, so it MUST be backed by the exact
367    // permission the eventual lease-backed registration would require.
368    if !deferred.context_providers.is_empty() && !has_permission("context_providers.register") {
369        return Err("deferred_context_providers_require_context_providers_register_permission");
370    }
371    if deferred.lifecycle == Some(DeferredLifecycle::Hook) && manifest.hooks.is_empty() {
372        return Err("hook_lifecycle_requires_hook_subscriptions");
373    }
374    Ok(())
375}
376
377/// Shared bounded shape check for a tool identity (declared OR runtime
378/// registered). Declared tool names additionally must not contain ':'
379/// so the `<plugin>:<tool>` runtime name stays reverse-parseable.
380fn validate_tool_shape(
381    name: &str,
382    description: &str,
383    input_schema: &Value,
384) -> Result<(), &'static str> {
385    if !valid_name(name) || name.contains(':') {
386        return Err("invalid_declared_tool_name");
387    }
388    // Non-empty required: the runtime's initialize validation refuses
389    // empty descriptions, so an empty declaration could never match a
390    // live registration — fail closed at manifest time instead.
391    if description.trim().is_empty() || description.len() > DECLARED_DESCRIPTION_MAX_BYTES {
392        return Err("invalid_declared_tool_description");
393    }
394    if !input_schema.is_object() {
395        return Err("declared_tool_schema_not_object");
396    }
397    let schema_len = serde_json::to_vec(input_schema)
398        .map(|b| b.len())
399        .unwrap_or(usize::MAX);
400    if schema_len > DECLARED_SCHEMA_MAX_BYTES {
401        return Err("oversized_declared_tool_schema");
402    }
403    Ok(())
404}
405
406/// Strict initialize-time declaration check: the runtime's registered tool
407/// specs must match the manifest's passive declarations EXACTLY — same
408/// name set (no missing, no undeclared extras) and identical canonical
409/// schema digests. Static reasons only; callers shut the child down and
410/// fail closed on any mismatch, before any registration or grant.
411pub fn validate_runtime_tool_declarations(
412    declared: &[DeclaredExtensionTool],
413    registered: &[RegisteredExtensionToolSpec],
414) -> Result<(), &'static str> {
415    if declared.len() != registered.len() {
416        return Err("declared_and_registered_tool_counts_differ");
417    }
418    for spec in registered {
419        // The runtime's own declaration must pass the same bounded shape
420        // policy BEFORE any comparison is trusted.
421        validate_tool_shape(&spec.name, &spec.description, &spec.input_schema)?;
422    }
423    for decl in declared {
424        let Some(live) = registered.iter().find(|spec| spec.name == decl.name) else {
425            return Err("declared_tool_not_registered");
426        };
427        if SchemaDigest::of_schema(&live.input_schema)
428            != SchemaDigest::of_schema(&decl.input_schema)
429        {
430            return Err("registered_tool_schema_digest_mismatch");
431        }
432        // Full-match policy: descriptions are part of the declared
433        // contract (model-visible prompt surface), so they must be equal.
434        if live.description != decl.description {
435            return Err("registered_tool_description_mismatch");
436        }
437    }
438    // Equal counts + every declared found => no undeclared extras (names
439    // are unique per validate_deferred; duplicates in `registered` would
440    // fail the count/find pass above).
441    let mut live_names: HashSet<&str> = HashSet::new();
442    for spec in registered {
443        if !live_names.insert(spec.name.as_str()) {
444            return Err("registered_tool_names_duplicate");
445        }
446    }
447    Ok(())
448}
449
450/// Strict initialize-time PROVIDER declaration check (Task 20 Commit B):
451/// the runtime's registered provider specs must match the manifest's
452/// passive declarations EXACTLY — same id set, display names,
453/// descriptions, model id sets, per-model display/context-window/
454/// capability values, and config schemas as declared. A manifest that
455/// declares NO providers must see NO registered providers (a tool-only
456/// runtime sneaking provider metadata in is an undeclared capability).
457/// Static reasons only; callers shut the child down and fail closed.
458pub fn validate_runtime_provider_declarations(
459    declared: &[DeclaredExtensionProvider],
460    registered: &[crate::extensions::runtime::process::RegisteredProviderSpec],
461) -> Result<(), &'static str> {
462    if declared.len() != registered.len() {
463        return Err("declared_and_registered_provider_counts_differ");
464    }
465    let mut live_ids: HashSet<&str> = HashSet::new();
466    for spec in registered {
467        if !live_ids.insert(spec.id.as_str()) {
468            return Err("registered_provider_ids_duplicate");
469        }
470    }
471    for decl in declared {
472        let Some(live) = registered.iter().find(|spec| spec.id == decl.id) else {
473            return Err("declared_provider_not_registered");
474        };
475        if live.display_name != decl.display_name {
476            return Err("registered_provider_display_name_mismatch");
477        }
478        if live.description != decl.description {
479            return Err("registered_provider_description_mismatch");
480        }
481        if live.config_schema != decl.config_schema {
482            return Err("registered_provider_config_schema_mismatch");
483        }
484        if live.models.len() != decl.models.len() {
485            return Err("registered_provider_model_counts_differ");
486        }
487        let mut live_model_ids: HashSet<&str> = HashSet::new();
488        for model in &live.models {
489            if !live_model_ids.insert(model.id.as_str()) {
490                return Err("registered_provider_model_ids_duplicate");
491            }
492        }
493        for declared_model in &decl.models {
494            let Some(live_model) = live.models.iter().find(|m| m.id == declared_model.id) else {
495                return Err("declared_provider_model_not_registered");
496            };
497            if live_model.display_name != declared_model.display_name {
498                return Err("registered_provider_model_display_name_mismatch");
499            }
500            if live_model.context_window != declared_model.context_window {
501                return Err("registered_provider_model_context_window_mismatch");
502            }
503            if live_model.capabilities != declared_model.capabilities {
504                return Err("registered_provider_model_capabilities_mismatch");
505            }
506        }
507    }
508    Ok(())
509}
510
511impl DeclaredExtensionProvider {
512    /// Convert a VALIDATED passive declaration into the runtime's
513    /// `RegisteredProviderSpec` shape for metadata-only registration
514    /// (Task 20 Commit C). Field-for-field: routing later re-validates the
515    /// live runtime's registrations against these same declarations.
516    pub fn to_registered_spec(
517        &self,
518    ) -> crate::extensions::runtime::process::RegisteredProviderSpec {
519        crate::extensions::runtime::process::RegisteredProviderSpec {
520            id: self.id.clone(),
521            display_name: self.display_name.clone(),
522            description: self.description.clone(),
523            models: self
524                .models
525                .iter()
526                .map(
527                    |model| crate::extensions::runtime::process::RegisteredProviderModelSpec {
528                        id: model.id.clone(),
529                        display_name: model.display_name.clone(),
530                        capabilities: model.capabilities.clone(),
531                        context_window: model.context_window,
532                    },
533                )
534                .collect(),
535            config_schema: self.config_schema.clone(),
536        }
537    }
538}
539
540/// A dormant, manifest-declared extension tool. Registered like any live
541/// tool (stable `ext:<plugin>:<name>` identity, schema, digest — so wire
542/// resolution, discovery, exact grants, and the execution gate all work
543/// unchanged), but its implementation is DEFERRED: executing it requires
544/// the session extension-runtime lease capability (lease lifecycle
545/// commit), which starts only the OWNING extension after the execution
546/// gate has already authorized the call. Without that capability it fails
547/// typed and spawns nothing.
548pub struct DeferredExtensionTool {
549    plugin_id: String,
550    runtime_name: String,
551    tool_name: String,
552    description: String,
553    input_schema: Value,
554    expected_digest: SchemaDigest,
555}
556
557impl DeferredExtensionTool {
558    fn new(plugin_id: &str, declared: &DeclaredExtensionTool) -> Self {
559        Self {
560            plugin_id: plugin_id.to_string(),
561            // Naming parity with live registration ("<plugin>:<tool>").
562            runtime_name: format!("{}:{}", plugin_id, declared.name),
563            tool_name: declared.name.clone(),
564            description: BoundedText::new(&declared.description, DECLARED_DESCRIPTION_MAX_BYTES)
565                .text,
566            input_schema: declared.input_schema.clone(),
567            expected_digest: SchemaDigest::of_schema(&declared.input_schema),
568        }
569    }
570
571    pub fn plugin_id(&self) -> &str {
572        &self.plugin_id
573    }
574
575    pub fn tool_name(&self) -> &str {
576        &self.tool_name
577    }
578
579    /// Digest of the pinned declaration — equal by construction to this
580    /// tool's catalog record digest.
581    pub fn expected_digest(&self) -> &SchemaDigest {
582        &self.expected_digest
583    }
584}
585
586#[async_trait::async_trait]
587impl Tool for DeferredExtensionTool {
588    fn origin(&self) -> ToolOrigin {
589        ToolOrigin::Extension {
590            extension_id: self.plugin_id.clone(),
591        }
592    }
593
594    fn name(&self) -> &str {
595        &self.runtime_name
596    }
597
598    fn description(&self) -> &str {
599        &self.description
600    }
601
602    fn parameters(&self) -> Value {
603        self.input_schema.clone()
604    }
605
606    async fn execute(&self, params: Value, ctx: ToolContext) -> crate::Result<String> {
607        // Runs strictly AFTER the ExecutionGate authorized this exact call.
608        // Without the typed session lease capability, a deferred extension
609        // tool NEVER starts a process.
610        let Some(leases) = ctx.capabilities.extension_leases.clone() else {
611            return Err(crate::RuntimeError::Tool(format!(
612                "extension tool '{}' (plugin '{}') is activation-deferred and no extension \
613                 runtime lease capability is available in this context; no process was started",
614                self.runtime_name, self.plugin_id
615            )));
616        };
617        match leases
618            .call_exact(
619                &self.plugin_id,
620                &self.tool_name,
621                &self.expected_digest,
622                params,
623            )
624            .await
625        {
626            Ok(value) => Ok(render_tool_result(&value)),
627            Err(err) => {
628                // Grant invalidation (spec §7.5): record removal, manifest/
629                // permission re-validation failure, launch-record (config)
630                // drift, catalog drift, and runtime declaration mismatches
631                // poison the pinned declaration, so the EXACT session grant
632                // must fall with the lease. Transport/capacity/revocation-
633                // race and extension-reported tool errors are transient and
634                // must NOT revoke.
635                if err.revokes_exact_grant() {
636                    if let Some(activation) = ctx.capabilities.tool_activation.as_ref() {
637                        if activation.revoke_exact_extension_grant(
638                            leases.session(),
639                            &self.plugin_id,
640                            &self.tool_name,
641                        ) {
642                            tracing::debug!(
643                                "revoked exact extension activation after declaration invalidation"
644                            );
645                        }
646                    }
647                }
648                Err(crate::RuntimeError::Tool(err.to_string()))
649            }
650        }
651    }
652}
653
654/// Render an extension tool-call result value exactly like the eager
655/// `ExtensionTool` path: plain string, `content` string field, or the
656/// serialized value.
657fn render_tool_result(value: &Value) -> String {
658    if let Some(text) = value.as_str() {
659        text.to_string()
660    } else if let Some(text) = value.get("content").and_then(Value::as_str) {
661        text.to_string()
662    } else {
663        value.to_string()
664    }
665}
666
667impl DeclaredExtensionTool {
668    /// Stable canonical schema digest of this declaration. VISIBILITY-ONLY
669    /// metadata (diagnostics/inventory); grants and gate checks always
670    /// recompute from catalog records, never trust this accessor.
671    pub fn schema_digest(&self) -> SchemaDigest {
672        SchemaDigest::of_schema(&self.input_schema)
673    }
674}
675
676/// Build the dormant deferred tools for one manifest. Deterministic
677/// (declaration order); returns nothing for manifests without validated
678/// declarations — descriptors are never invented. Never spawns.
679pub fn dormant_extension_tools(
680    plugin_id: &str,
681    manifest: &ExtensionManifest,
682) -> Vec<Arc<dyn Tool>> {
683    // Plugin ids with ':' would break `<plugin>:<tool>` reverse identity.
684    if plugin_id.is_empty() || plugin_id.contains(':') || plugin_id.chars().any(char::is_control) {
685        return Vec::new();
686    }
687    let Some(deferred) = &manifest.deferred else {
688        return Vec::new();
689    };
690    // Full manifest-level policy (bounds + permission/hook coupling), not
691    // just block bounds: dormant descriptors minted for a manifest that
692    // lacks `tools.register` would be an unauthorized catalog grant.
693    if validate_manifest_deferred(manifest).is_err() {
694        return Vec::new();
695    }
696    deferred
697        .tools
698        .iter()
699        .map(|declared| Arc::new(DeferredExtensionTool::new(plugin_id, declared)) as Arc<dyn Tool>)
700        .collect()
701}
702
703#[cfg(test)]
704mod tests {
705    use super::*;
706    use serde_json::json;
707
708    fn base_manifest(permissions: &[&str]) -> ExtensionManifest {
709        serde_json::from_value(json!({
710            "runtime": "process",
711            "command": "/bin/false",
712            "permissions": permissions,
713        }))
714        .unwrap()
715    }
716
717    fn declared_tool(name: &str) -> DeclaredExtensionTool {
718        DeclaredExtensionTool {
719            name: name.to_string(),
720            description: format!("declared {name}"),
721            input_schema: json!({"type": "object"}),
722        }
723    }
724
725    fn declared_context_provider(id: &str) -> DeclaredExtensionContextProvider {
726        DeclaredExtensionContextProvider {
727            id: id.to_string(),
728            capability: "project-memory".to_string(),
729            description: "Project memory context contributions".to_string(),
730            schema_version: 1,
731        }
732    }
733
734    fn with_context_providers(
735        permissions: &[&str],
736        context_providers: Vec<DeclaredExtensionContextProvider>,
737    ) -> ExtensionManifest {
738        let mut m = base_manifest(permissions);
739        m.deferred = Some(DeferredDeclarations {
740            context_providers,
741            ..DeferredDeclarations::default()
742        });
743        m
744    }
745
746    // ── regression proof: empty/absent context_providers change NOTHING ─
747
748    #[test]
749    fn absent_or_empty_deferred_blocks_classify_exactly_as_before() {
750        // No deferred block at all.
751        let legacy = base_manifest(&["tools.register"]);
752        assert!(legacy.deferred.is_none());
753        assert_eq!(classify(&legacy), ExtensionClass::LegacyEager);
754        assert_eq!(earliest_trigger(&legacy), ActivationTrigger::Eager);
755        legacy.validate("legacy").unwrap();
756
757        // Empty deferred block (context_providers defaulted empty).
758        let mut empty = base_manifest(&["tools.register"]);
759        empty.deferred = Some(DeferredDeclarations::default());
760        assert!(empty
761            .deferred
762            .as_ref()
763            .is_some_and(|d| d.context_providers.is_empty() && !has_context_provider(d)));
764        assert_eq!(classify(&empty), ExtensionClass::LegacyEager);
765        assert_eq!(earliest_trigger(&empty), ActivationTrigger::Eager);
766        empty.validate("empty").unwrap();
767
768        // A deferred block WITHOUT the context_providers key deserializes
769        // to the empty default (additive/byte-compatible) and does not
770        // reappear on serialization.
771        let d: DeferredDeclarations = serde_json::from_value(json!({
772            "tools": [], "providers": []
773        }))
774        .unwrap();
775        assert!(d.context_providers.is_empty());
776        let round = serde_json::to_value(&d).unwrap();
777        assert!(round.get("context_providers").is_none());
778
779        // Pre-existing classes are untouched when context_providers is
780        // empty: tool-only stays ToolOnly...
781        let mut tool_only = base_manifest(&["tools.register"]);
782        tool_only.deferred = Some(DeferredDeclarations {
783            tools: vec![declared_tool("t")],
784            ..DeferredDeclarations::default()
785        });
786        assert_eq!(classify(&tool_only), ExtensionClass::ToolOnly);
787        // ...user lifecycle stays UiSidecar...
788        let mut sidecar = base_manifest(&["tools.register"]);
789        sidecar.deferred = Some(DeferredDeclarations {
790            lifecycle: Some(DeferredLifecycle::User),
791            ..DeferredDeclarations::default()
792        });
793        assert_eq!(classify(&sidecar), ExtensionClass::UiSidecar);
794        // ...and hook subscriptions stay HookLifecycle.
795        let mut hooky: ExtensionManifest = serde_json::from_value(json!({
796            "runtime": "process",
797            "command": "/bin/false",
798            "permissions": ["tools.intercept"],
799            "hooks": [{"hook": "before_tool_call"}]
800        }))
801        .unwrap();
802        hooky.deferred = Some(DeferredDeclarations::default());
803        assert_eq!(classify(&hooky), ExtensionClass::HookLifecycle);
804    }
805
806    // ── context-provider-only classification + permission gate ──────────
807
808    #[test]
809    fn context_provider_only_manifest_classifies_distinctly_and_requires_permission() {
810        let m = with_context_providers(
811            &["context_providers.register"],
812            vec![declared_context_provider("project-memory")],
813        );
814        assert!(m.deferred.as_ref().is_some_and(has_context_provider));
815        assert_eq!(classify(&m), ExtensionClass::ContextProvider);
816        // Dormant until an explicit user/host action grants a lease —
817        // never eager, never tool-search/hook/provider triggered.
818        assert_eq!(earliest_trigger(&m), ActivationTrigger::UserAction);
819        m.validate("axel-memory-manager").unwrap();
820
821        // WITHOUT the exact permission the whole manifest fails closed.
822        let ungated = with_context_providers(
823            &["tools.register"],
824            vec![declared_context_provider("project-memory")],
825        );
826        let err = ungated.validate("axel-memory-manager").unwrap_err();
827        assert!(
828            err.contains(
829                "deferred_context_providers_require_context_providers_register_permission"
830            ),
831            "{err}"
832        );
833        // The model/LLM provider permission does NOT cover it.
834        let wrong_gate = with_context_providers(
835            &["providers.register"],
836            vec![declared_context_provider("project-memory")],
837        );
838        assert!(wrong_gate.validate("axel-memory-manager").is_err());
839    }
840
841    #[test]
842    fn mixed_combinations_with_context_providers_still_classify_mixed() {
843        // tools + context providers => Mixed (both permissions required).
844        let mut m = with_context_providers(
845            &["tools.register", "context_providers.register"],
846            vec![declared_context_provider("project-memory")],
847        );
848        m.deferred.as_mut().unwrap().tools = vec![declared_tool("t")];
849        assert_eq!(classify(&m), ExtensionClass::Mixed);
850        m.validate("plug").unwrap();
851        // Tool activation stays the Mixed trigger — context providers
852        // never advance a Mixed extension's earliest trigger.
853        assert_eq!(earliest_trigger(&m), ActivationTrigger::ExactToolActivation);
854    }
855
856    // ── bounds: fail the WHOLE manifest closed ─────────────────────────
857
858    #[test]
859    fn too_many_declared_context_providers_are_rejected() {
860        let at_cap: Vec<_> = (0..DECLARED_MAX_CONTEXT_PROVIDERS)
861            .map(|i| declared_context_provider(&format!("p{i}")))
862            .collect();
863        with_context_providers(&["context_providers.register"], at_cap)
864            .validate("plug")
865            .unwrap();
866
867        let over: Vec<_> = (0..=DECLARED_MAX_CONTEXT_PROVIDERS)
868            .map(|i| declared_context_provider(&format!("p{i}")))
869            .collect();
870        let m = with_context_providers(&["context_providers.register"], over);
871        assert_eq!(
872            validate_deferred(m.deferred.as_ref().unwrap()),
873            Err("too_many_declared_context_providers")
874        );
875        assert!(m.validate("plug").is_err());
876    }
877
878    #[test]
879    fn oversized_or_hostile_context_provider_fields_are_rejected() {
880        let check = |p: DeclaredExtensionContextProvider, reason: &'static str| {
881            let m = with_context_providers(&["context_providers.register"], vec![p]);
882            assert_eq!(validate_deferred(m.deferred.as_ref().unwrap()), Err(reason));
883            assert!(m.validate("plug").is_err());
884        };
885        // Oversized id (one byte over the declared-name budget).
886        check(
887            declared_context_provider(&"i".repeat(DECLARED_NAME_MAX_BYTES + 1)),
888            "invalid_declared_context_provider_id",
889        );
890        // Empty and colon ids fail through the same newtype policy.
891        check(
892            declared_context_provider(""),
893            "invalid_declared_context_provider_id",
894        );
895        check(
896            declared_context_provider("has:colon"),
897            "invalid_declared_context_provider_id",
898        );
899        // Oversized / empty / control-char capability.
900        let mut p = declared_context_provider("p");
901        p.capability = "c".repeat(DECLARED_NAME_MAX_BYTES + 1);
902        check(p, "invalid_declared_context_provider_capability");
903        let mut p = declared_context_provider("p");
904        p.capability = String::new();
905        check(p, "invalid_declared_context_provider_capability");
906        let mut p = declared_context_provider("p");
907        p.capability = "ctrl\u{7}".to_string();
908        check(p, "invalid_declared_context_provider_capability");
909        // Oversized / empty description.
910        let mut p = declared_context_provider("p");
911        p.description = "d".repeat(DECLARED_DESCRIPTION_MAX_BYTES + 1);
912        check(p, "invalid_declared_context_provider_description");
913        let mut p = declared_context_provider("p");
914        p.description = String::new();
915        check(p, "invalid_declared_context_provider_description");
916        // Zero schema version.
917        let mut p = declared_context_provider("p");
918        p.schema_version = 0;
919        check(p, "invalid_declared_context_provider_schema_version");
920        // Duplicate ids.
921        let m = with_context_providers(
922            &["context_providers.register"],
923            vec![
924                declared_context_provider("dup"),
925                declared_context_provider("dup"),
926            ],
927        );
928        assert_eq!(
929            validate_deferred(m.deferred.as_ref().unwrap()),
930            Err("duplicate_declared_context_provider_id")
931        );
932        // Exact-cap fields pass (bounds are exact, not fuzzy).
933        let mut p = declared_context_provider(&"i".repeat(DECLARED_NAME_MAX_BYTES));
934        p.capability = "c".repeat(DECLARED_NAME_MAX_BYTES);
935        p.description = "d".repeat(DECLARED_DESCRIPTION_MAX_BYTES);
936        with_context_providers(&["context_providers.register"], vec![p])
937            .validate("plug")
938            .unwrap();
939    }
940
941    // ── dormant descriptors: fail closed without the permission ─────────
942
943    #[test]
944    fn dormant_descriptors_are_minted_only_for_validated_gated_manifests() {
945        use super::super::context_provider::dormant_context_provider_descriptors;
946        let m = with_context_providers(
947            &["context_providers.register"],
948            vec![declared_context_provider("project-memory")],
949        );
950        let descriptors = dormant_context_provider_descriptors("axel-memory-manager", &m);
951        assert_eq!(descriptors.len(), 1);
952        assert_eq!(
953            descriptors[0].runtime_address(),
954            "extension:axel-memory-manager:project-memory"
955        );
956        assert_eq!(descriptors[0].schema_version(), 1);
957
958        // Missing permission => NOTHING is minted (unauthorized dormant
959        // metadata would still be future runtime reach).
960        let ungated = with_context_providers(
961            &["tools.register"],
962            vec![declared_context_provider("project-memory")],
963        );
964        assert!(dormant_context_provider_descriptors("axel-memory-manager", &ungated).is_empty());
965        // Colon plugin ids would break the composed runtime address.
966        assert!(dormant_context_provider_descriptors("bad:plugin", &m).is_empty());
967        // No deferred block => nothing.
968        assert!(
969            dormant_context_provider_descriptors("p", &base_manifest(&["tools.register"]))
970                .is_empty()
971        );
972    }
973}