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