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