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        let binding = ctx
628            .capabilities
629            .memory_backend
630            .clone()
631            .unwrap_or_else(crate::memory_backend::MemoryBinding::configured_current);
632        if binding.exclusive() && crate::memory_backend::competing_note_tool(&self.tool_name) {
633            return Err(crate::RuntimeError::Tool("independent extension memory is disabled by the selected host backend; use short memory tools".into()));
634        }
635        // Without the typed session lease capability, a deferred extension
636        // tool NEVER starts a process.
637        let Some(leases) = ctx.capabilities.extension_leases.clone() else {
638            return Err(crate::RuntimeError::Tool(format!(
639                "extension tool '{}' (plugin '{}') is activation-deferred and no extension \
640                 runtime lease capability is available in this context; no process was started",
641                self.runtime_name, self.plugin_id
642            )));
643        };
644        match leases
645            .call_exact(
646                &self.plugin_id,
647                &self.tool_name,
648                &self.expected_digest,
649                params,
650            )
651            .await
652        {
653            Ok(value) => Ok(render_tool_result(&value)),
654            Err(err) => {
655                // Grant invalidation (spec §7.5): record removal, manifest/
656                // permission re-validation failure, launch-record (config)
657                // drift, catalog drift, and runtime declaration mismatches
658                // poison the pinned declaration, so the EXACT session grant
659                // must fall with the lease. Transport/capacity/revocation-
660                // race and extension-reported tool errors are transient and
661                // must NOT revoke.
662                if err.revokes_exact_grant() {
663                    if let Some(activation) = ctx.capabilities.tool_activation.as_ref() {
664                        if activation.revoke_exact_extension_grant(
665                            leases.session(),
666                            &self.plugin_id,
667                            &self.tool_name,
668                        ) {
669                            tracing::debug!(
670                                "revoked exact extension activation after declaration invalidation"
671                            );
672                        }
673                    }
674                }
675                Err(crate::RuntimeError::Tool(err.to_string()))
676            }
677        }
678    }
679}
680
681/// Render an extension tool-call result value exactly like the eager
682/// `ExtensionTool` path: plain string, `content` string field, or the
683/// serialized value.
684fn render_tool_result(value: &Value) -> String {
685    if let Some(text) = value.as_str() {
686        text.to_string()
687    } else if let Some(text) = value.get("content").and_then(Value::as_str) {
688        text.to_string()
689    } else {
690        value.to_string()
691    }
692}
693
694impl DeclaredExtensionTool {
695    /// Stable canonical schema digest of this declaration. VISIBILITY-ONLY
696    /// metadata (diagnostics/inventory); grants and gate checks always
697    /// recompute from catalog records, never trust this accessor.
698    pub fn schema_digest(&self) -> SchemaDigest {
699        SchemaDigest::of_schema(&self.input_schema)
700    }
701}
702
703/// Build the dormant deferred tools for one manifest. Deterministic
704/// (declaration order); returns nothing for manifests without validated
705/// declarations — descriptors are never invented. Never spawns.
706pub fn dormant_extension_tools(
707    plugin_id: &str,
708    manifest: &ExtensionManifest,
709) -> Vec<Arc<dyn Tool>> {
710    // Plugin ids with ':' would break `<plugin>:<tool>` reverse identity.
711    if plugin_id.is_empty() || plugin_id.contains(':') || plugin_id.chars().any(char::is_control) {
712        return Vec::new();
713    }
714    let Some(deferred) = &manifest.deferred else {
715        return Vec::new();
716    };
717    // Full manifest-level policy (bounds + permission/hook coupling), not
718    // just block bounds: dormant descriptors minted for a manifest that
719    // lacks `tools.register` would be an unauthorized catalog grant.
720    if validate_manifest_deferred(manifest).is_err() {
721        return Vec::new();
722    }
723    deferred
724        .tools
725        .iter()
726        .map(|declared| Arc::new(DeferredExtensionTool::new(plugin_id, declared)) as Arc<dyn Tool>)
727        .collect()
728}
729
730#[cfg(test)]
731mod tests {
732    use super::*;
733    use serde_json::json;
734
735    fn base_manifest(permissions: &[&str]) -> ExtensionManifest {
736        serde_json::from_value(json!({
737            "runtime": "process",
738            "command": "/bin/false",
739            "permissions": permissions,
740        }))
741        .unwrap()
742    }
743
744    fn declared_tool(name: &str) -> DeclaredExtensionTool {
745        DeclaredExtensionTool {
746            name: name.to_string(),
747            description: format!("declared {name}"),
748            input_schema: json!({"type": "object"}),
749        }
750    }
751
752    fn declared_context_provider(id: &str) -> DeclaredExtensionContextProvider {
753        DeclaredExtensionContextProvider {
754            id: id.to_string(),
755            capability: "project-memory".to_string(),
756            description: "Project memory context contributions".to_string(),
757            schema_version: 1,
758        }
759    }
760
761    fn with_context_providers(
762        permissions: &[&str],
763        context_providers: Vec<DeclaredExtensionContextProvider>,
764    ) -> ExtensionManifest {
765        let mut m = base_manifest(permissions);
766        m.deferred = Some(DeferredDeclarations {
767            context_providers,
768            ..DeferredDeclarations::default()
769        });
770        m
771    }
772
773    // ── regression proof: empty/absent context_providers change NOTHING ─
774
775    #[test]
776    fn absent_or_empty_deferred_blocks_classify_exactly_as_before() {
777        // No deferred block at all.
778        let legacy = base_manifest(&["tools.register"]);
779        assert!(legacy.deferred.is_none());
780        assert_eq!(classify(&legacy), ExtensionClass::LegacyEager);
781        assert_eq!(earliest_trigger(&legacy), ActivationTrigger::Eager);
782        legacy.validate("legacy").unwrap();
783
784        // Empty deferred block (context_providers defaulted empty).
785        let mut empty = base_manifest(&["tools.register"]);
786        empty.deferred = Some(DeferredDeclarations::default());
787        assert!(empty
788            .deferred
789            .as_ref()
790            .is_some_and(|d| d.context_providers.is_empty() && !has_context_provider(d)));
791        assert_eq!(classify(&empty), ExtensionClass::LegacyEager);
792        assert_eq!(earliest_trigger(&empty), ActivationTrigger::Eager);
793        empty.validate("empty").unwrap();
794
795        // A deferred block WITHOUT the context_providers key deserializes
796        // to the empty default (additive/byte-compatible) and does not
797        // reappear on serialization.
798        let d: DeferredDeclarations = serde_json::from_value(json!({
799            "tools": [], "providers": []
800        }))
801        .unwrap();
802        assert!(d.context_providers.is_empty());
803        let round = serde_json::to_value(&d).unwrap();
804        assert!(round.get("context_providers").is_none());
805
806        // Pre-existing classes are untouched when context_providers is
807        // empty: tool-only stays ToolOnly...
808        let mut tool_only = base_manifest(&["tools.register"]);
809        tool_only.deferred = Some(DeferredDeclarations {
810            tools: vec![declared_tool("t")],
811            ..DeferredDeclarations::default()
812        });
813        assert_eq!(classify(&tool_only), ExtensionClass::ToolOnly);
814        // ...user lifecycle stays UiSidecar...
815        let mut sidecar = base_manifest(&["tools.register"]);
816        sidecar.deferred = Some(DeferredDeclarations {
817            lifecycle: Some(DeferredLifecycle::User),
818            ..DeferredDeclarations::default()
819        });
820        assert_eq!(classify(&sidecar), ExtensionClass::UiSidecar);
821        // ...and hook subscriptions stay HookLifecycle.
822        let mut hooky: ExtensionManifest = serde_json::from_value(json!({
823            "runtime": "process",
824            "command": "/bin/false",
825            "permissions": ["tools.intercept"],
826            "hooks": [{"hook": "before_tool_call"}]
827        }))
828        .unwrap();
829        hooky.deferred = Some(DeferredDeclarations::default());
830        assert_eq!(classify(&hooky), ExtensionClass::HookLifecycle);
831    }
832
833    // ── context-provider-only classification + permission gate ──────────
834
835    #[test]
836    fn context_provider_only_manifest_classifies_distinctly_and_requires_permission() {
837        let m = with_context_providers(
838            &["context_providers.register"],
839            vec![declared_context_provider("project-memory")],
840        );
841        assert!(m.deferred.as_ref().is_some_and(has_context_provider));
842        assert_eq!(classify(&m), ExtensionClass::ContextProvider);
843        // Dormant until an explicit user/host action grants a lease —
844        // never eager, never tool-search/hook/provider triggered.
845        assert_eq!(earliest_trigger(&m), ActivationTrigger::UserAction);
846        m.validate("axel-memory-manager").unwrap();
847
848        // WITHOUT the exact permission the whole manifest fails closed.
849        let ungated = with_context_providers(
850            &["tools.register"],
851            vec![declared_context_provider("project-memory")],
852        );
853        let err = ungated.validate("axel-memory-manager").unwrap_err();
854        assert!(
855            err.contains(
856                "deferred_context_providers_require_context_providers_register_permission"
857            ),
858            "{err}"
859        );
860        // The model/LLM provider permission does NOT cover it.
861        let wrong_gate = with_context_providers(
862            &["providers.register"],
863            vec![declared_context_provider("project-memory")],
864        );
865        assert!(wrong_gate.validate("axel-memory-manager").is_err());
866    }
867
868    #[test]
869    fn mixed_combinations_with_context_providers_still_classify_mixed() {
870        // tools + context providers => Mixed (both permissions required).
871        let mut m = with_context_providers(
872            &["tools.register", "context_providers.register"],
873            vec![declared_context_provider("project-memory")],
874        );
875        m.deferred.as_mut().unwrap().tools = vec![declared_tool("t")];
876        assert_eq!(classify(&m), ExtensionClass::Mixed);
877        m.validate("plug").unwrap();
878        // Tool activation stays the Mixed trigger — context providers
879        // never advance a Mixed extension's earliest trigger.
880        assert_eq!(earliest_trigger(&m), ActivationTrigger::ExactToolActivation);
881    }
882
883    // ── bounds: fail the WHOLE manifest closed ─────────────────────────
884
885    #[test]
886    fn too_many_declared_context_providers_are_rejected() {
887        let at_cap: Vec<_> = (0..DECLARED_MAX_CONTEXT_PROVIDERS)
888            .map(|i| declared_context_provider(&format!("p{i}")))
889            .collect();
890        with_context_providers(&["context_providers.register"], at_cap)
891            .validate("plug")
892            .unwrap();
893
894        let over: Vec<_> = (0..=DECLARED_MAX_CONTEXT_PROVIDERS)
895            .map(|i| declared_context_provider(&format!("p{i}")))
896            .collect();
897        let m = with_context_providers(&["context_providers.register"], over);
898        assert_eq!(
899            validate_deferred(m.deferred.as_ref().unwrap()),
900            Err("too_many_declared_context_providers")
901        );
902        assert!(m.validate("plug").is_err());
903    }
904
905    #[test]
906    fn oversized_or_hostile_context_provider_fields_are_rejected() {
907        let check = |p: DeclaredExtensionContextProvider, reason: &'static str| {
908            let m = with_context_providers(&["context_providers.register"], vec![p]);
909            assert_eq!(validate_deferred(m.deferred.as_ref().unwrap()), Err(reason));
910            assert!(m.validate("plug").is_err());
911        };
912        // Oversized id (one byte over the declared-name budget).
913        check(
914            declared_context_provider(&"i".repeat(DECLARED_NAME_MAX_BYTES + 1)),
915            "invalid_declared_context_provider_id",
916        );
917        // Empty and colon ids fail through the same newtype policy.
918        check(
919            declared_context_provider(""),
920            "invalid_declared_context_provider_id",
921        );
922        check(
923            declared_context_provider("has:colon"),
924            "invalid_declared_context_provider_id",
925        );
926        // Oversized / empty / control-char capability.
927        let mut p = declared_context_provider("p");
928        p.capability = "c".repeat(DECLARED_NAME_MAX_BYTES + 1);
929        check(p, "invalid_declared_context_provider_capability");
930        let mut p = declared_context_provider("p");
931        p.capability = String::new();
932        check(p, "invalid_declared_context_provider_capability");
933        let mut p = declared_context_provider("p");
934        p.capability = "ctrl\u{7}".to_string();
935        check(p, "invalid_declared_context_provider_capability");
936        // Oversized / empty description.
937        let mut p = declared_context_provider("p");
938        p.description = "d".repeat(DECLARED_DESCRIPTION_MAX_BYTES + 1);
939        check(p, "invalid_declared_context_provider_description");
940        let mut p = declared_context_provider("p");
941        p.description = String::new();
942        check(p, "invalid_declared_context_provider_description");
943        // Zero schema version.
944        let mut p = declared_context_provider("p");
945        p.schema_version = 0;
946        check(p, "invalid_declared_context_provider_schema_version");
947        // Duplicate ids.
948        let m = with_context_providers(
949            &["context_providers.register"],
950            vec![
951                declared_context_provider("dup"),
952                declared_context_provider("dup"),
953            ],
954        );
955        assert_eq!(
956            validate_deferred(m.deferred.as_ref().unwrap()),
957            Err("duplicate_declared_context_provider_id")
958        );
959        // Exact-cap fields pass (bounds are exact, not fuzzy).
960        let mut p = declared_context_provider(&"i".repeat(DECLARED_NAME_MAX_BYTES));
961        p.capability = "c".repeat(DECLARED_NAME_MAX_BYTES);
962        p.description = "d".repeat(DECLARED_DESCRIPTION_MAX_BYTES);
963        with_context_providers(&["context_providers.register"], vec![p])
964            .validate("plug")
965            .unwrap();
966    }
967
968    // ── dormant descriptors: fail closed without the permission ─────────
969
970    #[test]
971    fn dormant_descriptors_are_minted_only_for_validated_gated_manifests() {
972        use super::super::context_provider::dormant_context_provider_descriptors;
973        let m = with_context_providers(
974            &["context_providers.register"],
975            vec![declared_context_provider("project-memory")],
976        );
977        let descriptors = dormant_context_provider_descriptors("axel-memory-manager", &m);
978        assert_eq!(descriptors.len(), 1);
979        assert_eq!(
980            descriptors[0].runtime_address(),
981            "extension:axel-memory-manager:project-memory"
982        );
983        assert_eq!(descriptors[0].schema_version(), 1);
984
985        // Missing permission => NOTHING is minted (unauthorized dormant
986        // metadata would still be future runtime reach).
987        let ungated = with_context_providers(
988            &["tools.register"],
989            vec![declared_context_provider("project-memory")],
990        );
991        assert!(dormant_context_provider_descriptors("axel-memory-manager", &ungated).is_empty());
992        // Colon plugin ids would break the composed runtime address.
993        assert!(dormant_context_provider_descriptors("bad:plugin", &m).is_empty());
994        // No deferred block => nothing.
995        assert!(
996            dormant_context_provider_descriptors("p", &base_manifest(&["tools.register"]))
997                .is_empty()
998        );
999    }
1000
1001    // ── T291 D4: manifest-time ids must be validated by the SAME function
1002    // the runtime uses at initialize, or names pass here and die there.
1003
1004    /// Every name that manifest validation accepts for a declared tool MUST
1005    /// also be accepted by the runtime's `validate_id_segment`, and vice
1006    /// versa. This is the anti-drift invariant; the two checks used to
1007    /// disagree on length (128 vs 64) and on whitespace.
1008    #[test]
1009    fn declared_capability_ids_agree_with_runtime_validation() {
1010        let cases = [
1011            "ok",
1012            "with-dash",
1013            "with_underscore",
1014            "MiXedCase123",
1015            "",
1016            "has space",
1017            "has\ttab",
1018            "colon:name",
1019            "trailing ",
1020            &"a".repeat(super::super::validation::MAX_ID_LENGTH),
1021            &"a".repeat(super::super::validation::MAX_ID_LENGTH + 1),
1022            &"a".repeat(DECLARED_NAME_MAX_BYTES),
1023        ];
1024        for name in cases {
1025            let manifest_ok = validate_tool_shape(name, "d", &json!({"type": "object"})).is_ok();
1026            let runtime_ok = validate_id_segment(name).is_ok();
1027            assert_eq!(
1028                manifest_ok, runtime_ok,
1029                "manifest/runtime disagree on declared tool name {name:?}: \
1030                 manifest_ok={manifest_ok} runtime_ok={runtime_ok}"
1031            );
1032        }
1033    }
1034
1035    /// The exact defect from the S287 audit: a 65-byte name sat in the gap
1036    /// between DECLARED_NAME_MAX_BYTES (128) and MAX_ID_LENGTH (64). It
1037    /// passed manifest validation and then failed at initialize with an
1038    /// unrelated error. It must now fail early, at the manifest.
1039    #[test]
1040    fn declared_tool_name_over_runtime_bound_fails_at_manifest() {
1041        let name = "a".repeat(super::super::validation::MAX_ID_LENGTH + 1);
1042        assert_eq!(
1043            validate_deferred(&DeferredDeclarations {
1044                tools: vec![declared_tool(&name)],
1045                ..Default::default()
1046            }),
1047            Err("invalid_declared_tool_name"),
1048        );
1049        // ...and it is genuinely inside the old accepted range, i.e. this
1050        // test would have failed before the fix.
1051        assert!(name.len() <= DECLARED_NAME_MAX_BYTES);
1052    }
1053
1054    /// Whitespace was the second, unfiled half of the same defect.
1055    #[test]
1056    fn declared_capability_ids_reject_whitespace_at_manifest() {
1057        assert_eq!(
1058            validate_deferred(&DeferredDeclarations {
1059                tools: vec![declared_tool("my tool")],
1060                ..Default::default()
1061            }),
1062            Err("invalid_declared_tool_name"),
1063        );
1064    }
1065}